mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-07-04 13:23:15 +02:00
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>
This commit is contained in:
parent
52068bb687
commit
abd4ba3bd7
321
lib/data/note_template.dart
Normal file
321
lib/data/note_template.dart
Normal file
@ -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<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');
|
||||||
|
}
|
||||||
@ -4,9 +4,11 @@ import 'package:uuid/uuid.dart';
|
|||||||
|
|
||||||
import '../data/note.dart';
|
import '../data/note.dart';
|
||||||
import '../data/note_repository.dart';
|
import '../data/note_repository.dart';
|
||||||
|
import '../data/note_template.dart';
|
||||||
import '../sync/github_client.dart';
|
import '../sync/github_client.dart';
|
||||||
import '../sync/sync_service.dart';
|
import '../sync/sync_service.dart';
|
||||||
import '../sync/sync_settings.dart';
|
import '../sync/sync_settings.dart';
|
||||||
|
import 'note_editor.dart';
|
||||||
import 'notes_list_screen.dart';
|
import 'notes_list_screen.dart';
|
||||||
import 'settings_screen.dart';
|
import 'settings_screen.dart';
|
||||||
|
|
||||||
@ -34,31 +36,12 @@ class CaptureScreen extends StatefulWidget {
|
|||||||
class _CaptureScreenState extends State<CaptureScreen> {
|
class _CaptureScreenState extends State<CaptureScreen> {
|
||||||
static const _uuid = Uuid();
|
static const _uuid = Uuid();
|
||||||
|
|
||||||
/// Placeholder for the note's title line; selected on reset so the first
|
/// Latest assembled text from the editor; persisted on change and re-saved
|
||||||
/// keystroke replaces it.
|
/// when only priority/status change.
|
||||||
static const _titlePlaceholder = '<imperative title>';
|
String _draftText = '';
|
||||||
|
|
||||||
/// The structured scaffold pre-filled into every new note (see the
|
/// Bumped on save to recreate the editor with a fresh, empty template.
|
||||||
/// `<work_backlog>` format). Pre-filling beats a hint because the em-dashes
|
int _editorGeneration = 0;
|
||||||
/// 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();
|
|
||||||
|
|
||||||
/// Id of the note currently being edited, or null before the first
|
/// Id of the note currently being edited, or null before the first
|
||||||
/// keystroke of a fresh draft.
|
/// keystroke of a fresh draft.
|
||||||
@ -78,36 +61,11 @@ class _CaptureScreenState extends State<CaptureScreen> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_resetToTemplate();
|
|
||||||
SyncSettings.load().then((s) {
|
SyncSettings.load().then((s) {
|
||||||
if (mounted) setState(() => _settings = 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.
|
/// Opens the settings screen and adopts any saved configuration.
|
||||||
Future<void> _openSettings() async {
|
Future<void> _openSettings() async {
|
||||||
final current = _settings ?? await SyncSettings.load();
|
final current = _settings ?? await SyncSettings.load();
|
||||||
@ -168,10 +126,11 @@ class _CaptureScreenState extends State<CaptureScreen> {
|
|||||||
/// Persists the current text on every change. Creates the note row on
|
/// Persists the current text on every change. Creates the note row on
|
||||||
/// the first non-empty keystroke so empty drafts never hit storage.
|
/// the first non-empty keystroke so empty drafts never hit storage.
|
||||||
Future<void> _onChanged(String text) async {
|
Future<void> _onChanged(String text) async {
|
||||||
|
_draftText = text;
|
||||||
if (_draftId == null) {
|
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, so
|
||||||
// a note is only created once the user actually fills something in.
|
// an empty template (no section typed yet) never hits storage.
|
||||||
if (text.isEmpty || _isPristine(text)) return;
|
if (text.trim().isEmpty) return;
|
||||||
_draftId = _uuid.v4();
|
_draftId = _uuid.v4();
|
||||||
_draftCreatedAt = DateTime.now();
|
_draftCreatedAt = DateTime.now();
|
||||||
}
|
}
|
||||||
@ -210,7 +169,7 @@ class _CaptureScreenState extends State<CaptureScreen> {
|
|||||||
await widget.repository.upsert(
|
await widget.repository.upsert(
|
||||||
Note(
|
Note(
|
||||||
id: _draftId!,
|
id: _draftId!,
|
||||||
text: _controller.text,
|
text: _draftText,
|
||||||
priority: _draftPriority,
|
priority: _draftPriority,
|
||||||
status: _draftStatus,
|
status: _draftStatus,
|
||||||
createdAt: _draftCreatedAt!,
|
createdAt: _draftCreatedAt!,
|
||||||
@ -225,14 +184,14 @@ class _CaptureScreenState extends State<CaptureScreen> {
|
|||||||
// A note was actually persisted only if a draft row was created.
|
// A note was actually persisted only if a draft row was created.
|
||||||
final saved = _draftId != null;
|
final saved = _draftId != null;
|
||||||
setState(() {
|
setState(() {
|
||||||
_resetToTemplate();
|
_editorGeneration++; // recreate the editor with a fresh template
|
||||||
|
_draftText = '';
|
||||||
_draftId = null;
|
_draftId = null;
|
||||||
_draftCreatedAt = null;
|
_draftCreatedAt = null;
|
||||||
_lastSavedAt = null;
|
_lastSavedAt = null;
|
||||||
_draftPriority = Priority.defaultValue;
|
_draftPriority = Priority.defaultValue;
|
||||||
_draftStatus = Status.todo;
|
_draftStatus = Status.todo;
|
||||||
});
|
});
|
||||||
_focusNode.requestFocus();
|
|
||||||
if (saved) {
|
if (saved) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
@ -316,19 +275,10 @@ class _CaptureScreenState extends State<CaptureScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TextField(
|
child: NoteEditor(
|
||||||
controller: _controller,
|
key: ValueKey(_editorGeneration),
|
||||||
focusNode: _focusNode,
|
initialTemplate: NoteTemplate.defaultTemplate,
|
||||||
autofocus: true,
|
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,
|
onChanged: _onChanged,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
105
lib/ui/markdown_view.dart
Normal file
105
lib/ui/markdown_view.dart
Normal file
@ -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 = <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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
141
lib/ui/note_detail_screen.dart
Normal file
141
lib/ui/note_detail_screen.dart
Normal file
@ -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<NoteDetailScreen> createState() => _NoteDetailScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NoteDetailScreenState extends State<NoteDetailScreen> {
|
||||||
|
late Note _note = widget.note;
|
||||||
|
|
||||||
|
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 {
|
||||||
|
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<Priority>(
|
||||||
|
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<Status>(
|
||||||
|
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<T> 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<T> values;
|
||||||
|
final String Function(T) labelOf;
|
||||||
|
final ValueChanged<T> onChanged;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return DropdownButtonFormField<T>(
|
||||||
|
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<T>(value: v, child: Text(labelOf(v))),
|
||||||
|
],
|
||||||
|
onChanged: (v) {
|
||||||
|
if (v != null) onChanged(v);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
404
lib/ui/note_editor.dart
Normal file
404
lib/ui/note_editor.dart
Normal file
@ -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<String> 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<NoteEditor> createState() => _NoteEditorState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NoteEditorState extends State<NoteEditor> {
|
||||||
|
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<String, TextEditingController> _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<String, String> 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<String>(
|
||||||
|
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<NoteEditorMode>(
|
||||||
|
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();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,6 +4,8 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
import '../data/note.dart';
|
import '../data/note.dart';
|
||||||
import '../data/note_repository.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
|
/// The default status selection: hide completed/dropped work. This is the
|
||||||
/// app's notion of "unfiltered", so it does not count towards the filter
|
/// app's notion of "unfiltered", so it does not count towards the filter
|
||||||
@ -106,7 +108,17 @@ class _NotesListScreenState extends State<NotesListScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Opens the per-note actions sheet (priority, status, delete).
|
/// Opens the full note: read it, edit the body, change priority/status.
|
||||||
|
Future<void> _openNote(Note note) async {
|
||||||
|
await Navigator.of(context).push<void>(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) =>
|
||||||
|
NoteDetailScreen(note: note, repository: widget.repository),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens the per-note quick-actions sheet (priority, status, delete).
|
||||||
Future<void> _openNoteActions(Note note) async {
|
Future<void> _openNoteActions(Note note) async {
|
||||||
await showModalBottomSheet<void>(
|
await showModalBottomSheet<void>(
|
||||||
context: context,
|
context: context,
|
||||||
@ -218,7 +230,8 @@ class _NotesListScreenState extends State<NotesListScreen> {
|
|||||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||||
itemBuilder: (context, i) => _NoteTile(
|
itemBuilder: (context, i) => _NoteTile(
|
||||||
note: notes[i],
|
note: notes[i],
|
||||||
onTap: () => _openNoteActions(notes[i]),
|
onTap: () => _openNote(notes[i]),
|
||||||
|
onActions: () => _openNoteActions(notes[i]),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@ -232,14 +245,23 @@ class _NotesListScreenState extends State<NotesListScreen> {
|
|||||||
|
|
||||||
/// One row in the notes list: first line, then a metadata subtitle.
|
/// One row in the notes list: first line, then a metadata subtitle.
|
||||||
class _NoteTile extends StatelessWidget {
|
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;
|
final Note note;
|
||||||
|
|
||||||
|
/// Open the full note for reading/editing.
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
/// Open the quick-actions sheet (priority/status/delete).
|
||||||
|
final VoidCallback onActions;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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.
|
// Every note has a status and a priority now, so both are always shown.
|
||||||
final meta = <String>[
|
final meta = <String>[
|
||||||
note.status.label,
|
note.status.label,
|
||||||
@ -253,8 +275,13 @@ class _NoteTile extends StatelessWidget {
|
|||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
subtitle: Text(meta),
|
subtitle: Text(meta),
|
||||||
trailing: const Icon(Icons.more_vert),
|
trailing: IconButton(
|
||||||
|
icon: const Icon(Icons.more_vert),
|
||||||
|
tooltip: 'Quick actions',
|
||||||
|
onPressed: onActions,
|
||||||
|
),
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
|
onLongPress: onActions,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -300,7 +327,7 @@ class _NoteActionsSheetState extends State<_NoteActionsSheet> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final firstLine = widget.note.text.split('\n').first;
|
final firstLine = noteTitle(widget.note.text);
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||||
|
|||||||
@ -44,12 +44,15 @@ void main() {
|
|||||||
'sync.token': 'tok',
|
'sync.token': 'tok',
|
||||||
};
|
};
|
||||||
|
|
||||||
testWidgets('pre-fills the structured template', (tester) async {
|
testWidgets('opens the guided editor with section guidance', (tester) async {
|
||||||
await pumpCapture(tester);
|
await pumpCapture(tester);
|
||||||
|
|
||||||
expect(find.textContaining('<imperative title>'), findsOneWidget);
|
// The guided stepper shows the design-spec sections and the title step's
|
||||||
expect(find.textContaining('what —'), findsOneWidget);
|
// guidance, with no note persisted yet.
|
||||||
expect(find.textContaining('done —'), findsOneWidget);
|
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);
|
expect(find.text('0 saved'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -67,10 +70,8 @@ void main() {
|
|||||||
) async {
|
) async {
|
||||||
final repo = await pumpCapture(tester);
|
final repo = await pumpCapture(tester);
|
||||||
|
|
||||||
await tester.enterText(
|
// The first field in the guided stepper is the title section.
|
||||||
find.byType(TextField),
|
await tester.enterText(find.byType(TextField).first, 'My idea');
|
||||||
'My idea\n\nwhat — build the thing',
|
|
||||||
);
|
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
final notes = await repo.listNotes();
|
final notes = await repo.listNotes();
|
||||||
@ -86,7 +87,7 @@ void main() {
|
|||||||
) async {
|
) async {
|
||||||
final repo = await pumpCapture(tester);
|
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.pump();
|
||||||
await tester.tap(find.text('Save'));
|
await tester.tap(find.text('Save'));
|
||||||
await tester.pump(); // build the snackbar
|
await tester.pump(); // build the snackbar
|
||||||
@ -95,7 +96,8 @@ void main() {
|
|||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
expect(await repo.listNotes(), hasLength(1));
|
expect(await repo.listNotes(), hasLength(1));
|
||||||
expect(find.textContaining('<imperative title>'), 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', (
|
testWidgets('tapping Sync while unconfigured prompts for a token', (
|
||||||
@ -127,7 +129,7 @@ void main() {
|
|||||||
) async {
|
) async {
|
||||||
final repo = await pumpCapture(tester);
|
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.pump();
|
||||||
|
|
||||||
await tester.tap(
|
await tester.tap(
|
||||||
@ -146,7 +148,7 @@ void main() {
|
|||||||
) async {
|
) async {
|
||||||
final repo = await pumpCapture(tester);
|
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.pump();
|
||||||
|
|
||||||
await tester.tap(
|
await tester.tap(
|
||||||
|
|||||||
71
test/markdown_view_test.dart
Normal file
71
test/markdown_view_test.dart
Normal file
@ -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<void> 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<Text>(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);
|
||||||
|
});
|
||||||
|
}
|
||||||
96
test/note_detail_screen_test.dart
Normal file
96
test/note_detail_screen_test.dart
Normal file
@ -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<FakeNoteRepository> 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<Priority>),
|
||||||
|
);
|
||||||
|
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<Status>),
|
||||||
|
);
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
218
test/note_editor_test.dart
Normal file
218
test/note_editor_test.dart
Normal file
@ -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<List<String>> pumpEditor(
|
||||||
|
WidgetTester tester, {
|
||||||
|
String initialText = '',
|
||||||
|
NoteTemplate? initialTemplate,
|
||||||
|
NoteEditorMode initialMode = NoteEditorMode.guided,
|
||||||
|
}) async {
|
||||||
|
final emitted = <String>[];
|
||||||
|
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<TextField>(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<TextField>(find.byType(TextField));
|
||||||
|
expect(raw.controller!.text, 'cannot be guided');
|
||||||
|
});
|
||||||
|
}
|
||||||
118
test/note_template_test.dart
Normal file
118
test/note_template_test.dart
Normal file
@ -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 '), '');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -105,7 +105,7 @@ void main() {
|
|||||||
testWidgets('per-note sheet deletes the note', (tester) async {
|
testWidgets('per-note sheet deletes the note', (tester) async {
|
||||||
final repo = await pumpList(tester, seed: [note('a', 'Delete me')]);
|
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();
|
||||||
await tester.pump(const Duration(milliseconds: 300)); // sheet open
|
await tester.pump(const Duration(milliseconds: 300)); // sheet open
|
||||||
await tester.tap(find.text('Delete note'));
|
await tester.tap(find.text('Delete note'));
|
||||||
@ -117,7 +117,7 @@ void main() {
|
|||||||
testWidgets('per-note sheet changes status via a chip', (tester) async {
|
testWidgets('per-note sheet changes status via a chip', (tester) async {
|
||||||
final repo = await pumpList(tester, seed: [note('a', 'Change me')]);
|
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();
|
||||||
await tester.pump(const Duration(milliseconds: 300));
|
await tester.pump(const Duration(milliseconds: 300));
|
||||||
await tester.tap(find.text('In progress'));
|
await tester.tap(find.text('In progress'));
|
||||||
@ -303,7 +303,7 @@ void main() {
|
|||||||
testWidgets('per-note sheet changes priority via a chip', (tester) async {
|
testWidgets('per-note sheet changes priority via a chip', (tester) async {
|
||||||
final repo = await pumpList(tester, seed: [note('a', 'Repriortise me')]);
|
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();
|
||||||
await tester.pump(const Duration(milliseconds: 300));
|
await tester.pump(const Duration(milliseconds: 300));
|
||||||
await tester.tap(find.text('Low')); // default is Medium → change to Low
|
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);
|
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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user