diff --git a/lib/ui/capture_screen.dart b/lib/ui/capture_screen.dart index 181e916..8bcd7e0 100644 --- a/lib/ui/capture_screen.dart +++ b/lib/ui/capture_screen.dart @@ -81,6 +81,10 @@ class _CaptureScreenState extends State 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 _lastSavedAt = null; _draftPriority = Priority.defaultValue; _draftStatus = Status.todo; + _chromeVisible = true; }); if (saved) { ScaffoldMessenger.of(context).showSnackBar( @@ -359,35 +364,44 @@ class _CaptureScreenState extends State crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // Pickers sit above the editor so the bottom-right Save FAB - // never overlaps them. - Row( - children: [ - Expanded( - child: _MetaDropdown( - 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( + label: 'Priority', + value: _draftPriority, + values: Priority.values, + labelOf: (p) => p.label, + onChanged: _setPriority, + ), ), - ), - const SizedBox(width: 12), - Expanded( - child: _MetaDropdown( - label: 'Status', - value: _draftStatus, - values: Status.values, - labelOf: (s) => s.label, - onChanged: _setStatus, + const SizedBox(width: 12), + Expanded( + child: _MetaDropdown( + 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, ), diff --git a/lib/ui/note_detail_screen.dart b/lib/ui/note_detail_screen.dart index 5c230f6..487c80e 100644 --- a/lib/ui/note_detail_screen.dart +++ b/lib/ui/note_detail_screen.dart @@ -28,6 +28,10 @@ class NoteDetailScreen extends StatefulWidget { class _NoteDetailScreenState extends State { 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 _persist(Note next) async { setState(() => _note = next); await widget.repository.upsert(next); @@ -60,38 +64,46 @@ class _NoteDetailScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Row( - children: [ - Expanded( - child: _MetaDropdown( - 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( + 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( - 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( + 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, ), ), diff --git a/lib/ui/note_editor.dart b/lib/ui/note_editor.dart index ff87152..52e9129 100644 --- a/lib/ui/note_editor.dart +++ b/lib/ui/note_editor.dart @@ -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 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 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 onChromeVisibleChanged; + /// Existing note text to load. Empty for a fresh draft. final String initialText; @@ -75,6 +98,18 @@ class _NoteEditorState extends State { 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 _section = {}; @@ -94,14 +129,30 @@ class _NoteEditorState extends State { 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 { _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 { _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( - 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( + 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( - 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( + 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 _buildWizardPriorityStep() { + return [ + DropdownButtonFormField( + 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 _buildWizardTemplateStep() { + final templates = NoteTemplate.all.where((t) => !t.isFreeform).toList(); + return [ + DropdownButtonFormField( + 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: diff --git a/test/capture_screen_test.dart b/test/capture_screen_test.dart index 2781df9..fc18bd2 100644 --- a/test/capture_screen_test.dart +++ b/test/capture_screen_test.dart @@ -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(find.byType(TextField)); + expect(raw.controller!.text, isEmpty); }); testWidgets('tapping Sync while unconfigured prompts for a token', ( diff --git a/test/note_detail_screen_test.dart b/test/note_detail_screen_test.dart index 634d155..deb16ad 100644 --- a/test/note_detail_screen_test.dart +++ b/test/note_detail_screen_test.dart @@ -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(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)); + 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')); diff --git a/test/note_editor_test.dart b/test/note_editor_test.dart index 7554e46..080bac0 100644 --- a/test/note_editor_test.dart +++ b/test/note_editor_test.dart @@ -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? onPriorityChanged, + ValueChanged? onChromeVisibleChanged, }) async { final emitted = []; 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(find.byType(TextField)); - expect(raw.controller!.text, startsWith('# T')); + final raw = tester.widget(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(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(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), 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), 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)); + 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 = []; + 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 = []; + 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); + }); }