mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-11-10 22:07:02 +01:00
702 -> 220 lines, decomposed rather than just moved, because a single
~600-line State class was the actual problem:
note_editor_document.dart the text model -- template, controllers, and
which of the two sources (raw body vs. section
fields) is authoritative. None of it needed a
BuildContext or a setState, which is what made
it the honest seam. The raw<->guided conversions
and the initial-template detection moved here as
tryAdoptGuided/materialiseToRaw/forInitialText.
note_editor_wizard.dart the two-step priority+template wizard, which now
owns its own step state instead of parking three
fields on the editor.
note_editor_step_page.dart the guided per-step view.
note_editor_chrome.dart the template picker + mode toggle.
note_editor_raw_field.dart the raw-mode text field.
note_editor_mode.dart the mode enum, re-exported so callers are
unaffected.
note_editor_{widget,views} parts, not libraries: createState returns a
private class, so the widget and its State
cannot be split across libraries.
The layout comments about Expanded/expands:true are load-bearing (nesting
broke the constraint chain in release builds before), so they travelled with
the code they describe rather than being summarised away.
Verified: 313 tests pass, flutter analyze clean. Covered lines went UP,
1922 -> 1958, because the wizard's state is now exercised in a widget of its
own; every new file reports 100%.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
164 lines
5.5 KiB
Dart
164 lines
5.5 KiB
Dart
/// The editor's text state: the template, the controllers, and which of the
|
|
/// two sources (raw body vs. per-section fields) is currently authoritative.
|
|
///
|
|
/// Split out of `note_editor.dart` for file size, and it is the honest seam:
|
|
/// none of this needs a `BuildContext` or a `setState` — it is the model the
|
|
/// widget renders. `_NoteEditorState` still drives every transition and still
|
|
/// owns when to rebuild; this class just holds the text and converts between
|
|
/// the two sources.
|
|
///
|
|
/// The two-source design is what keeps non-conforming text safe: text that
|
|
/// does not fit the template is never forced through it, it stays verbatim in
|
|
/// [body] and the editor shows the raw field instead.
|
|
library;
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
import 'package:todo/data/note_template.dart';
|
|
|
|
/// Holds a note's editable text for one [NoteTemplate].
|
|
class NoteEditorDocument {
|
|
/// Creates a document for [template]; call [load] to put text in it.
|
|
NoteEditorDocument(this.template);
|
|
|
|
/// Builds the document a freshly-mounted editor should start from.
|
|
///
|
|
/// With an explicit [template] the text is simply loaded into it. Without
|
|
/// one the template is *detected*: text that cleanly fits the design-spec
|
|
/// template adopts it, and anything else (freeform, legacy, hand-mangled)
|
|
/// stays a raw body under [NoteTemplate.blank], untouched.
|
|
///
|
|
/// [openingRaw] keeps the raw body authoritative even for conforming text.
|
|
/// Without it, typing into a Raw field would silently update the hidden,
|
|
/// unused section controllers instead of what `onChanged` emits.
|
|
factory NoteEditorDocument.forInitialText(
|
|
String text, {
|
|
required NoteTemplate? template,
|
|
required bool openingRaw,
|
|
}) {
|
|
if (template != null) {
|
|
return NoteEditorDocument(template)
|
|
..load(template, text, preferGuided: !openingRaw);
|
|
}
|
|
final parsed = parse(NoteTemplate.llmDesignSpec, text);
|
|
if (!parsed.conforms) {
|
|
return NoteEditorDocument(NoteTemplate.blank)
|
|
..rawSource = true
|
|
..body.text = text;
|
|
}
|
|
final doc = NoteEditorDocument(NoteTemplate.llmDesignSpec);
|
|
if (openingRaw) {
|
|
doc
|
|
..rawSource = true
|
|
..body.text = text;
|
|
} else {
|
|
doc.adoptSections(parsed.values);
|
|
}
|
|
return doc;
|
|
}
|
|
|
|
/// The template the text is authored against.
|
|
NoteTemplate template;
|
|
|
|
/// 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.
|
|
bool rawSource = true;
|
|
|
|
/// Single field used for the freeform [NoteTemplate.blank] body and for raw
|
|
/// mode of a structured template.
|
|
final TextEditingController body = TextEditingController();
|
|
|
|
/// One controller per structured section (keyed by section key).
|
|
final Map<String, TextEditingController> section = {};
|
|
|
|
/// Ensures a controller exists for every section of [forTemplate].
|
|
void ensureControllers(NoteTemplate forTemplate) {
|
|
for (final s in forTemplate.sections) {
|
|
section.putIfAbsent(s.key, TextEditingController.new);
|
|
}
|
|
}
|
|
|
|
/// Writes [values] into the per-section controllers.
|
|
void fillSections(Map<String, String> values) {
|
|
ensureControllers(template);
|
|
for (final s in template.sections) {
|
|
section[s.key]!.text = values[s.key] ?? '';
|
|
}
|
|
}
|
|
|
|
/// Loads [text] into [forTemplate], choosing the guided source when it
|
|
/// conforms (or when [preferGuided] and the text is empty) and the raw body
|
|
/// otherwise.
|
|
void load(
|
|
NoteTemplate forTemplate,
|
|
String text, {
|
|
required bool preferGuided,
|
|
}) {
|
|
template = forTemplate;
|
|
if (forTemplate.isFreeform) {
|
|
rawSource = true;
|
|
body.text = text;
|
|
return;
|
|
}
|
|
final parsed = parse(forTemplate, text);
|
|
if (parsed.conforms || (preferGuided && text.trim().isEmpty)) {
|
|
rawSource = false;
|
|
fillSections(parsed.values);
|
|
} else {
|
|
rawSource = true;
|
|
body.text = text;
|
|
}
|
|
}
|
|
|
|
/// Adopts [values] as the guided source, making the section fields
|
|
/// authoritative.
|
|
///
|
|
/// The raw -> guided direction, once the caller has confirmed the text
|
|
/// still conforms to the template.
|
|
void adoptSections(Map<String, String> values) {
|
|
fillSections(values);
|
|
rawSource = false;
|
|
}
|
|
|
|
/// Tries to make the section fields authoritative, for raw -> guided.
|
|
///
|
|
/// Returns false and changes nothing when the edited raw text no longer
|
|
/// fits the template — the caller keeps the editor in raw and says so,
|
|
/// rather than forcing text through a template it does not match.
|
|
/// Empty text always succeeds: there is nothing to lose.
|
|
bool tryAdoptGuided() {
|
|
if (!rawSource) return true;
|
|
final parsed = parse(template, body.text);
|
|
if (!parsed.conforms && body.text.trim().isNotEmpty) return false;
|
|
adoptSections(parsed.values);
|
|
return true;
|
|
}
|
|
|
|
/// Moves the editable content into the raw body, leaving it authoritative.
|
|
///
|
|
/// Used on guided -> raw so the assembled text is what the user then edits.
|
|
void materialiseToRaw() {
|
|
if (rawSource) return;
|
|
body.text = currentText();
|
|
rawSource = true;
|
|
}
|
|
|
|
/// 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);
|
|
}
|
|
|
|
/// Disposes every controller this document owns.
|
|
void dispose() {
|
|
for (final c in section.values) {
|
|
c.dispose();
|
|
}
|
|
body.dispose();
|
|
}
|
|
}
|