todo-app/lib/ui/note_editor.dart
Krzysztof kuhy Rudnicki f4272d01b9
Some checks are pending
file-length / file length <= 250 lines (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
Route todo's snackbars through the shared showToast/showError
The package shipped feedback helpers while todo still had a private
_showSnack and a raw SnackBar in note_editor -- exactly the duplication
the extraction was meant to end, left live in a repo that had just
adopted the package.

Two deliberate behaviour changes come with it: the duration goes 2s ->
3s for toasts and 5s for errors (an error the user missed is an error
they will hit again), and messages now replace rather than queue, since
a queued snackbar describes an action the user moved on from seconds
ago. No test asserted on either, and the one snackbar assertion in the
suite checks that capture is *not* interrupted by one, which still holds.

Failures now read as failures: the mode-switch rejection in note_editor
and a failed sync in capture_screen use showError, while a successful
sync and the not-configured hint stay neutral toasts. Spending the danger
hue on routine confirmations is how a real failure becomes invisible.

Verified: flutter analyze clean, 308 tests pass at 100% coverage
(1964/1964). The release APK built and installed, but on-device
re-verification could not complete: the 23:00 night curfew removed the
package ~80ms after install, because dev.kuhy.todo is in the focus-mode
day WHITELIST but not NIGHT_WHITELIST. Enforcer working as designed, not
an app fault -- reverify after 05:00.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XS1m7b1piBtAL22USLW6jL
2026-08-14 23:09:24 +02:00

219 lines
7.5 KiB
Dart

import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:todo/data/note.dart';
import 'package:todo/data/note_template.dart';
import 'package:todo/ui/markdown_view.dart';
import 'package:todo/ui/note_editor_chrome.dart';
import 'package:todo/ui/note_editor_document.dart';
import 'package:todo/ui/note_editor_mode.dart';
import 'package:todo/ui/note_editor_raw_field.dart';
import 'package:todo/ui/note_editor_step_page.dart';
import 'package:todo/ui/note_editor_wizard.dart';
export 'package:todo/ui/note_editor_mode.dart';
part 'note_editor_views.dart';
part 'note_editor_widget.dart';
class _NoteEditorState extends State<NoteEditor> {
late final NoteEditorDocument _doc;
late NoteEditorMode _mode;
int _currentStep = 0;
/// Whether the priority+template entry wizard is showing in place of the
/// normal chrome. True only between tapping Guided on an empty draft and
/// either "Start" (which flips to bare Guided) or "Cancel" (back to Raw).
bool _enteringGuided = false;
/// Set once the user picks a template themselves, which stops
/// [_retemplateDraft] from overriding that choice as they keep typing.
bool _templatePickedByUser = false;
@override
void initState() {
super.initState();
_doc = NoteEditorDocument.forInitialText(
widget.initialText,
template: widget.initialTemplate,
openingRaw: widget.initialMode == NoteEditorMode.raw,
);
_mode = _resolveMode(widget.initialMode);
}
@override
void dispose() {
_doc.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 => !_doc.template.isFreeform && !_doc.rawSource;
/// Picks the mode to actually display: honours [desired] unless Guided was
/// asked for when it can't be opened (falls back to Raw), or
/// [NoteEditor.advancedMode] is off (Preview/Guided are unreachable
/// without their chrome, so Raw is the only mode shown).
NoteEditorMode _resolveMode(NoteEditorMode desired) {
if (!widget.advancedMode) return NoteEditorMode.raw;
if (desired == NoteEditorMode.guided && !_canOpenGuided) {
return NoteEditorMode.raw;
}
return desired;
}
void _goToStep(int index) {
setState(() {
_currentStep = index.clamp(0, _doc.template.sections.length - 1);
});
}
void _emit() => widget.onChanged(_doc.currentText());
/// Re-picks the draft's template from what has been typed so far, unless the
/// user has chosen one explicitly.
///
/// Only ever runs while the raw body *is* the source, so there are no section
/// values to convert and the switch is lossless in both directions: paste a
/// link and Guided drops away, write a sentence under it and the spec
/// template comes back.
void _retemplateDraft() {
if (_templatePickedByUser || !_doc.rawSource) return;
final next = NoteTemplate.forDraft(_doc.body.text);
if (next.id == _doc.template.id) return;
setState(() {
_doc.template = next;
_currentStep = 0;
_mode = _resolveMode(_mode);
});
}
void _switchTemplate(NoteTemplate next) {
if (next.id == _doc.template.id) return;
final text = _doc.currentText();
setState(() {
_doc.template = next;
_currentStep = 0;
_doc.load(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:
// raw -> guided: only if the edited text still fits the template.
if (!_doc.tryAdoptGuided()) {
showError(
context,
"Text doesn't match the template — staying in raw",
);
return;
}
_currentStep = 0;
if (_doc.currentText().trim().isEmpty) {
// Fresh, empty draft: priority and template are meaningful
// choices only once, so ask for them before showing the stepper.
_enteringGuided = true;
} else {
// Existing content: priority/template are already settled, so
// skip straight to the bare stepper rather than re-asking.
_mode = NoteEditorMode.guided;
}
case NoteEditorMode.raw:
// guided -> raw: materialise the assembled text into the body.
_doc.materialiseToRaw();
_mode = NoteEditorMode.raw;
case NoteEditorMode.preview:
// Read-only render of the current source; nothing to convert.
_mode = NoteEditorMode.preview;
}
});
widget.onChromeVisibleChanged(
!_enteringGuided && _mode != NoteEditorMode.guided,
);
}
/// Commits the wizard's template choice and enters the bare stepper.
/// Distinct from [_switchTemplate]: that short-circuits when the template
/// is unchanged, which would skip flipping out of the wizard here.
void _enterGuidedWithTemplate(NoteTemplate template) {
final text = _doc.currentText();
// Choosing a template in the wizard is as explicit as using the dropdown.
_templatePickedByUser = true;
setState(() {
_doc.template = template;
_currentStep = 0;
_doc.load(template, text, preferGuided: true);
_mode = NoteEditorMode.guided;
_enteringGuided = false;
});
_emit();
widget.onChromeVisibleChanged(false);
}
/// Aborts the wizard, returning to Raw with the chrome restored.
void _cancelWizard() {
setState(() => _enteringGuided = false);
widget.onChromeVisibleChanged(true);
}
@override
Widget build(BuildContext context) {
if (_enteringGuided) return _guidedEntryWizard();
// Guided hides the template/mode chrome entirely. Return the flat step
// page directly so the TextField sits in THIS column's Expanded — same
// depth as the raw field, avoiding the nested-Column/Expanded layout issue
// that silently collapses the inner flex space in release builds.
if (_mode == NoteEditorMode.guided) return _buildStepPage();
// Advanced mode off: the template picker and mode toggle are hidden and
// _resolveMode() has already pinned _mode to raw, so skip straight to
// the body directly (not wrapped in another Expanded — the parent
// screen already wraps this whole widget in one, same as the guided
// branch above).
if (!widget.advancedMode) return _buildBody();
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
NoteEditorChrome(
template: _doc.template,
mode: _mode,
onTemplateSelected: (next) {
_templatePickedByUser = true;
_switchTemplate(next);
},
onModeSelected: _setMode,
),
const SizedBox(height: 12),
Expanded(child: _buildBody()),
],
);
}
/// Exits the bare stepper back to Raw, restoring the chrome.
void _exitGuided() {
setState(() {
_doc.materialiseToRaw();
_mode = NoteEditorMode.raw;
});
widget.onChromeVisibleChanged(true);
}
/// Rebuilds after a guided step edit, then re-emits the assembled text.
void _onStepFieldChanged() {
setState(() {});
_emit();
}
}