todo-app/lib/ui/note_editor_step_page.dart
Krzysztof kuhy Rudnicki 2ffae26f93 Capture feedback: haptic + checkmark on commit, motion tokens for routes and wizard bar
- Committing a captured note (New note tap with a persisted draft) fires
  HapticFeedback.lightImpact and plays a ~600 ms checkmark beside the counter;
  empty drafts and keystrokes confirm nothing.
- appRoute: one shared page route (fade + rise, AppDuration.slow) replaces all
  seven MaterialPageRoute sites; guard test keeps it that way.
- Wizard progress bar animates on AppDuration.base / AppCurve.standard.
- Everything honours disableAnimations; the haptic and the (static) checkmark
  still fire. Removes the finished dopamine-ux prompt (sound step rejected).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 09:28:13 +01:00

160 lines
5.3 KiB
Dart

/// The guided stepper's full-screen per-step view.
///
/// Split out of `note_editor.dart` for file size. Stateless on purpose: the
/// controllers, the current step and the template all stay owned by
/// `_NoteEditorState`, and are passed in — this widget only lays them out.
///
/// Returned directly from the editor's `build` so the [TextField] is a
/// first-level [Expanded] child of THIS column, the same depth as the raw
/// editor's. Nesting it inside a second Column broke the flutter constraint
/// chain in release builds (the inner Expanded got 0 height), so the flat
/// structure here is load-bearing rather than incidental.
library;
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:todo/data/note_template.dart';
import 'package:todo/ui/motion.dart';
/// One step of the guided editor: label, guidance, input, and navigation.
class NoteEditorStepPage extends StatelessWidget {
/// Creates the step view for [section], step [index] of [total].
const NoteEditorStepPage({
required this.section,
required this.controller,
required this.index,
required this.total,
required this.autofocus,
required this.onChanged,
required this.onGoToStep,
required this.onExit,
super.key,
});
/// The template section this step edits.
final TemplateSection section;
/// Controller for this section's text, owned by the editor.
final TextEditingController controller;
/// Zero-based index of this step, already clamped to the section range.
final int index;
/// How many steps the template has in total.
final int total;
/// Whether the input should take focus on first build.
final bool autofocus;
/// Called on every keystroke so the editor can re-emit the assembled text.
final VoidCallback onChanged;
/// Called with the step index to move to.
final ValueChanged<int> onGoToStep;
/// Called by the back arrow and by "Done" on the last step.
final VoidCallback onExit;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
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: onExit,
),
),
// Progress bar + step counter.
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 4),
child: Row(
children: [
Text('${index + 1} / $total', style: theme.textTheme.labelMedium),
const SizedBox(width: 8),
Expanded(
child: TweenAnimationBuilder<double>(
tween: Tween<double>(end: (index + 1) / total),
duration: motionDuration(context, AppDuration.base),
curve: AppCurve.standard,
builder: (_, value, _) =>
LinearProgressIndicator(value: value),
),
),
],
),
),
// 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 the raw editor, so expands:true works correctly.
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 0),
child: TextField(
controller: controller,
autofocus: autofocus && index == 0,
expands: true,
maxLines: null,
keyboardType: TextInputType.multiline,
textAlignVertical: TextAlignVertical.top,
decoration: InputDecoration(
hintText: section.hint,
border: const OutlineInputBorder(),
isDense: true,
),
onChanged: (_) => onChanged(),
),
),
),
// Navigation buttons — sibling of Expanded so keyboard pushes them up.
Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
if (index > 0)
TextButton(
onPressed: () => onGoToStep(index - 1),
child: const Text('Back'),
),
const Spacer(),
if (index < total - 1)
FilledButton(
onPressed: () => onGoToStep(index + 1),
child: const Text('Next'),
)
else
FilledButton(onPressed: onExit, child: const Text('Done')),
],
),
),
],
);
}
}