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>
This commit is contained in:
Krzysztof Rudnicki 2026-11-02 09:21:00 +01:00
parent 377ec17356
commit 2ffae26f93
16 changed files with 477 additions and 240 deletions

50
lib/ui/app_route.dart Normal file
View File

@ -0,0 +1,50 @@
/// The one page route every screen is pushed with, on the shared motion tokens.
library;
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:todo/ui/motion.dart';
/// A route for [builder]: fade plus a short rise over [AppDuration.slow],
/// instant under the OS "remove animations" setting.
Route<T> appRoute<T>(BuildContext context, WidgetBuilder builder) =>
_AppPageRoute<T>(
builder: builder,
duration: motionDuration(context, AppDuration.slow),
);
class _AppPageRoute<T> extends MaterialPageRoute<T> {
_AppPageRoute({required super.builder, required this.duration});
final Duration duration;
@override
Duration get transitionDuration => duration;
@override
Duration get reverseTransitionDuration => duration;
@override
Widget buildTransitions(
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
Widget child,
) {
final enter = CurvedAnimation(
parent: animation,
curve: AppCurve.decelerate,
reverseCurve: AppCurve.accelerate,
);
return FadeTransition(
opacity: enter,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 0.04),
end: Offset.zero,
).animate(enter),
child: child,
),
);
}
}

View File

@ -4,11 +4,13 @@
/// screen still owns every action; this just lays the row out. /// screen still owns every action; this just lays the row out.
library; library;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:todo/data/note_repository.dart'; import 'package:todo/data/note_repository.dart';
import 'package:todo/frame_stats.dart'; import 'package:todo/frame_stats.dart';
import 'package:todo/ui/attachment_picking.dart'; import 'package:todo/ui/attachment_picking.dart';
import 'package:todo/ui/capture_confirm_mark.dart';
/// App bar for the capture screen. /// App bar for the capture screen.
class CaptureAppBar extends StatelessWidget implements PreferredSizeWidget { class CaptureAppBar extends StatelessWidget implements PreferredSizeWidget {
@ -22,6 +24,7 @@ class CaptureAppBar extends StatelessWidget implements PreferredSizeWidget {
required this.onOpenList, required this.onOpenList,
required this.onOpenSettings, required this.onOpenSettings,
required this.onAttachFiles, required this.onAttachFiles,
required this.captured,
this.attachmentPicking, this.attachmentPicking,
super.key, super.key,
}); });
@ -50,6 +53,9 @@ class CaptureAppBar extends StatelessWidget implements PreferredSizeWidget {
/// Receives files picked with the 📎 action. /// Receives files picked with the 📎 action.
final ValueChanged<List<XFile>> onAttachFiles; final ValueChanged<List<XFile>> onAttachFiles;
/// Counts committed captures; each change plays the confirmation mark.
final ValueListenable<int> captured;
/// The picker behind 📎; null uses the platform one. /// The picker behind 📎; null uses the platform one.
final AttachmentPicking? attachmentPicking; final AttachmentPicking? attachmentPicking;
@ -114,6 +120,7 @@ class CaptureAppBar extends StatelessWidget implements PreferredSizeWidget {
); );
}, },
), ),
CaptureConfirmMark(captured: captured),
IconButton( IconButton(
tooltip: 'Settings', tooltip: 'Settings',
onPressed: onOpenSettings, onPressed: onOpenSettings,

View File

@ -0,0 +1,91 @@
/// The checkmark confirming a capture; static under "remove animations".
library;
import 'package:design_system/design_system.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
/// Plays once each time [captured] changes value.
class CaptureConfirmMark extends StatefulWidget {
/// Creates the mark.
const CaptureConfirmMark({required this.captured, super.key});
/// Key of the checkmark icon while it is visible.
static const ValueKey<String> iconKey = ValueKey('capture-confirm-icon');
/// Width always reserved, so the counter beside it never moves.
static const double slotWidth = 24;
/// Fade in, hold and fade out: three [AppDuration.base] thirds.
static final Duration duration = AppDuration.base * 3;
/// Counts captures; each change plays the confirmation.
final ValueListenable<int> captured;
@override
State<CaptureConfirmMark> createState() => _CaptureConfirmMarkState();
}
class _CaptureConfirmMarkState extends State<CaptureConfirmMark>
with SingleTickerProviderStateMixin {
late final AnimationController _controller = AnimationController(
vsync: this,
duration: CaptureConfirmMark.duration,
animationBehavior: AnimationBehavior.preserve,
);
@override
void initState() {
super.initState();
widget.captured.addListener(_play);
}
@override
void dispose() {
widget.captured.removeListener(_play);
_controller.dispose();
super.dispose();
}
void _play() => _controller.forward(from: 0);
double _opacity({required bool reduced}) {
final t = _controller.value;
if (reduced || (t >= 1 / 3 && t < 2 / 3)) return 1;
if (t < 1 / 3) return AppCurve.decelerate.transform(t * 3);
return 1 - AppCurve.accelerate.transform((t - 2 / 3) * 3);
}
@override
Widget build(BuildContext context) {
final reduced = MediaQuery.disableAnimationsOf(context);
return SizedBox(
width: CaptureConfirmMark.slotWidth,
child: AnimatedBuilder(
animation: _controller,
builder: (context, _) {
if (!_controller.isAnimating) return const SizedBox.shrink();
return Opacity(
opacity: _opacity(reduced: reduced),
child: const _CheckIcon(),
);
},
),
);
}
}
class _CheckIcon extends StatelessWidget {
const _CheckIcon();
@override
Widget build(BuildContext context) {
final color = Theme.of(context).colorScheme.primary;
return Icon(
Icons.check_circle,
key: CaptureConfirmMark.iconKey,
size: 18,
color: color,
);
}
}

View File

@ -38,6 +38,10 @@ class CaptureDraft {
/// yet, so the first write still reports the note as created. /// yet, so the first write still reports the note as created.
bool _reserved = false; bool _reserved = false;
/// Whether a note with content has been persisted for this draft, i.e.
/// whether committing it is a real capture.
bool get hasCapture => _id != null && !_reserved && _text.trim().isNotEmpty;
/// The draft's priority, applied to every write. /// The draft's priority, applied to every write.
Priority priority = Priority.defaultValue; Priority priority = Priority.defaultValue;

View File

@ -1,9 +1,9 @@
import 'dart:async'; import 'dart:async';
import 'dart:typed_data';
import 'package:crdt_sync/crdt_sync.dart'; import 'package:crdt_sync/crdt_sync.dart';
import 'package:design_system/design_system.dart'; import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:todo/analytics/analytics_event.dart'; import 'package:todo/analytics/analytics_event.dart';
import 'package:todo/analytics/analytics_service.dart'; import 'package:todo/analytics/analytics_service.dart';
@ -15,6 +15,7 @@ import 'package:todo/data/note_template.dart';
import 'package:todo/sync/firebase_backend.dart'; import 'package:todo/sync/firebase_backend.dart';
import 'package:todo/sync/local_backup.dart'; import 'package:todo/sync/local_backup.dart';
import 'package:todo/sync/sync_settings.dart'; import 'package:todo/sync/sync_settings.dart';
import 'package:todo/ui/app_route.dart';
import 'package:todo/ui/attachment_picking.dart'; import 'package:todo/ui/attachment_picking.dart';
import 'package:todo/ui/capture_app_bar.dart'; import 'package:todo/ui/capture_app_bar.dart';
import 'package:todo/ui/capture_draft.dart'; import 'package:todo/ui/capture_draft.dart';
@ -71,6 +72,9 @@ class _CaptureScreenState extends State<CaptureScreen>
final SyncStatusStore _syncStatus = SyncStatusStore(); final SyncStatusStore _syncStatus = SyncStatusStore();
/// Bumped once per committed capture; drives the confirmation checkmark.
final ValueNotifier<int> _captures = ValueNotifier<int>(0);
/// Runs sync passes and records their outcome; see [CaptureSyncRunner]. /// Runs sync passes and records their outcome; see [CaptureSyncRunner].
late final CaptureSyncRunner _runner = CaptureSyncRunner( late final CaptureSyncRunner _runner = CaptureSyncRunner(
repository: widget.repository, repository: widget.repository,
@ -130,6 +134,7 @@ class _CaptureScreenState extends State<CaptureScreen>
_localBackup.dispose(); _localBackup.dispose();
_draft.dispose(); _draft.dispose();
_syncStatus.dispose(); _syncStatus.dispose();
_captures.dispose();
super.dispose(); super.dispose();
} }
@ -150,6 +155,7 @@ class _CaptureScreenState extends State<CaptureScreen>
onOpenSettings: _openSettings, onOpenSettings: _openSettings,
onAttachFiles: _attachFiles, onAttachFiles: _attachFiles,
attachmentPicking: widget.attachmentPicking, attachmentPicking: widget.attachmentPicking,
captured: _captures,
), ),
body: _captureBody(advanced: advanced), body: _captureBody(advanced: advanced),
); );

View File

@ -66,8 +66,9 @@ extension _CaptureSyncBehaviour on _CaptureScreenState {
final current = _settings ?? await SyncSettings.load(); final current = _settings ?? await SyncSettings.load();
if (!mounted) return; if (!mounted) return;
await Navigator.of(context).push<SyncSettings>( await Navigator.of(context).push<SyncSettings>(
MaterialPageRoute( appRoute(
builder: (_) => SettingsScreen( context,
(_) => SettingsScreen(
initial: current, initial: current,
repository: widget.repository, repository: widget.repository,
appSettings: widget.appSettings, appSettings: widget.appSettings,
@ -87,8 +88,9 @@ extension _CaptureSyncBehaviour on _CaptureScreenState {
void _openList() { void _openList() {
_logEvent('screen_view', params: {'screen': 'notes_list'}); _logEvent('screen_view', params: {'screen': 'notes_list'});
Navigator.of(context).push<void>( Navigator.of(context).push<void>(
MaterialPageRoute( appRoute(
builder: (_) => NotesListScreen( context,
(_) => NotesListScreen(
repository: widget.repository, repository: widget.repository,
appSettings: widget.appSettings, appSettings: widget.appSettings,
), ),
@ -206,10 +208,15 @@ extension _CaptureSyncBehaviour on _CaptureScreenState {
} }
/// Finalises the current idea and resets the field to a fresh template. /// Finalises the current idea and resets the field to a fresh template.
/// The field going blank is the only feedback needed — autosave already /// Autosave already persisted every keystroke, so the only thing left to
/// persisted every keystroke, so there is nothing left to confirm. /// give is the confirmation -- and only when a note was really captured.
/// The haptic fires here, on the tap, before anything touches the network.
void _saveAndReset() { void _saveAndReset() {
_logEvent('new_note_reset'); _logEvent('new_note_reset');
if (_draft.hasCapture) {
unawaited(HapticFeedback.lightImpact());
_captures.value++;
}
_rebuild(() { _rebuild(() {
// Bumping the generation remounts NoteForm, which resets the chrome // Bumping the generation remounts NoteForm, which resets the chrome
// gate to visible for free -- no separate flag to keep in step. // gate to visible for free -- no separate flag to keep in step.

View File

@ -17,6 +17,7 @@ import 'package:todo/attachments/image_password_store.dart';
import 'package:todo/attachments/image_store_api.dart'; import 'package:todo/attachments/image_store_api.dart';
import 'package:todo/attachments/image_store_scope.dart'; import 'package:todo/attachments/image_store_scope.dart';
import 'package:todo/images/dufs_image_client.dart'; import 'package:todo/images/dufs_image_client.dart';
import 'package:todo/ui/app_route.dart';
/// The command that creates the login, shown wherever it is missing. /// The command that creates the login, shown wherever it is missing.
const kAddLoginCommand = const kAddLoginCommand =
@ -37,9 +38,7 @@ class CloudImagesTile extends StatelessWidget {
subtitle: const Text('Where attached images and files upload (dufs)'), subtitle: const Text('Where attached images and files upload (dufs)'),
trailing: const Icon(Icons.chevron_right), trailing: const Icon(Icons.chevron_right),
onTap: () => Navigator.of(context).push<void>( onTap: () => Navigator.of(context).push<void>(
MaterialPageRoute( appRoute(context, (_) => CloudImagesScreen(httpClient: httpClient)),
builder: (_) => CloudImagesScreen(httpClient: httpClient),
),
), ),
); );
} }

View File

@ -7,6 +7,7 @@ import 'package:flutter/material.dart';
import 'package:todo/attachments/image_store_api.dart'; import 'package:todo/attachments/image_store_api.dart';
import 'package:todo/attachments/image_store_scope.dart'; import 'package:todo/attachments/image_store_scope.dart';
import 'package:todo/images/attachment_ref.dart'; import 'package:todo/images/attachment_ref.dart';
import 'package:todo/ui/app_route.dart';
import 'package:todo/ui/images/image_viewer_screen.dart'; import 'package:todo/ui/images/image_viewer_screen.dart';
import 'package:todo/ui/images/local_image.dart'; import 'package:todo/ui/images/local_image.dart';
@ -39,9 +40,7 @@ class ImageEmbed extends StatelessWidget {
onTap: source is MissingImageSource onTap: source is MissingImageSource
? null ? null
: () => Navigator.of(context).push<void>( : () => Navigator.of(context).push<void>(
MaterialPageRoute( appRoute(context, (_) => ImageViewerScreen(source: source)),
builder: (_) => ImageViewerScreen(source: source),
),
), ),
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),

10
lib/ui/motion.dart Normal file
View File

@ -0,0 +1,10 @@
/// Reduced-motion handling shared by every animated surface.
library;
import 'package:design_system/design_system.dart';
import 'package:flutter/widgets.dart';
/// [token] unless the OS "remove animations" setting is on, in which case
/// [AppDuration.instant]. Haptics are not motion and never go through this.
Duration motionDuration(BuildContext context, Duration token) =>
MediaQuery.disableAnimationsOf(context) ? AppDuration.instant : token;

View File

@ -11,9 +11,11 @@
/// structure here is load-bearing rather than incidental. /// structure here is load-bearing rather than incidental.
library; library;
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:todo/data/note_template.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. /// One step of the guided editor: label, guidance, input, and navigation.
class NoteEditorStepPage extends StatelessWidget { class NoteEditorStepPage extends StatelessWidget {
@ -77,7 +79,13 @@ class NoteEditorStepPage extends StatelessWidget {
Text('${index + 1} / $total', style: theme.textTheme.labelMedium), Text('${index + 1} / $total', style: theme.textTheme.labelMedium),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: LinearProgressIndicator(value: (index + 1) / total), child: TweenAnimationBuilder<double>(
tween: Tween<double>(end: (index + 1) / total),
duration: motionDuration(context, AppDuration.base),
curve: AppCurve.standard,
builder: (_, value, _) =>
LinearProgressIndicator(value: value),
),
), ),
], ],
), ),

View File

@ -10,6 +10,7 @@ import 'package:flutter/material.dart';
import 'package:todo/data/app_settings.dart'; import 'package:todo/data/app_settings.dart';
import 'package:todo/data/note.dart'; import 'package:todo/data/note.dart';
import 'package:todo/data/note_repository.dart'; import 'package:todo/data/note_repository.dart';
import 'package:todo/ui/app_route.dart';
import 'package:todo/ui/note_actions_sheet.dart'; import 'package:todo/ui/note_actions_sheet.dart';
import 'package:todo/ui/note_detail_screen.dart'; import 'package:todo/ui/note_detail_screen.dart';
@ -24,8 +25,9 @@ Future<void> openNote(
// re-focuses it on return, so backing out of a note would pop the keyboard. // re-focuses it on return, so backing out of a note would pop the keyboard.
FocusManager.instance.primaryFocus?.unfocus(); FocusManager.instance.primaryFocus?.unfocus();
await Navigator.of(context).push<void>( await Navigator.of(context).push<void>(
MaterialPageRoute( appRoute(
builder: (_) => NoteDetailScreen( context,
(_) => NoteDetailScreen(
note: note, note: note,
repository: repository, repository: repository,
appSettings: appSettings, appSettings: appSettings,

View File

@ -14,6 +14,7 @@ import 'package:todo/sync/firebase_backend.dart';
import 'package:todo/sync/google_sign_in_backend.dart'; import 'package:todo/sync/google_sign_in_backend.dart';
import 'package:todo/sync/notes_markdown.dart'; import 'package:todo/sync/notes_markdown.dart';
import 'package:todo/sync/sync_settings.dart'; import 'package:todo/sync/sync_settings.dart';
import 'package:todo/ui/app_route.dart';
import 'package:todo/ui/cloud_images_settings.dart'; import 'package:todo/ui/cloud_images_settings.dart';
import 'package:todo/ui/github_mirror_screen.dart'; import 'package:todo/ui/github_mirror_screen.dart';
@ -168,8 +169,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
Future<void> _openSyncSettings() async { Future<void> _openSyncSettings() async {
await Navigator.of(context).push<void>( await Navigator.of(context).push<void>(
MaterialPageRoute( appRoute(
builder: (_) => SyncSettingsScreen( context,
(_) => SyncSettingsScreen(
accountLoader: widget.accountLoader ?? loadAccount, accountLoader: widget.accountLoader ?? loadAccount,
accountSaver: widget.accountSaver ?? saveAccount, accountSaver: widget.accountSaver ?? saveAccount,
accountClearer: widget.accountClearer ?? clearAccount, accountClearer: widget.accountClearer ?? clearAccount,
@ -186,8 +188,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
Future<void> _openGitHubMirror() async { Future<void> _openGitHubMirror() async {
await Navigator.of(context).push<SyncSettings>( await Navigator.of(context).push<SyncSettings>(
MaterialPageRoute( appRoute(
builder: (_) => GitHubMirrorScreen( context,
(_) => GitHubMirrorScreen(
initial: widget.initial, initial: widget.initial,
repository: widget.repository, repository: widget.repository,
appSettings: widget.appSettings, appSettings: widget.appSettings,

View File

@ -1,220 +0,0 @@
> **This file is a ready-to-use prompt.** Open this repo and say
> "do dopamine-ux-todo". It is self-contained -- it needs no context from
> any other session.
>
> Canonical copy: `~/src/utils/dopamine-ux/06-todo.md` (source of truth; this is a
> distributed copy). Running order and cross-prompt rules: `~/src/utils/dopamine-ux/00-INDEX.md`.
> Generated 2026-08-16 from a survey of this repo -- line numbers are accurate
> as of that date; **anchor on the symbol names, not the line numbers.**
## ⛔ WHEN YOU FINISH THIS PROMPT
Do **both**, in the same session that completes the work:
1. **Delete this file** and commit that deletion together with the
implementation:
```bash
git rm prompts/dopamine-ux-todo.md
```
Do not leave a finished prompt lying in the repo -- a stale prompt is
indistinguishable from a pending one, and the next session will re-run it.
2. **Log completion in the canonical index**, in `~/src/utils` (a *different* repo,
so it needs its own commit):
```bash
cd ~/src/utils
# append to the "Completion log" table in dopamine-ux/00-INDEX.md:
# | 06-todo.md | DONE <YYYY-MM-DD> | <impl commit sha> | <one-line note> |
git add dopamine-ux/00-INDEX.md
git commit -m "dopamine-ux: mark 06-todo.md done"
git push
```
If you finish only part of the prompt, do **neither** -- instead edit this file
to strike out what is done and state what remains, so the next session starts
from the true state. Partial completion must never be recorded as DONE.
---
Adopt the motion tokens in todo, and make capture feel immediate
## findings (3, ranked by value over effort)
1. **Capture gives no confirmation** — a successful capture is indistinguishable
from a no-op unless you go looking for the note.
2. **Zero motion, zero haptics** — despite being the only app already wired to
the shared package, so adoption here is cheapest.
3. **The pinned `design_system` tag predates the motion tokens** — nothing
compiles until the ref is bumped. Easy to forget, fatal if skipped.
## what
`todo` is the only app in kuhy's fleet already wired to the shared
`design_system` package — it has **deliberately no local `theme.dart`** (the
pubspec comment says so: nothing to drift from the frozen table). That makes it
the cheapest place to adopt the motion tokens added by prompt 01, and the
reference implementation the other apps get compared against.
Today it has **zero motion and zero haptics**. Verified 2026-08-16: no
`Animated*`, no `AnimationController`, no `HapticFeedback`, no audio. Its only
progress UI is the note-editor wizard bar. Capturing a note — the app's whole
purpose, and the thing that has to feel frictionless enough to actually do —
gives nothing back beyond a toast on error.
Per kuhy's rule for this project: **reward the capture, never the completion
count.** A todo app that celebrates finishing things teaches you to log easy
things; one that celebrates *capturing* keeps the backlog honest.
## where
Repo: `~/src/todo`.
**First step — bump the dependency.** `pubspec.yaml` pins `design_system` at git
ref `design_system-v0.1.0`, which predates the motion tokens. Bump it to
**`design_system-v0.2.0`** (cut by prompt 01) or the tag that prompt actually
printed. Without this, `AppDuration` does not exist and nothing else here compiles.
Primary:
- `lib/ui/capture_screen.dart`, `lib/ui/capture_screen_widget.dart`,
`lib/ui/capture_screen_sync.dart` — the capture path. `showError` ~:76 and
`showToast` ~:78 in `capture_screen_sync.dart` are the fleet's only uses of the
shared feedback helpers.
- `lib/ui/capture_app_bar.dart` — progress indicators ~:66, :84.
- `lib/ui/note_editor.dart` — `showError` ~:115.
- `lib/ui/note_editor_step_page.dart` ~:80 — `LinearProgressIndicator(value:
(index+1)/total)`, the wizard step bar. The one honest progress surface.
- `lib/ui/notes_list_screen.dart` — `_searchDebounce = 250ms` ~:45 (**not**
animation; do not tokenise it).
- `lib/ui/notes_list_navigation.dart`, `lib/ui/settings_screen.dart` (2×),
`lib/ui/capture_screen.dart` — the `MaterialPageRoute` sites, all using the
default platform transition.
Settings — **this app has the only working toggle in the fleet; it is the
pattern the other prompts copy**:
- `lib/ui/settings_screen.dart` ~:213-225 — `ValueListenableBuilder<AppSettings>`
wrapping a `SwitchListTile`; setter ~:111-140 (logs an `AnalyticsEvent`, opens
a Firebase client, calls `withAdvancedMode`).
- `lib/data/app_settings.dart` — `AppSettings` immutable class,
`SharedPreferences` keys ~:38-39, `load()` ~:42, `withAdvancedMode` ~:60,
`reconcileWithRemote` ~:98, `adopt` ~:132 (last-writer-wins cross-device sync
via `settings/advancedMode` ~:20). Held app-wide as a `ValueNotifier`.
Theme: no local file. `lib/main.dart` ~:4 imports `design_system`; ~:60-61 wires
`theme: buildLightTheme(), darkTheme: buildDarkTheme()`. Source of truth is
`~/src/utils/design_system/lib/src/theme.dart`.
## must
**Step 1 — motion + haptics (no new dependency).**
- Bump `design_system` to the new tag **first**, and confirm `AppDuration` /
`AppCurve` resolve before writing any other code.
- Add `HapticFeedback.selectionClick()` (or the token vocabulary's "confirm" cue)
on **successful note capture**. `HapticFeedback` comes from
`package:flutter/services.dart` — no pubspec change. Add
`<uses-permission android:name="android.permission.VIBRATE"/>` to
`android/app/src/main/AndroidManifest.xml`; it is absent today.
- Fire the haptic **on the tap**, not after the Firebase sync returns. The whole
point of the micro-animation idea is that immediate feedback removes the
perceived latency; a cue gated on a network round-trip reintroduces it.
- Give the capture a visible confirmation — currently a successful capture is
indistinguishable from a no-op unless you go looking.
- Animate the wizard `LinearProgressIndicator` between steps rather than jumping.
- Apply the shared page-transition/motion tokens to the `MaterialPageRoute` sites
so navigation matches the rest of the system.
- Honour `MediaQuery.of(context).disableAnimations`: durations collapse to zero,
haptics still fire.
**Step 2 — sound (separate, independently revertable).**
- Add an audio dependency and an `assets:` block to `pubspec.yaml` (neither
exists) plus a short capture-confirm cue.
- Sound ships **on**, with an opt-out `SwitchListTile` — copy the
`advancedMode` pattern verbatim (widget ~:213-225, setter ~:111-140, field in
`app_settings.dart`). It is the fleet's proven toggle; do not invent a new one.
- Consider whether the sound preference should sync across devices like
`advancedMode` does, or stay local. Local is the safer default — a per-device
audio choice is usually what people mean.
**Both steps:**
- must not: reward **completing** a todo more than capturing one. No streak on
completions, no counter that makes finishing feel better than recording. The
backlog is only useful if capture stays frictionless and honest.
- must not: add a variable/random reward to capture. There is no real pool to
draw from here, so any variance would be manufactured — which fails the
project's second test.
- must not: fabricate progress. The wizard bar's `(index+1)/total` is true; keep
it that way.
- must not: hand-copy token values into a local `theme.dart`. This app
deliberately has none — that is a feature, and the pubspec comment says so.
- must not: add `# noqa`-equivalents or suppress analyzer warnings.
- optional: animate the notes-list reordering/filtering, if it falls out cheaply
from the shared tokens.
## done
1. `pubspec.yaml` references the new `design_system` tag and `flutter pub get`
resolves it.
2. Capturing a note produces a haptic within ~50ms of the tap and a visible
confirmation.
3. Wizard step transitions and page navigation use shared motion tokens; no
inline `Duration` remains for animation purposes (the 250ms search debounce
and 5s auto-sync debounce stay as they are — they are not motion).
4. `cd ~/src/todo && flutter analyze` is clean.
5. `cd ~/src/todo && flutter test` passes.
6. With OS "remove animations" enabled, the app works and durations are zero.
7. Step 2: the sound toggle flips, persists across restart, and silences the cue.
## verify
**On the phone — todo is mobile-primary.**
```
adb devices # confirm 23181JEGR08034
cd ~/src/todo
flutter build apk --release
adb install -r build/app/outputs/flutter-apk/app-release.apk
```
Never `flutter install`, never `adb uninstall`, never `pm clear` — those wipe
real notes. `adb install -r` preserves them.
Then capture a real note on the device and report what the phone did. A haptic is
a physical outcome: say what you felt, do not infer it from code. Also step
through the note-editor wizard to confirm the progress bar animates.
If the app also has a desktop/web surface deployed under `/opt/`, note that it
can go stale independently — but this prompt's done-condition is the phone.
## read first
- `lib/ui/settings_screen.dart` ~:213-225 and `lib/data/app_settings.dart` — the
toggle pattern, in full, before writing the sound switch.
- `lib/ui/capture_screen_sync.dart` ~:76-78 — how `showToast`/`showError` are
used today; the shared helpers live in
`~/src/utils/design_system/lib/src/feedback.dart`.
- `~/src/utils/design_system/lib/src/feedback.dart` — `showToast`/`showError`, `_show`
~:50. `confirm.dart` beside it **already imports `flutter/services.dart`**, so
if prompt 01 added a shared haptic helper it lives near here.
- `~/src/utils/unified-design-system/motion.md` — the vocabulary from prompt 01.
**Prompt 01 must have run first, and its tag must be cut.**
- `pubspec.yaml` — read the comment explaining why there is no local theme.
## context you would otherwise rediscover
- `showToast` and `showError` already carry fixed dwell times in the shared
package (`_toastDuration = 3s`, `_errorDuration = 5s`,
`feedback.dart` ~:14, :18). Those are **not** motion tokens; leave them.
- This is the **only** app on `design_system`. diet-guard, workout_app and
wake_alarm hand-transcribe tokens into local theme files, so a widget copied
from this app into one of those will not compile unmodified.
- The app has no streaks, no rings, no history charts, and no completion counts
today. That is a deliberate starting point for this prompt, not an oversight to
fix wholesale.
- Settings sync cross-device last-writer-wins through Firebase
(`settings/advancedMode`). If you add a synced preference, follow
`reconcileWithRemote`/`adopt`; if local-only, skip that path entirely.
REMOVE ME AFTER FINISH

View File

@ -0,0 +1,138 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:todo/ui/capture_confirm_mark.dart';
import '../capture_screen_harness.dart';
import 'motion_harness.dart';
const _lightImpact = 'HapticFeedbackType.lightImpact';
final _mark = find.byKey(CaptureConfirmMark.iconKey);
double _opacity(WidgetTester tester) => tester
.widget<Opacity>(find.ancestor(of: _mark, matching: find.byType(Opacity)))
.opacity;
Future<void> _type(WidgetTester tester) async {
await tester.enterText(find.byType(TextField), 'buy milk');
await tester.pump();
}
void main() {
testWidgets('typing alone neither vibrates nor shows the checkmark', (
tester,
) async {
final haptics = recordHaptics(tester);
await pumpCapture(tester);
await _type(tester);
await tester.enterText(find.byType(TextField), 'buy milk and eggs');
await tester.pump(const Duration(milliseconds: 100));
expect(haptics, isEmpty);
expect(_mark, findsNothing);
});
testWidgets('committing a captured note vibrates once, immediately', (
tester,
) async {
final haptics = recordHaptics(tester);
await pumpCapture(tester);
await _type(tester);
await tester.tap(find.byTooltip('New note'));
await tester.pump(); // one frame: the cue must not wait for anything
expect(haptics, [_lightImpact]);
await tester.pump(const Duration(milliseconds: 700));
});
testWidgets('the checkmark fades in, holds, fades out in ~600 ms', (
tester,
) async {
recordHaptics(tester);
await pumpCapture(tester);
await _type(tester);
await tester.tap(find.byTooltip('New note'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(_mark, findsOneWidget);
expect(_opacity(tester), inExclusiveRange(0, 1)); // fading in
await tester.pump(const Duration(milliseconds: 200)); // t = 300 ms
expect(_opacity(tester), 1); // holding
await tester.pump(const Duration(milliseconds: 200)); // t = 500 ms
expect(_opacity(tester), inExclusiveRange(0, 1)); // fading out
await tester.pump(const Duration(milliseconds: 200)); // t = 700 ms
expect(_mark, findsNothing);
});
testWidgets('an empty draft is not a capture: no haptic, no checkmark', (
tester,
) async {
final haptics = recordHaptics(tester);
await pumpCapture(tester);
await tester.tap(find.byTooltip('New note'));
await tester.pump(const Duration(milliseconds: 100));
expect(haptics, isEmpty);
expect(_mark, findsNothing);
});
testWidgets('each capture confirms once; tapping the reset box does not', (
tester,
) async {
final haptics = recordHaptics(tester);
await pumpCapture(tester);
await _type(tester);
await tester.tap(find.byTooltip('New note'));
await tester.pump(); // the ticker's clock starts on its first frame
await tester.pump(const Duration(milliseconds: 700));
await tester.tap(find.byTooltip('New note')); // nothing new to capture
await tester.pump(const Duration(milliseconds: 100));
expect(haptics, hasLength(1));
expect(_mark, findsNothing);
await _type(tester);
await tester.tap(find.byTooltip('New note'));
await tester.pump(const Duration(milliseconds: 100));
expect(haptics, hasLength(2));
expect(_mark, findsOneWidget);
await tester.pump(const Duration(milliseconds: 700));
});
testWidgets('remove-animations: still vibrates, checkmark static', (
tester,
) async {
final haptics = recordHaptics(tester);
disableAnimations(tester);
await pumpCapture(tester);
await _type(tester);
await tester.tap(find.byTooltip('New note'));
await tester.pump();
expect(haptics, [_lightImpact]);
await tester.pump(const Duration(milliseconds: 1));
expect(_opacity(tester), 1); // no fade-in
await tester.pump(const Duration(milliseconds: 400));
expect(_opacity(tester), 1); // no fade-out either
await tester.pump(const Duration(milliseconds: 300));
expect(_mark, findsNothing);
});
testWidgets('the checkmark reserves its width so the counter never jumps', (
tester,
) async {
recordHaptics(tester);
await pumpCapture(tester);
final before = tester.getTopLeft(find.text('0'));
await _type(tester);
await tester.tap(find.byTooltip('New note'));
await tester.pump(const Duration(milliseconds: 300));
expect(tester.getTopLeft(find.text('1')), before);
await tester.pump(const Duration(milliseconds: 400));
});
}

View File

@ -0,0 +1,37 @@
/// Shared helpers for the motion / haptic tests.
///
/// Deliberately NOT named `*_test.dart`: the runner would collect it and fail
/// on the missing `main()`.
library;
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
/// Records the arguments of every `HapticFeedback.*` call for the test's
/// lifetime. Matches the platform channel Flutter's [HapticFeedback] uses.
List<String> recordHaptics(WidgetTester tester) {
final calls = <String>[];
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform,
(call) async {
if (call.method == 'HapticFeedback.vibrate') {
calls.add(call.arguments as String);
}
return null;
},
);
addTearDown(
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform,
null,
),
);
return calls;
}
/// Turns the OS "remove animations" setting on for this test.
void disableAnimations(WidgetTester tester) {
tester.platformDispatcher.accessibilityFeaturesTestValue =
const FakeAccessibilityFeatures(disableAnimations: true);
addTearDown(tester.platformDispatcher.clearAccessibilityFeaturesTestValue);
}

View File

@ -0,0 +1,96 @@
import 'dart:async';
import 'dart:io';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:todo/ui/app_route.dart';
import '../note_editor_harness.dart';
import 'motion_harness.dart';
double _barValue(WidgetTester tester) => tester
.widget<LinearProgressIndicator>(find.byType(LinearProgressIndicator))
.value!;
Future<void> _pushPage(WidgetTester tester) async {
late BuildContext ctx;
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (c) {
ctx = c;
return const Scaffold(body: Text('home'));
},
),
),
);
unawaited(
Navigator.of(
ctx,
).push<void>(appRoute(ctx, (_) => const Scaffold(body: Text('page')))),
);
await tester.pump();
}
void main() {
test('every page route goes through appRoute', () {
final offenders = <String>[];
for (final f in Directory('lib').listSync(recursive: true)) {
if (f is! File || !f.path.endsWith('.dart')) continue;
if (f.path.endsWith('ui/app_route.dart')) continue;
if (f.readAsStringSync().contains('MaterialPageRoute(')) {
offenders.add(f.path);
}
}
expect(offenders, isEmpty);
});
testWidgets('a pushed page eases in over AppDuration.slow', (tester) async {
await _pushPage(tester);
await tester.pump(AppDuration.slow ~/ 4);
expect(find.text('page'), findsOneWidget);
final fade = tester.widget<FadeTransition>(
find
.ancestor(
of: find.text('page'),
matching: find.byType(FadeTransition),
)
.first,
);
expect(fade.opacity.value, inExclusiveRange(0, 1));
await tester.pump(AppDuration.slow);
expect(find.text('home'), findsNothing); // fully covered, route settled
});
testWidgets('remove-animations: the page is there at once', (tester) async {
disableAnimations(tester);
await _pushPage(tester);
await tester.pump(const Duration(milliseconds: 1));
expect(find.text('home'), findsNothing);
expect(find.text('page'), findsOneWidget);
});
testWidgets('wizard progress bar animates between steps', (tester) async {
await pumpEditor(tester, initialTemplate: spec);
final total = spec.sections.length;
expect(_barValue(tester), closeTo(1 / total, 1e-9));
await tester.tap(find.text('Next'));
await tester.pump();
await tester.pump(AppDuration.base ~/ 2);
expect(_barValue(tester), inExclusiveRange(1 / total, 2 / total));
await tester.pump(AppDuration.base);
expect(_barValue(tester), closeTo(2 / total, 1e-9));
});
testWidgets('remove-animations: the progress bar jumps', (tester) async {
disableAnimations(tester);
await pumpEditor(tester, initialTemplate: spec);
await tester.tap(find.text('Next'));
await tester.pump();
expect(_barValue(tester), closeTo(2 / spec.sections.length, 1e-9));
});
}