todo-app/lib/ui/note_editor_document.dart
Krzysztof kuhy Rudnicki 63725e726f Split note_editor.dart into nine files under the cap
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>
2026-08-14 16:12:04 +02:00

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();
}
}