mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-07-04 11:43:10 +02:00
Add priority/template wizard and raw-as-default for guided mode
Entering Guided on an empty draft now runs a two-step wizard (priority, then template) before showing the stepper. CaptureScreen defaults to Raw mode so a quick capture stays in the single-field flow; switching to Guided opens the wizard. Chrome (template/mode bar and priority/status row) is hidden while Guided or the wizard is active. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Cb7oE5Xsc8zSBHHtT6qwa
This commit is contained in:
parent
f91311f3f9
commit
29f94e76a5
@ -81,6 +81,10 @@ class _CaptureScreenState extends State<CaptureScreen>
|
||||
SyncSettings? _settings;
|
||||
bool _syncing = false;
|
||||
|
||||
/// Hides the Priority/Status row while the editor's own bare-guided chrome
|
||||
/// (template/mode selectors) is also hidden, so the two stay in lockstep.
|
||||
bool _chromeVisible = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@ -301,6 +305,7 @@ class _CaptureScreenState extends State<CaptureScreen>
|
||||
_lastSavedAt = null;
|
||||
_draftPriority = Priority.defaultValue;
|
||||
_draftStatus = Status.todo;
|
||||
_chromeVisible = true;
|
||||
});
|
||||
if (saved) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@ -359,35 +364,44 @@ class _CaptureScreenState extends State<CaptureScreen>
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Pickers sit above the editor so the bottom-right Save FAB
|
||||
// never overlaps them.
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _MetaDropdown<Priority>(
|
||||
label: 'Priority',
|
||||
value: _draftPriority,
|
||||
values: Priority.values,
|
||||
labelOf: (p) => p.label,
|
||||
onChanged: _setPriority,
|
||||
// never overlaps them. Hidden together with the editor's own
|
||||
// chrome while the bare guided stepper or its entry wizard is up,
|
||||
// so the top of the screen stays free of noise.
|
||||
if (_chromeVisible) ...[
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _MetaDropdown<Priority>(
|
||||
label: 'Priority',
|
||||
value: _draftPriority,
|
||||
values: Priority.values,
|
||||
labelOf: (p) => p.label,
|
||||
onChanged: _setPriority,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _MetaDropdown<Status>(
|
||||
label: 'Status',
|
||||
value: _draftStatus,
|
||||
values: Status.values,
|
||||
labelOf: (s) => s.label,
|
||||
onChanged: _setStatus,
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _MetaDropdown<Status>(
|
||||
label: 'Status',
|
||||
value: _draftStatus,
|
||||
values: Status.values,
|
||||
labelOf: (s) => s.label,
|
||||
onChanged: _setStatus,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
Expanded(
|
||||
child: NoteEditor(
|
||||
key: ValueKey(_editorGeneration),
|
||||
initialTemplate: NoteTemplate.defaultTemplate,
|
||||
initialMode: NoteEditorMode.raw,
|
||||
priority: _draftPriority,
|
||||
onPriorityChanged: _setPriority,
|
||||
onChromeVisibleChanged: (visible) =>
|
||||
setState(() => _chromeVisible = visible),
|
||||
autofocus: true,
|
||||
onChanged: _onChanged,
|
||||
),
|
||||
|
||||
@ -28,6 +28,10 @@ class NoteDetailScreen extends StatefulWidget {
|
||||
class _NoteDetailScreenState extends State<NoteDetailScreen> {
|
||||
late Note _note = widget.note;
|
||||
|
||||
/// Hides the Priority/Status row while the editor's own bare-guided chrome
|
||||
/// (template/mode selectors) is also hidden, so the two stay in lockstep.
|
||||
bool _chromeVisible = true;
|
||||
|
||||
Future<void> _persist(Note next) async {
|
||||
setState(() => _note = next);
|
||||
await widget.repository.upsert(next);
|
||||
@ -60,38 +64,46 @@ class _NoteDetailScreenState extends State<NoteDetailScreen> {
|
||||
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()),
|
||||
if (_chromeVisible) ...[
|
||||
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(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),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
Expanded(
|
||||
child: NoteEditor(
|
||||
initialText: _note.text,
|
||||
initialMode: NoteEditorMode.preview,
|
||||
initialMode: NoteEditorMode.raw,
|
||||
priority: _note.priority,
|
||||
onPriorityChanged: (p) => _persist(
|
||||
_note.copyWith(priority: p, updatedAt: DateTime.now()),
|
||||
),
|
||||
onChromeVisibleChanged: (visible) =>
|
||||
setState(() => _chromeVisible = visible),
|
||||
onChanged: _onTextChanged,
|
||||
),
|
||||
),
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../data/note.dart';
|
||||
import '../data/note_template.dart';
|
||||
import 'markdown_view.dart';
|
||||
|
||||
@ -31,9 +32,19 @@ enum NoteEditorMode {
|
||||
/// 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 stepper — 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 {
|
||||
const NoteEditor({
|
||||
required this.onChanged,
|
||||
required this.priority,
|
||||
required this.onPriorityChanged,
|
||||
required this.onChromeVisibleChanged,
|
||||
this.initialText = '',
|
||||
this.initialTemplate,
|
||||
this.initialMode = NoteEditorMode.guided,
|
||||
@ -44,6 +55,18 @@ class NoteEditor extends StatefulWidget {
|
||||
/// 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;
|
||||
|
||||
@ -75,6 +98,18 @@ class _NoteEditorState extends State<NoteEditor> {
|
||||
|
||||
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 = {};
|
||||
|
||||
@ -94,14 +129,30 @@ class _NoteEditorState extends State<NoteEditor> {
|
||||
final initial = widget.initialTemplate;
|
||||
if (initial != null) {
|
||||
_template = initial;
|
||||
_loadSource(initial, widget.initialText, preferGuided: true);
|
||||
// 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;
|
||||
_rawSource = false;
|
||||
_fillSections(parsed.values);
|
||||
// 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;
|
||||
@ -244,7 +295,18 @@ class _NoteEditorState extends State<NoteEditor> {
|
||||
_rawSource = false;
|
||||
}
|
||||
_currentStep = 0;
|
||||
_mode = NoteEditorMode.guided;
|
||||
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.
|
||||
@ -257,67 +319,225 @@ class _NoteEditorState extends State<NoteEditor> {
|
||||
_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();
|
||||
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 (the bare stepper) hides the template/mode chrome entirely —
|
||||
// that's the point of "Guided": just the stepper, no top-bar noise. A
|
||||
// single back arrow is the only way out, restoring the full chrome.
|
||||
final bareGuided = _mode == NoteEditorMode.guided;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: _template.id,
|
||||
isDense: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Template',
|
||||
if (bareGuided)
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
tooltip: 'Exit guided',
|
||||
onPressed: _exitGuided,
|
||||
),
|
||||
)
|
||||
else ...[
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: _template.id,
|
||||
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'),
|
||||
),
|
||||
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)),
|
||||
],
|
||||
selected: {_mode},
|
||||
onSelectionChanged: (s) => _setMode(s.first),
|
||||
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; 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,
|
||||
isDense: true,
|
||||
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,
|
||||
isDense: true,
|
||||
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:
|
||||
|
||||
@ -63,16 +63,31 @@ void main() {
|
||||
'sync.token': 'tok',
|
||||
};
|
||||
|
||||
testWidgets('opens the guided editor with section guidance', (tester) async {
|
||||
testWidgets('defaults to Raw, with Guided available via the entry wizard', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpCapture(tester);
|
||||
|
||||
// The guided stepper shows the design-spec sections and the title step's
|
||||
// guidance, with no note persisted yet.
|
||||
// Defaults to Raw: a single text field, no note persisted yet.
|
||||
expect(find.byType(TextField), findsOneWidget);
|
||||
expect(find.text('Guided'), findsOneWidget);
|
||||
expect(find.text('0 saved'), findsOneWidget);
|
||||
|
||||
// Tapping Guided on the empty draft opens the priority+template wizard
|
||||
// rather than jumping straight into the stepper.
|
||||
await tester.tap(find.text('Guided'));
|
||||
await tester.pump();
|
||||
expect(find.text('Step 1 of 2'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Next'));
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Start'));
|
||||
await tester.pump();
|
||||
|
||||
// Now in the bare guided stepper, showing the design-spec sections.
|
||||
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);
|
||||
});
|
||||
|
||||
testWidgets('saving the untouched template creates no note', (tester) async {
|
||||
@ -115,8 +130,10 @@ void main() {
|
||||
await tester.pump();
|
||||
|
||||
expect(await repo.listNotes(), hasLength(1));
|
||||
// The editor reset to a fresh guided template (title guidance shown again).
|
||||
expect(find.textContaining('imperative'), findsOneWidget);
|
||||
// The editor reset to a fresh, empty Raw draft.
|
||||
expect(find.byType(TextField), findsOneWidget);
|
||||
final raw = tester.widget<TextField>(find.byType(TextField));
|
||||
expect(raw.controller!.text, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('tapping Sync while unconfigured prompts for a token', (
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
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';
|
||||
@ -33,14 +32,12 @@ void main() {
|
||||
return repo;
|
||||
}
|
||||
|
||||
testWidgets('opens in the rendered Markdown view with the title in the bar', (
|
||||
tester,
|
||||
) async {
|
||||
testWidgets('opens in Raw with the title in the app 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);
|
||||
final raw = tester.widget<TextField>(find.byType(TextField));
|
||||
expect(raw.controller!.text, contains('My note'));
|
||||
expect(find.text('My note'), findsOneWidget); // app bar title
|
||||
});
|
||||
|
||||
testWidgets('changing the priority dropdown persists the note', (
|
||||
@ -84,6 +81,34 @@ void main() {
|
||||
expect((await repo.listNotes()).single.text, contains('new body'));
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'clearing the body then tapping Guided runs the wizard and persists the chosen priority',
|
||||
(tester) async {
|
||||
final repo = await pumpDetail(tester, seedNote('# T\n\n## what\nold'));
|
||||
|
||||
await tester.enterText(find.byType(TextField), '');
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.text('Guided'));
|
||||
await tester.pump();
|
||||
expect(find.text('Step 1 of 2'), findsOneWidget);
|
||||
// Wizard hides the Status dropdown (onChromeVisibleChanged(false)).
|
||||
expect(find.text('Status'), findsNothing);
|
||||
|
||||
await tester.tap(find.byType(DropdownButtonFormField<Priority>));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('High').last);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Next'));
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Start'));
|
||||
await tester.pump();
|
||||
|
||||
expect((await repo.listNotes()).single.priority, Priority.high);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('the delete action removes the note and pops', (tester) async {
|
||||
final repo = await pumpDetail(tester, seedNote('# Bye'));
|
||||
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.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.dart';
|
||||
@ -14,6 +15,9 @@ void main() {
|
||||
String initialText = '',
|
||||
NoteTemplate? initialTemplate,
|
||||
NoteEditorMode initialMode = NoteEditorMode.guided,
|
||||
Priority priority = Priority.defaultValue,
|
||||
ValueChanged<Priority>? onPriorityChanged,
|
||||
ValueChanged<bool>? onChromeVisibleChanged,
|
||||
}) async {
|
||||
final emitted = <String>[];
|
||||
tester.view.physicalSize = const Size(1200, 2400);
|
||||
@ -27,6 +31,9 @@ void main() {
|
||||
initialText: initialText,
|
||||
initialTemplate: initialTemplate,
|
||||
initialMode: initialMode,
|
||||
priority: priority,
|
||||
onPriorityChanged: onPriorityChanged ?? (_) {},
|
||||
onChromeVisibleChanged: onChromeVisibleChanged ?? (_) {},
|
||||
onChanged: emitted.add,
|
||||
),
|
||||
),
|
||||
@ -41,7 +48,7 @@ void main() {
|
||||
) async {
|
||||
final emitted = await pumpEditor(tester, initialTemplate: spec);
|
||||
|
||||
expect(find.text('Guided'), findsOneWidget);
|
||||
expect(find.byTooltip('Exit guided'), findsOneWidget);
|
||||
await tester.enterText(find.byType(TextField).first, 'Dark mode');
|
||||
await tester.pump();
|
||||
|
||||
@ -73,8 +80,12 @@ void main() {
|
||||
});
|
||||
|
||||
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 pumpEditor(
|
||||
tester,
|
||||
initialTemplate: spec,
|
||||
initialMode: NoteEditorMode.raw,
|
||||
);
|
||||
await tester.enterText(find.byType(TextField).first, '# Render me');
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.text('View'));
|
||||
@ -84,23 +95,24 @@ void main() {
|
||||
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();
|
||||
testWidgets(
|
||||
'bare Guided back arrow returns to Raw, materialising the assembled text',
|
||||
(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();
|
||||
await tester.tap(find.byTooltip('Exit guided'));
|
||||
await tester.pump();
|
||||
|
||||
final raw = tester.widget<TextField>(find.byType(TextField));
|
||||
expect(raw.controller!.text, startsWith('# T'));
|
||||
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'));
|
||||
});
|
||||
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,
|
||||
@ -120,25 +132,29 @@ void main() {
|
||||
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);
|
||||
testWidgets(
|
||||
'guided → Raw → Guided round-trips (skipping the wizard) 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);
|
||||
// Bare guided -> raw via the back arrow makes the (still conforming)
|
||||
// body the source…
|
||||
await tester.tap(find.byTooltip('Exit guided'));
|
||||
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);
|
||||
});
|
||||
// …then raw -> guided re-parses the conforming body straight back into
|
||||
// the bare stepper — no wizard, since the content isn't empty.
|
||||
await tester.tap(find.text('Guided'));
|
||||
await tester.pump();
|
||||
expect(find.byType(Stepper), findsOneWidget);
|
||||
expect(find.text('Step 1 of 2'), findsNothing); // wizard was skipped
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('freeform template offers only View and Raw', (tester) async {
|
||||
final emitted = await pumpEditor(tester, initialTemplate: blank);
|
||||
@ -155,16 +171,19 @@ void main() {
|
||||
testWidgets('switching template via the dropdown reloads the source', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpEditor(tester, initialTemplate: spec);
|
||||
expect(find.byType(Stepper), findsOneWidget);
|
||||
await pumpEditor(
|
||||
tester,
|
||||
initialTemplate: spec,
|
||||
initialMode: NoteEditorMode.raw,
|
||||
);
|
||||
expect(find.text('Guided'), findsOneWidget); // structured: Guided offered
|
||||
|
||||
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);
|
||||
// Freeform now: Guided is no longer offered.
|
||||
expect(find.text('Guided'), findsNothing);
|
||||
});
|
||||
|
||||
@ -175,7 +194,7 @@ void main() {
|
||||
await pumpEditor(tester, initialText: conforming);
|
||||
|
||||
expect(find.byType(Stepper), findsOneWidget); // guided by default
|
||||
expect(find.text('Guided'), findsOneWidget);
|
||||
expect(find.byTooltip('Exit guided'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('detects a legacy note (no template given) as freeform raw', (
|
||||
@ -202,6 +221,23 @@ void main() {
|
||||
expect(find.text('Preview me'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Preview → Raw materialises the still-guided source', (
|
||||
tester,
|
||||
) async {
|
||||
final conforming = assemble(spec, {'title': 'Preview src', 'what': 'x'});
|
||||
await pumpEditor(
|
||||
tester,
|
||||
initialText: conforming,
|
||||
initialMode: NoteEditorMode.preview,
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Raw'));
|
||||
await tester.pump();
|
||||
|
||||
final raw = tester.widget<TextField>(find.byType(TextField));
|
||||
expect(raw.controller!.text, contains('Preview src'));
|
||||
});
|
||||
|
||||
testWidgets('initialMode guided falls back to Raw for non-conforming text', (
|
||||
tester,
|
||||
) async {
|
||||
@ -217,4 +253,138 @@ void main() {
|
||||
final raw = tester.widget<TextField>(find.byType(TextField));
|
||||
expect(raw.controller!.text, 'cannot be guided');
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'tapping Guided on an empty draft opens the priority+template wizard',
|
||||
(tester) async {
|
||||
await pumpEditor(
|
||||
tester,
|
||||
initialTemplate: spec,
|
||||
initialMode: NoteEditorMode.raw,
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Guided'));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Step 1 of 2'), findsOneWidget);
|
||||
expect(find.byType(DropdownButtonFormField<Priority>), findsOneWidget);
|
||||
expect(find.byType(Stepper), findsNothing);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'the wizard Next/Back moves between priority and template steps',
|
||||
(tester) async {
|
||||
await pumpEditor(
|
||||
tester,
|
||||
initialTemplate: spec,
|
||||
initialMode: NoteEditorMode.raw,
|
||||
);
|
||||
await tester.tap(find.text('Guided'));
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.text('Next'));
|
||||
await tester.pump();
|
||||
expect(find.text('Step 2 of 2'), findsOneWidget);
|
||||
expect(find.byType(DropdownButtonFormField<String>), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Back'));
|
||||
await tester.pump();
|
||||
expect(find.text('Step 1 of 2'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('the wizard template step only offers structured templates', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpEditor(
|
||||
tester,
|
||||
initialTemplate: spec,
|
||||
initialMode: NoteEditorMode.raw,
|
||||
);
|
||||
await tester.tap(find.text('Guided'));
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Next'));
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.text('LLM design spec').first); // open the menu
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Blank'), findsNothing);
|
||||
|
||||
// Select the (only) offered template, exercising the dropdown's onChanged.
|
||||
await tester.tap(find.text('LLM design spec').last);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Step 2 of 2'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'wizard Start commits the chosen priority and enters the bare stepper',
|
||||
(tester) async {
|
||||
Priority? committed;
|
||||
await pumpEditor(
|
||||
tester,
|
||||
initialTemplate: spec,
|
||||
initialMode: NoteEditorMode.raw,
|
||||
onPriorityChanged: (p) => committed = p,
|
||||
);
|
||||
await tester.tap(find.text('Guided'));
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.byType(DropdownButtonFormField<Priority>));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('High').last);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Next'));
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Start'));
|
||||
await tester.pump();
|
||||
|
||||
expect(committed, Priority.high);
|
||||
expect(find.byType(Stepper), findsOneWidget);
|
||||
expect(find.byTooltip('Exit guided'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('wizard Cancel returns to Raw with the chrome restored', (
|
||||
tester,
|
||||
) async {
|
||||
final chromeVisible = <bool>[];
|
||||
await pumpEditor(
|
||||
tester,
|
||||
initialTemplate: spec,
|
||||
initialMode: NoteEditorMode.raw,
|
||||
onChromeVisibleChanged: chromeVisible.add,
|
||||
);
|
||||
await tester.tap(find.text('Guided'));
|
||||
await tester.pump();
|
||||
expect(chromeVisible.last, false);
|
||||
|
||||
await tester.tap(find.byTooltip('Cancel'));
|
||||
await tester.pump();
|
||||
|
||||
expect(chromeVisible.last, true);
|
||||
expect(find.byType(Stepper), findsNothing);
|
||||
expect(find.text('Raw'), findsOneWidget); // chrome's mode selector is back
|
||||
});
|
||||
|
||||
testWidgets('exiting bare Guided via the back arrow restores the chrome', (
|
||||
tester,
|
||||
) async {
|
||||
final chromeVisible = <bool>[];
|
||||
final conforming = assemble(spec, {'title': 'X', 'what': 'a body'});
|
||||
await pumpEditor(
|
||||
tester,
|
||||
initialTemplate: spec,
|
||||
initialText: conforming,
|
||||
onChromeVisibleChanged: chromeVisible.add,
|
||||
);
|
||||
expect(find.byType(Stepper), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byTooltip('Exit guided'));
|
||||
await tester.pump();
|
||||
|
||||
expect(chromeVisible, contains(true));
|
||||
expect(find.text('Raw'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user