diff --git a/lib/ui/app_route.dart b/lib/ui/app_route.dart new file mode 100644 index 0000000..24aa534 --- /dev/null +++ b/lib/ui/app_route.dart @@ -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 appRoute(BuildContext context, WidgetBuilder builder) => + _AppPageRoute( + builder: builder, + duration: motionDuration(context, AppDuration.slow), + ); + +class _AppPageRoute extends MaterialPageRoute { + _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 animation, + Animation secondaryAnimation, + Widget child, + ) { + final enter = CurvedAnimation( + parent: animation, + curve: AppCurve.decelerate, + reverseCurve: AppCurve.accelerate, + ); + return FadeTransition( + opacity: enter, + child: SlideTransition( + position: Tween( + begin: const Offset(0, 0.04), + end: Offset.zero, + ).animate(enter), + child: child, + ), + ); + } +} diff --git a/lib/ui/capture_app_bar.dart b/lib/ui/capture_app_bar.dart index 44f8cb9..21868d8 100644 --- a/lib/ui/capture_app_bar.dart +++ b/lib/ui/capture_app_bar.dart @@ -4,11 +4,13 @@ /// screen still owns every action; this just lays the row out. library; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:todo/data/note_repository.dart'; import 'package:todo/frame_stats.dart'; import 'package:todo/ui/attachment_picking.dart'; +import 'package:todo/ui/capture_confirm_mark.dart'; /// App bar for the capture screen. class CaptureAppBar extends StatelessWidget implements PreferredSizeWidget { @@ -22,6 +24,7 @@ class CaptureAppBar extends StatelessWidget implements PreferredSizeWidget { required this.onOpenList, required this.onOpenSettings, required this.onAttachFiles, + required this.captured, this.attachmentPicking, super.key, }); @@ -50,6 +53,9 @@ class CaptureAppBar extends StatelessWidget implements PreferredSizeWidget { /// Receives files picked with the 📎 action. final ValueChanged> onAttachFiles; + /// Counts committed captures; each change plays the confirmation mark. + final ValueListenable captured; + /// The picker behind 📎; null uses the platform one. final AttachmentPicking? attachmentPicking; @@ -114,6 +120,7 @@ class CaptureAppBar extends StatelessWidget implements PreferredSizeWidget { ); }, ), + CaptureConfirmMark(captured: captured), IconButton( tooltip: 'Settings', onPressed: onOpenSettings, diff --git a/lib/ui/capture_confirm_mark.dart b/lib/ui/capture_confirm_mark.dart new file mode 100644 index 0000000..160770a --- /dev/null +++ b/lib/ui/capture_confirm_mark.dart @@ -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 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 captured; + + @override + State createState() => _CaptureConfirmMarkState(); +} + +class _CaptureConfirmMarkState extends State + 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, + ); + } +} diff --git a/lib/ui/capture_draft.dart b/lib/ui/capture_draft.dart index 9adde3a..7b30262 100644 --- a/lib/ui/capture_draft.dart +++ b/lib/ui/capture_draft.dart @@ -38,6 +38,10 @@ class CaptureDraft { /// yet, so the first write still reports the note as created. 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. Priority priority = Priority.defaultValue; diff --git a/lib/ui/capture_screen.dart b/lib/ui/capture_screen.dart index da034b5..0416c34 100644 --- a/lib/ui/capture_screen.dart +++ b/lib/ui/capture_screen.dart @@ -1,9 +1,9 @@ import 'dart:async'; -import 'dart:typed_data'; import 'package:crdt_sync/crdt_sync.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:http/http.dart' as http; import 'package:todo/analytics/analytics_event.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/local_backup.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/capture_app_bar.dart'; import 'package:todo/ui/capture_draft.dart'; @@ -71,6 +72,9 @@ class _CaptureScreenState extends State final SyncStatusStore _syncStatus = SyncStatusStore(); + /// Bumped once per committed capture; drives the confirmation checkmark. + final ValueNotifier _captures = ValueNotifier(0); + /// Runs sync passes and records their outcome; see [CaptureSyncRunner]. late final CaptureSyncRunner _runner = CaptureSyncRunner( repository: widget.repository, @@ -130,6 +134,7 @@ class _CaptureScreenState extends State _localBackup.dispose(); _draft.dispose(); _syncStatus.dispose(); + _captures.dispose(); super.dispose(); } @@ -150,6 +155,7 @@ class _CaptureScreenState extends State onOpenSettings: _openSettings, onAttachFiles: _attachFiles, attachmentPicking: widget.attachmentPicking, + captured: _captures, ), body: _captureBody(advanced: advanced), ); diff --git a/lib/ui/capture_screen_sync.dart b/lib/ui/capture_screen_sync.dart index 34ada3f..bfe8c1e 100644 --- a/lib/ui/capture_screen_sync.dart +++ b/lib/ui/capture_screen_sync.dart @@ -66,8 +66,9 @@ extension _CaptureSyncBehaviour on _CaptureScreenState { final current = _settings ?? await SyncSettings.load(); if (!mounted) return; await Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => SettingsScreen( + appRoute( + context, + (_) => SettingsScreen( initial: current, repository: widget.repository, appSettings: widget.appSettings, @@ -87,8 +88,9 @@ extension _CaptureSyncBehaviour on _CaptureScreenState { void _openList() { _logEvent('screen_view', params: {'screen': 'notes_list'}); Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => NotesListScreen( + appRoute( + context, + (_) => NotesListScreen( repository: widget.repository, appSettings: widget.appSettings, ), @@ -206,10 +208,15 @@ extension _CaptureSyncBehaviour on _CaptureScreenState { } /// Finalises the current idea and resets the field to a fresh template. - /// The field going blank is the only feedback needed — autosave already - /// persisted every keystroke, so there is nothing left to confirm. + /// Autosave already persisted every keystroke, so the only thing left to + /// 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() { _logEvent('new_note_reset'); + if (_draft.hasCapture) { + unawaited(HapticFeedback.lightImpact()); + _captures.value++; + } _rebuild(() { // Bumping the generation remounts NoteForm, which resets the chrome // gate to visible for free -- no separate flag to keep in step. diff --git a/lib/ui/cloud_images_settings.dart b/lib/ui/cloud_images_settings.dart index f76bfc7..bb90281 100644 --- a/lib/ui/cloud_images_settings.dart +++ b/lib/ui/cloud_images_settings.dart @@ -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_scope.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. const kAddLoginCommand = @@ -37,9 +38,7 @@ class CloudImagesTile extends StatelessWidget { subtitle: const Text('Where attached images and files upload (dufs)'), trailing: const Icon(Icons.chevron_right), onTap: () => Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => CloudImagesScreen(httpClient: httpClient), - ), + appRoute(context, (_) => CloudImagesScreen(httpClient: httpClient)), ), ); } diff --git a/lib/ui/images/image_embed.dart b/lib/ui/images/image_embed.dart index 451c2b1..b377ec5 100644 --- a/lib/ui/images/image_embed.dart +++ b/lib/ui/images/image_embed.dart @@ -7,6 +7,7 @@ import 'package:flutter/material.dart'; import 'package:todo/attachments/image_store_api.dart'; import 'package:todo/attachments/image_store_scope.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/local_image.dart'; @@ -39,9 +40,7 @@ class ImageEmbed extends StatelessWidget { onTap: source is MissingImageSource ? null : () => Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => ImageViewerScreen(source: source), - ), + appRoute(context, (_) => ImageViewerScreen(source: source)), ), child: ClipRRect( borderRadius: BorderRadius.circular(8), diff --git a/lib/ui/motion.dart b/lib/ui/motion.dart new file mode 100644 index 0000000..1b0cb54 --- /dev/null +++ b/lib/ui/motion.dart @@ -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; diff --git a/lib/ui/note_editor_step_page.dart b/lib/ui/note_editor_step_page.dart index 24b9759..9e8497e 100644 --- a/lib/ui/note_editor_step_page.dart +++ b/lib/ui/note_editor_step_page.dart @@ -11,9 +11,11 @@ /// 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 { @@ -77,7 +79,13 @@ class NoteEditorStepPage extends StatelessWidget { Text('${index + 1} / $total', style: theme.textTheme.labelMedium), const SizedBox(width: 8), Expanded( - child: LinearProgressIndicator(value: (index + 1) / total), + child: TweenAnimationBuilder( + tween: Tween(end: (index + 1) / total), + duration: motionDuration(context, AppDuration.base), + curve: AppCurve.standard, + builder: (_, value, _) => + LinearProgressIndicator(value: value), + ), ), ], ), diff --git a/lib/ui/notes_list_navigation.dart b/lib/ui/notes_list_navigation.dart index 9f9a953..5b65f12 100644 --- a/lib/ui/notes_list_navigation.dart +++ b/lib/ui/notes_list_navigation.dart @@ -10,6 +10,7 @@ import 'package:flutter/material.dart'; import 'package:todo/data/app_settings.dart'; import 'package:todo/data/note.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_detail_screen.dart'; @@ -24,8 +25,9 @@ Future openNote( // re-focuses it on return, so backing out of a note would pop the keyboard. FocusManager.instance.primaryFocus?.unfocus(); await Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => NoteDetailScreen( + appRoute( + context, + (_) => NoteDetailScreen( note: note, repository: repository, appSettings: appSettings, diff --git a/lib/ui/settings_screen.dart b/lib/ui/settings_screen.dart index 11d0b7e..a7efec0 100644 --- a/lib/ui/settings_screen.dart +++ b/lib/ui/settings_screen.dart @@ -14,6 +14,7 @@ import 'package:todo/sync/firebase_backend.dart'; import 'package:todo/sync/google_sign_in_backend.dart'; import 'package:todo/sync/notes_markdown.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/github_mirror_screen.dart'; @@ -168,8 +169,9 @@ class _SettingsScreenState extends State { Future _openSyncSettings() async { await Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => SyncSettingsScreen( + appRoute( + context, + (_) => SyncSettingsScreen( accountLoader: widget.accountLoader ?? loadAccount, accountSaver: widget.accountSaver ?? saveAccount, accountClearer: widget.accountClearer ?? clearAccount, @@ -186,8 +188,9 @@ class _SettingsScreenState extends State { Future _openGitHubMirror() async { await Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => GitHubMirrorScreen( + appRoute( + context, + (_) => GitHubMirrorScreen( initial: widget.initial, repository: widget.repository, appSettings: widget.appSettings, diff --git a/prompts/TODO-dopamine-ux-todo.md b/prompts/TODO-dopamine-ux-todo.md deleted file mode 100644 index c839e90..0000000 --- a/prompts/TODO-dopamine-ux-todo.md +++ /dev/null @@ -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 | | | - 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` - 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 - `` 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 diff --git a/test/motion/capture_confirmation_test.dart b/test/motion/capture_confirmation_test.dart new file mode 100644 index 0000000..ce5ca37 --- /dev/null +++ b/test/motion/capture_confirmation_test.dart @@ -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(find.ancestor(of: _mark, matching: find.byType(Opacity))) + .opacity; + +Future _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)); + }); +} diff --git a/test/motion/motion_harness.dart b/test/motion/motion_harness.dart new file mode 100644 index 0000000..236daee --- /dev/null +++ b/test/motion/motion_harness.dart @@ -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 recordHaptics(WidgetTester tester) { + final calls = []; + 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); +} diff --git a/test/motion/motion_tokens_test.dart b/test/motion/motion_tokens_test.dart new file mode 100644 index 0000000..abfb46c --- /dev/null +++ b/test/motion/motion_tokens_test.dart @@ -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(find.byType(LinearProgressIndicator)) + .value!; + +Future _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(appRoute(ctx, (_) => const Scaffold(body: Text('page')))), + ); + await tester.pump(); +} + +void main() { + test('every page route goes through appRoute', () { + final offenders = []; + 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( + 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)); + }); +}