From 5ca289ca81b3a802b1af1a9e380dd052b3b20975 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sat, 27 Jun 2026 12:15:19 +0200 Subject: [PATCH] Rework guided mode from Stepper to full-screen per-step view Replace the vertical Stepper widget with a full-screen single-step view: progress bar, label, helper text, and a tall TextField that expands to fill the available height. Navigation is Next/Back buttons only; the final step's Next becomes Done which exits to Raw. Removes _stepKeys (scroll-into-view keys) and the async _goToStep (Future.delayed + Scrollable.ensureVisible) since neither is needed without a collapsing Stepper. _goToStep is now synchronous. Nav buttons sit below an Expanded area so they stay above the soft keyboard when resizeToAvoidBottomInset resizes the Scaffold. Tests: replace find.byType(Stepper) with find.byType(LinearProgressIndicator), rewrite the navigation test, add three new tests (progress counter, last-step Done, Done exits to Raw). Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_017Cb7oE5Xsc8zSBHHtT6qwa --- lib/ui/note_editor.dart | 207 +++++++++++++++------------------- test/capture_screen_test.dart | 10 +- test/note_editor_test.dart | 91 ++++++++++----- 3 files changed, 157 insertions(+), 151 deletions(-) diff --git a/lib/ui/note_editor.dart b/lib/ui/note_editor.dart index 52e9129..3693dbe 100644 --- a/lib/ui/note_editor.dart +++ b/lib/ui/note_editor.dart @@ -9,7 +9,7 @@ enum NoteEditorMode { /// Read-only rendered Markdown (headings, guidance, bullets). preview, - /// Inline [Stepper], one step per template section. + /// Full-screen per-step view, one step per template section. guided, /// A single text field showing the assembled Markdown verbatim. @@ -25,8 +25,8 @@ enum NoteEditorMode { /// /// Modes (see [NoteEditorMode]): /// * **Preview** — the note rendered as Markdown, read-only. -/// * **Guided** — an inline [Stepper], one step per template section, each -/// with guidance on what to write and why the LLM needs it. +/// * **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 @@ -36,7 +36,7 @@ enum NoteEditorMode { /// 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 +/// 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 { @@ -113,12 +113,6 @@ class _NoteEditorState extends State { /// One controller per structured section (keyed by section key). final Map _section = {}; - /// One key per structured section, used to scroll the newly-active step's - /// content into view after a step change — the Stepper reflows height - /// (other steps collapse/expand) without moving the scroll offset, so - /// without this the active step's input can end up hidden off-screen. - final Map _stepKeys = {}; - /// Single field used for the freeform [NoteTemplate.blank] body and for raw /// mode of a structured template. final TextEditingController _body = TextEditingController(); @@ -187,36 +181,17 @@ class _NoteEditorState extends State { return desired; } - /// Ensures a controller and scroll-into-view key exist for every section - /// of [template]. + /// Ensures a controller exists for every section of [template]. void _ensureControllers(NoteTemplate template) { for (final s in template.sections) { _section.putIfAbsent(s.key, () => TextEditingController()); - _stepKeys.putIfAbsent(s.key, () => GlobalKey()); } } - /// Moves to step [index] and scrolls that step's content into view once the - /// Stepper's own expand/collapse transition has settled. The Stepper - /// animates each step's content height over [kThemeAnimationDuration], so - /// scrolling on the next post-frame callback (before that animation - /// finishes) measures a layout that's still mid-collapse — the scroll lands - /// on a stale position and the active step ends up hidden under the fixed - /// app bar above. Waiting for the animation to finish before measuring - /// fixes that. - Future _goToStep(int index) async { - setState(() => _currentStep = index); - await Future.delayed(kThemeAnimationDuration); - if (!mounted) return; - final key = _stepKeys[_template.sections[index].key]; - final stepContext = key?.currentContext; - if (stepContext == null || !stepContext.mounted) return; - await Scrollable.ensureVisible( - stepContext, - duration: const Duration(milliseconds: 200), - curve: Curves.easeInOut, - alignment: 0.0, - ); + void _goToStep(int index) { + setState(() { + _currentStep = index.clamp(0, _template.sections.length - 1); + }); } void _fillSections(Map values) { @@ -545,7 +520,7 @@ class _NoteEditorState extends State { case NoteEditorMode.raw: return _buildRaw(theme); case NoteEditorMode.guided: - return _buildStepper(theme); + return _buildStepPage(theme); } } @@ -566,98 +541,92 @@ class _NoteEditorState extends State { ); } - Widget _buildStepper(ThemeData theme) { + Widget _buildStepPage(ThemeData theme) { _ensureControllers(_template); final sections = _template.sections; - return SingleChildScrollView( - child: Stepper( - currentStep: _currentStep.clamp(0, sections.length - 1), - physics: const NeverScrollableScrollPhysics(), - onStepTapped: _goToStep, - onStepContinue: _currentStep < sections.length - 1 - ? () => _goToStep(_currentStep + 1) - : null, - onStepCancel: _currentStep > 0 - ? () => _goToStep(_currentStep - 1) - : null, - controlsBuilder: (context, details) { - return Padding( - padding: const EdgeInsets.only(top: 12), - child: Row( + 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: [ + // Progress bar + step counter + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 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 field + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (details.onStepContinue != null) - FilledButton( - onPressed: details.onStepContinue, - child: const Text('Next'), + const SizedBox(height: 8), + 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, ), - if (details.onStepCancel != null) ...[ - const SizedBox(width: 8), - TextButton( - onPressed: details.onStepCancel, - child: const Text('Back'), + ), + const SizedBox(height: 12), + TextField( + controller: controller, + autofocus: widget.autofocus && idx == 0, + maxLines: section.inline ? 1 : null, + minLines: section.inline ? 1 : 6, + keyboardType: TextInputType.multiline, + decoration: InputDecoration( + hintText: section.hint, + border: const OutlineInputBorder(), + isDense: true, ), - ], + onChanged: (_) { + setState(() {}); + _emit(); + }, + ), ], ), - ); - }, - steps: [ - for (var i = 0; i < sections.length; i++) - _stepFor(theme, sections[i], i), - ], - ), - ); - } - - Step _stepFor(ThemeData theme, TemplateSection section, int index) { - final controller = _section[section.key]!; - final hasValue = controller.text.trim().isNotEmpty; - return Step( - // Keyed on the title (not content): ensureVisible aligns this widget's - // top to the viewport's top, so keying the title — which Stepper - // renders *above* content as the tappable heading — is what keeps the - // step's own heading on-screen. Keying content alone scrolled the - // heading off the top of the viewport. - title: KeyedSubtree( - key: _stepKeys[section.key], - child: Text(section.isTitle ? 'title' : section.label), - ), - state: hasValue - ? StepState.complete - : (index == _currentStep ? StepState.editing : StepState.indexed), - isActive: index <= _currentStep, - content: Align( - alignment: Alignment.centerLeft, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - section.helper, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 8), - TextField( - controller: controller, - autofocus: widget.autofocus && index == 0, - maxLines: section.inline ? 1 : null, - minLines: section.inline ? 1 : 3, - keyboardType: TextInputType.multiline, - decoration: InputDecoration( - hintText: section.hint, - border: const OutlineInputBorder(), - isDense: true, - ), - // Rebuild so the step's completion tick reflects the value. - onChanged: (_) { - setState(() {}); - _emit(); - }, - ), - ], + ), ), - ), + // Navigation buttons — below Expanded so they stay above the keyboard + 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')), + ], + ), + ), + ], ); } } diff --git a/test/capture_screen_test.dart b/test/capture_screen_test.dart index fc18bd2..fd562eb 100644 --- a/test/capture_screen_test.dart +++ b/test/capture_screen_test.dart @@ -4,6 +4,7 @@ import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:todo/data/note.dart'; +import 'package:todo/data/note_template.dart'; import 'package:todo/sync/local_backup.dart'; import 'package:todo/sync/notes_markdown.dart'; import 'package:todo/ui/capture_screen.dart'; @@ -84,10 +85,13 @@ void main() { await tester.tap(find.text('Start')); await tester.pump(); - // Now in the bare guided stepper, showing the design-spec sections. + // Now on step 1 of the full-screen step page. + expect(find.byType(LinearProgressIndicator), findsOneWidget); + expect( + find.text('1 / ${NoteTemplate.llmDesignSpec.sections.length}'), + findsOneWidget, + ); expect(find.textContaining('imperative'), findsOneWidget); // title helper - expect(find.text('what'), findsOneWidget); // a section step header - expect(find.text('done'), findsOneWidget); }); testWidgets('saving the untouched template creates no note', (tester) async { diff --git a/test/note_editor_test.dart b/test/note_editor_test.dart index 080bac0..2de7c44 100644 --- a/test/note_editor_test.dart +++ b/test/note_editor_test.dart @@ -55,28 +55,22 @@ void main() { expect(emitted.last, startsWith('# Dark mode')); }); - testWidgets('stepper Next/Back and tapping a step header navigate', ( + testWidgets('step page Next/Back navigate and progress counter updates', ( tester, ) async { await pumpEditor(tester, initialTemplate: spec); - // A vertical Stepper keeps every step's controls in the tree (collapsed), - // so the buttons resolve to many identical widgets — they all drive the - // same shared continue/cancel callbacks, so tap the first. - expect(find.byType(Stepper), findsOneWidget); - // Only the current step's controls are hit-testable (others are collapsed - // to zero height), so .hitTestable() resolves the single visible button. - // Settle the expand/collapse animation so the next step's controls lay out. - await tester.tap(find.text('Next').hitTestable()); // advance past the title - await tester.pumpAndSettle(); - await tester.tap(find.text('Back').hitTestable()); // and back - await tester.pumpAndSettle(); - // Jump directly to a step by tapping its header. _goToStep delays past - // the Stepper's own collapse/expand animation before scrolling, so drain - // that timer rather than a bare pump — see CLAUDE.md's Timer pitfall. - await tester.tap(find.text('done')); - await tester.pump(const Duration(milliseconds: 250)); - expect(find.byType(Stepper), findsOneWidget); + expect(find.byType(LinearProgressIndicator), findsOneWidget); + expect(find.text('1 / ${spec.sections.length}'), findsOneWidget); + + await tester.tap(find.text('Next')); + await tester.pump(); + expect(find.text('2 / ${spec.sections.length}'), findsOneWidget); + + await tester.tap(find.text('Back')); + await tester.pump(); + expect(find.text('1 / ${spec.sections.length}'), findsOneWidget); + expect(find.byType(LinearProgressIndicator), findsOneWidget); }); testWidgets('switching to View renders the note as Markdown', (tester) async { @@ -129,7 +123,7 @@ void main() { await tester.pump(); expect(find.textContaining("doesn't match the template"), findsOneWidget); - expect(find.byType(Stepper), findsNothing); // still raw + expect(find.byType(LinearProgressIndicator), findsNothing); // still raw }); testWidgets( @@ -139,19 +133,19 @@ void main() { // 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); + expect(find.byType(LinearProgressIndicator), findsOneWidget); // 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); + expect(find.byType(LinearProgressIndicator), findsNothing); // …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.byType(LinearProgressIndicator), findsOneWidget); expect(find.text('Step 1 of 2'), findsNothing); // wizard was skipped }, ); @@ -193,7 +187,10 @@ void main() { final conforming = assemble(spec, {'title': 'Detected', 'what': 'x'}); await pumpEditor(tester, initialText: conforming); - expect(find.byType(Stepper), findsOneWidget); // guided by default + expect( + find.byType(LinearProgressIndicator), + findsOneWidget, + ); // guided by default expect(find.byTooltip('Exit guided'), findsOneWidget); }); @@ -203,7 +200,7 @@ void main() { await pumpEditor(tester, initialText: 'old\n\nwhat — legacy'); // Non-conforming → blank/raw, no guided stepper offered. - expect(find.byType(Stepper), findsNothing); + expect(find.byType(LinearProgressIndicator), findsNothing); expect(find.text('Guided'), findsNothing); }); @@ -249,7 +246,7 @@ void main() { ); // Guided was requested but the text does not conform → opened in Raw. - expect(find.byType(Stepper), findsNothing); + expect(find.byType(LinearProgressIndicator), findsNothing); final raw = tester.widget(find.byType(TextField)); expect(raw.controller!.text, 'cannot be guided'); }); @@ -268,7 +265,7 @@ void main() { expect(find.text('Step 1 of 2'), findsOneWidget); expect(find.byType(DropdownButtonFormField), findsOneWidget); - expect(find.byType(Stepper), findsNothing); + expect(find.byType(LinearProgressIndicator), findsNothing); }, ); @@ -341,7 +338,7 @@ void main() { await tester.pump(); expect(committed, Priority.high); - expect(find.byType(Stepper), findsOneWidget); + expect(find.byType(LinearProgressIndicator), findsOneWidget); expect(find.byTooltip('Exit guided'), findsOneWidget); }, ); @@ -364,7 +361,7 @@ void main() { await tester.pump(); expect(chromeVisible.last, true); - expect(find.byType(Stepper), findsNothing); + expect(find.byType(LinearProgressIndicator), findsNothing); expect(find.text('Raw'), findsOneWidget); // chrome's mode selector is back }); @@ -379,7 +376,7 @@ void main() { initialText: conforming, onChromeVisibleChanged: chromeVisible.add, ); - expect(find.byType(Stepper), findsOneWidget); + expect(find.byType(LinearProgressIndicator), findsOneWidget); await tester.tap(find.byTooltip('Exit guided')); await tester.pump(); @@ -387,4 +384,40 @@ void main() { expect(chromeVisible, contains(true)); expect(find.text('Raw'), findsOneWidget); }); + + testWidgets('last step shows Done instead of Next', (tester) async { + await pumpEditor(tester, initialTemplate: spec); + + // Navigate to the final step. + for (var i = 0; i < spec.sections.length - 1; i++) { + await tester.tap(find.text('Next')); + await tester.pump(); + } + + expect(find.text('Done'), findsOneWidget); + expect(find.text('Next'), findsNothing); + expect( + find.text('${spec.sections.length} / ${spec.sections.length}'), + findsOneWidget, + ); + }); + + testWidgets('Done on last step exits Guided and materialises text into Raw', ( + tester, + ) async { + await pumpEditor(tester, initialTemplate: spec); + await tester.enterText(find.byType(TextField).first, 'My title'); + await tester.pump(); + + for (var i = 0; i < spec.sections.length - 1; i++) { + await tester.tap(find.text('Next')); + await tester.pump(); + } + await tester.tap(find.text('Done')); + await tester.pump(); + + expect(find.byType(LinearProgressIndicator), findsNothing); + final raw = tester.widget(find.byType(TextField)); + expect(raw.controller!.text, startsWith('# My title')); + }); }