mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-11-11 13:27:46 +01:00
A third of the freeform notes in the backlog are nothing but a pasted
URL, and the capture screen still offered them the seven-step spec
stepper. NoteTemplate.forDraft now picks `blank` for a draft that is
only links, so filing a link costs one paste.
The switch runs on every raw keystroke and is lossless in both
directions — it only ever fires while the raw body is the source, so
there are no section values to convert. Type prose alongside the link
and the spec template comes straight back. An explicit choice from the
dropdown or the guided wizard sets _templatePickedByUser and is never
overridden.
isBareLink is deliberately strict: one word of context ("read this
https://…") means the note has prose in it and keeps the spec template.
Verified on the phone (release APK, adb install -r): typing a bare URL
flips Template to Blank and drops the Guided segment; appending " port
this to Dart" restores LLM design spec and Guided. Test note created by
that check was deleted and the deletion synced (back to 49 notes).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
682 lines
23 KiB
Dart
682 lines
23 KiB
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/theme.dart';
|
|
|
|
/// Which view of the note the editor is currently showing.
|
|
enum NoteEditorMode {
|
|
/// Read-only rendered Markdown (headings, guidance, bullets).
|
|
preview,
|
|
|
|
/// Full-screen per-step view, 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** — a full-screen per-step view, one step per template
|
|
/// section, 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.
|
|
///
|
|
/// Entering Guided on an empty draft first runs a two-step wizard (priority,
|
|
/// then template) via [onPriorityChanged], since those choices only make
|
|
/// sense once, before there's anything to guide. Guided itself — wizard or
|
|
/// bare step page — hides the template/mode chrome entirely (just a back arrow
|
|
/// to return to Raw); [onChromeVisibleChanged] tells the parent screen to
|
|
/// hide its own priority/status row in sync.
|
|
class NoteEditor extends StatefulWidget {
|
|
/// Creates a [NoteEditor] with the given callbacks and initial state.
|
|
const NoteEditor({
|
|
required this.onChanged,
|
|
required this.priority,
|
|
required this.onPriorityChanged,
|
|
required this.onChromeVisibleChanged,
|
|
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;
|
|
|
|
/// The note's current priority, shown as the wizard's starting selection.
|
|
final Priority priority;
|
|
|
|
/// Called when the priority wizard step is confirmed (Guided "Start").
|
|
final ValueChanged<Priority> onPriorityChanged;
|
|
|
|
/// Called whenever the editor's own chrome (template dropdown, mode
|
|
/// selector) is shown/hidden, so the parent screen can hide its
|
|
/// priority/status row in sync while Guided (wizard or bare stepper) is
|
|
/// active.
|
|
final ValueChanged<bool> onChromeVisibleChanged;
|
|
|
|
/// 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;
|
|
|
|
/// 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;
|
|
|
|
/// Which wizard step (0: priority, 1: template) is showing.
|
|
int _wizardStep = 0;
|
|
|
|
/// Working copies of the wizard's two choices, committed on "Start".
|
|
Priority _wizardPriority = Priority.defaultValue;
|
|
NoteTemplate _wizardTemplate = NoteTemplate.defaultTemplate;
|
|
|
|
/// One controller per structured section (keyed by section key).
|
|
final Map<String, TextEditingController> _section = {};
|
|
|
|
/// Set once the user picks a template themselves, which stops
|
|
/// [_retemplateDraft] from overriding that choice as they keep typing.
|
|
bool _templatePickedByUser = false;
|
|
|
|
/// 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;
|
|
// Only prefer the guided (sections) source for an empty draft when the
|
|
// caller actually wants to open in Guided — an empty Raw draft (the new
|
|
// default) must keep the raw body as its source, or typing into the Raw
|
|
// field would silently update the (hidden, unused) section controllers
|
|
// instead of what's emitted via onChanged.
|
|
_loadSource(
|
|
initial,
|
|
widget.initialText,
|
|
preferGuided: widget.initialMode != NoteEditorMode.raw,
|
|
);
|
|
} else {
|
|
// Detect: does the text cleanly fit the design-spec template?
|
|
final parsed = parse(NoteTemplate.llmDesignSpec, widget.initialText);
|
|
if (parsed.conforms) {
|
|
_template = NoteTemplate.llmDesignSpec;
|
|
// Same Raw/source consistency rule as above: an explicit Raw request
|
|
// must keep the raw body as the source even for conforming text.
|
|
if (widget.initialMode == NoteEditorMode.raw) {
|
|
_rawSource = true;
|
|
_body.text = widget.initialText;
|
|
} else {
|
|
_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.new);
|
|
}
|
|
}
|
|
|
|
void _goToStep(int index) {
|
|
setState(() {
|
|
_currentStep = index.clamp(0, _template.sections.length - 1);
|
|
});
|
|
}
|
|
|
|
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());
|
|
|
|
/// 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 || !_rawSource) return;
|
|
final next = NoteTemplate.forDraft(_body.text);
|
|
if (next.id == _template.id) return;
|
|
setState(() {
|
|
_template = next;
|
|
_currentStep = 0;
|
|
_mode = _resolveMode(_mode);
|
|
});
|
|
}
|
|
|
|
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;
|
|
if (_currentText().trim().isEmpty) {
|
|
// Fresh, empty draft: priority and template are meaningful
|
|
// choices only once, so ask for them before showing the stepper.
|
|
_enteringGuided = true;
|
|
_wizardStep = 0;
|
|
_wizardPriority = widget.priority;
|
|
_wizardTemplate = NoteTemplate.defaultTemplate;
|
|
} 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:
|
|
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;
|
|
}
|
|
});
|
|
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 = _currentText();
|
|
// Choosing a template in the wizard is as explicit as using the dropdown.
|
|
_templatePickedByUser = true;
|
|
setState(() {
|
|
_template = template;
|
|
_currentStep = 0;
|
|
_loadSource(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);
|
|
}
|
|
|
|
/// Exits the bare stepper back to Raw, restoring the chrome.
|
|
void _exitGuided() {
|
|
setState(() {
|
|
if (!_rawSource) {
|
|
_body.text = _currentText();
|
|
_rawSource = true;
|
|
}
|
|
_mode = NoteEditorMode.raw;
|
|
});
|
|
widget.onChromeVisibleChanged(true);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
if (_enteringGuided) return _buildGuidedEntryWizard(theme);
|
|
|
|
// 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 _buildRaw, avoiding the nested-Column/Expanded layout issue
|
|
// that silently collapses the inner flex space in release builds.
|
|
if (_mode == NoteEditorMode.guided) return _buildStepPage(theme);
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
DropdownButtonFormField<String>(
|
|
initialValue: _template.id,
|
|
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;
|
|
_templatePickedByUser = true;
|
|
_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; tapping it
|
|
// on an empty draft opens the wizard (see _setMode), and 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)),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// The two-step priority -> template wizard shown before a fresh draft
|
|
/// enters Guided. Replaces the normal chrome entirely (see [build]).
|
|
Widget _buildGuidedEntryWizard(ThemeData theme) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
IconButton(
|
|
icon: const Icon(Icons.close),
|
|
tooltip: 'Cancel',
|
|
onPressed: _cancelWizard,
|
|
),
|
|
Text(
|
|
'Step ${_wizardStep + 1} of 2',
|
|
style: theme.textTheme.labelLarge,
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
if (_wizardStep == 0)
|
|
..._buildWizardPriorityStep()
|
|
else
|
|
..._buildWizardTemplateStep(),
|
|
],
|
|
);
|
|
}
|
|
|
|
List<Widget> _buildWizardPriorityStep() {
|
|
return [
|
|
DropdownButtonFormField<Priority>(
|
|
initialValue: _wizardPriority,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Priority',
|
|
isDense: true,
|
|
border: OutlineInputBorder(),
|
|
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
),
|
|
items: [
|
|
for (final p in Priority.values)
|
|
DropdownMenuItem(value: p, child: Text(p.label)),
|
|
],
|
|
onChanged: (p) {
|
|
if (p != null) setState(() => _wizardPriority = p);
|
|
},
|
|
),
|
|
const SizedBox(height: 12),
|
|
Align(
|
|
alignment: Alignment.centerRight,
|
|
child: FilledButton(
|
|
onPressed: () => setState(() => _wizardStep = 1),
|
|
child: const Text('Next'),
|
|
),
|
|
),
|
|
];
|
|
}
|
|
|
|
List<Widget> _buildWizardTemplateStep() {
|
|
final templates = NoteTemplate.all.where((t) => !t.isFreeform).toList();
|
|
return [
|
|
DropdownButtonFormField<String>(
|
|
initialValue: _wizardTemplate.id,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Template',
|
|
isDense: true,
|
|
border: OutlineInputBorder(),
|
|
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
),
|
|
items: [
|
|
for (final t in templates)
|
|
DropdownMenuItem(value: t.id, child: Text(t.label)),
|
|
],
|
|
onChanged: (id) {
|
|
if (id == null) return;
|
|
setState(
|
|
() => _wizardTemplate = templates.firstWhere((t) => t.id == id),
|
|
);
|
|
},
|
|
),
|
|
const SizedBox(height: 12),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.end,
|
|
children: [
|
|
TextButton(
|
|
onPressed: () => setState(() => _wizardStep = 0),
|
|
child: const Text('Back'),
|
|
),
|
|
const SizedBox(width: 8),
|
|
FilledButton(
|
|
onPressed: () {
|
|
widget.onPriorityChanged(_wizardPriority);
|
|
_enterGuidedWithTemplate(_wizardTemplate);
|
|
},
|
|
child: const Text('Start'),
|
|
),
|
|
],
|
|
),
|
|
];
|
|
}
|
|
|
|
Widget _buildBody(ThemeData theme) {
|
|
switch (_mode) {
|
|
case NoteEditorMode.preview:
|
|
return MarkdownView(text: _currentText());
|
|
case NoteEditorMode.raw:
|
|
return _buildRaw(theme);
|
|
// coverage:ignore-start
|
|
// Unreachable: build() short-circuits to _buildStepPage before reaching
|
|
// here, so this arm only exists to make the switch exhaustive.
|
|
case NoteEditorMode.guided:
|
|
return _buildRaw(theme);
|
|
// coverage:ignore-end
|
|
}
|
|
}
|
|
|
|
Widget _buildRaw(ThemeData theme) {
|
|
// Caps the editing column at ~70 chars (rule 21) on the desktop's
|
|
// arbitrarily wide Chrome `--app` window. Center/ConstrainedBox only,
|
|
// no extra Column — nesting a second Column here previously broke the
|
|
// expands:true constraint chain in release builds (see _buildStepPage).
|
|
return Center(
|
|
child: ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: kProseMaxWidth),
|
|
child: 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: (_) {
|
|
_retemplateDraft();
|
|
_emit();
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Full-screen per-step view returned directly from [build] so the
|
|
/// [TextField] is a first-level [Expanded] child of THIS column — the same
|
|
/// depth as [_buildRaw]. Nesting it inside a second Column broke the
|
|
/// flutter constraint chain in release builds (inner Expanded got 0 height).
|
|
Widget _buildStepPage(ThemeData theme) {
|
|
_ensureControllers(_template);
|
|
final sections = _template.sections;
|
|
final idx = _currentStep.clamp(0, sections.length - 1);
|
|
final section = sections[idx];
|
|
final total = sections.length;
|
|
final controller = _section[section.key]!;
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
// Back arrow — only exit from guided mode.
|
|
Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: IconButton(
|
|
icon: const Icon(Icons.arrow_back),
|
|
tooltip: 'Exit guided',
|
|
onPressed: _exitGuided,
|
|
),
|
|
),
|
|
// Progress bar + step counter.
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 4),
|
|
child: Row(
|
|
children: [
|
|
Text('${idx + 1} / $total', style: theme.textTheme.labelMedium),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: LinearProgressIndicator(value: (idx + 1) / total),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
// Section label + helper text.
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
section.isTitle ? 'title' : section.label,
|
|
style: theme.textTheme.titleMedium,
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
section.helper,
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
],
|
|
),
|
|
),
|
|
// TextField fills all remaining space — directly in this Column's
|
|
// Expanded, same as _buildRaw, so expands:true works correctly.
|
|
Expanded(
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 0),
|
|
child: TextField(
|
|
controller: controller,
|
|
autofocus: widget.autofocus && idx == 0,
|
|
expands: true,
|
|
maxLines: null,
|
|
keyboardType: TextInputType.multiline,
|
|
textAlignVertical: TextAlignVertical.top,
|
|
decoration: InputDecoration(
|
|
hintText: section.hint,
|
|
border: const OutlineInputBorder(),
|
|
isDense: true,
|
|
),
|
|
onChanged: (_) {
|
|
setState(() {});
|
|
_emit();
|
|
},
|
|
),
|
|
),
|
|
),
|
|
// Navigation buttons — sibling of Expanded so keyboard pushes them up.
|
|
Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Row(
|
|
children: [
|
|
if (idx > 0)
|
|
TextButton(
|
|
onPressed: () => _goToStep(idx - 1),
|
|
child: const Text('Back'),
|
|
),
|
|
const Spacer(),
|
|
if (idx < total - 1)
|
|
FilledButton(
|
|
onPressed: () => _goToStep(idx + 1),
|
|
child: const Text('Next'),
|
|
)
|
|
else
|
|
FilledButton(onPressed: _exitGuided, child: const Text('Done')),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|