mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-11-10 19:07:02 +01:00
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:
parent
377ec17356
commit
2ffae26f93
50
lib/ui/app_route.dart
Normal file
50
lib/ui/app_route.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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<List<XFile>> onAttachFiles;
|
||||
|
||||
/// Counts committed captures; each change plays the confirmation mark.
|
||||
final ValueListenable<int> 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,
|
||||
|
||||
91
lib/ui/capture_confirm_mark.dart
Normal file
91
lib/ui/capture_confirm_mark.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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<CaptureScreen>
|
||||
|
||||
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].
|
||||
late final CaptureSyncRunner _runner = CaptureSyncRunner(
|
||||
repository: widget.repository,
|
||||
@ -130,6 +134,7 @@ class _CaptureScreenState extends State<CaptureScreen>
|
||||
_localBackup.dispose();
|
||||
_draft.dispose();
|
||||
_syncStatus.dispose();
|
||||
_captures.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@ -150,6 +155,7 @@ class _CaptureScreenState extends State<CaptureScreen>
|
||||
onOpenSettings: _openSettings,
|
||||
onAttachFiles: _attachFiles,
|
||||
attachmentPicking: widget.attachmentPicking,
|
||||
captured: _captures,
|
||||
),
|
||||
body: _captureBody(advanced: advanced),
|
||||
);
|
||||
|
||||
@ -66,8 +66,9 @@ extension _CaptureSyncBehaviour on _CaptureScreenState {
|
||||
final current = _settings ?? await SyncSettings.load();
|
||||
if (!mounted) return;
|
||||
await Navigator.of(context).push<SyncSettings>(
|
||||
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<void>(
|
||||
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.
|
||||
|
||||
@ -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<void>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => CloudImagesScreen(httpClient: httpClient),
|
||||
),
|
||||
appRoute(context, (_) => CloudImagesScreen(httpClient: httpClient)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -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<void>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ImageViewerScreen(source: source),
|
||||
),
|
||||
appRoute(context, (_) => ImageViewerScreen(source: source)),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
|
||||
10
lib/ui/motion.dart
Normal file
10
lib/ui/motion.dart
Normal 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;
|
||||
@ -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<double>(
|
||||
tween: Tween<double>(end: (index + 1) / total),
|
||||
duration: motionDuration(context, AppDuration.base),
|
||||
curve: AppCurve.standard,
|
||||
builder: (_, value, _) =>
|
||||
LinearProgressIndicator(value: value),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@ -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<void> 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<void>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => NoteDetailScreen(
|
||||
appRoute(
|
||||
context,
|
||||
(_) => NoteDetailScreen(
|
||||
note: note,
|
||||
repository: repository,
|
||||
appSettings: appSettings,
|
||||
|
||||
@ -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<SettingsScreen> {
|
||||
|
||||
Future<void> _openSyncSettings() async {
|
||||
await Navigator.of(context).push<void>(
|
||||
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<SettingsScreen> {
|
||||
|
||||
Future<void> _openGitHubMirror() async {
|
||||
await Navigator.of(context).push<SyncSettings>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => GitHubMirrorScreen(
|
||||
appRoute(
|
||||
context,
|
||||
(_) => GitHubMirrorScreen(
|
||||
initial: widget.initial,
|
||||
repository: widget.repository,
|
||||
appSettings: widget.appSettings,
|
||||
|
||||
@ -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
|
||||
138
test/motion/capture_confirmation_test.dart
Normal file
138
test/motion/capture_confirmation_test.dart
Normal 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));
|
||||
});
|
||||
}
|
||||
37
test/motion/motion_harness.dart
Normal file
37
test/motion/motion_harness.dart
Normal 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);
|
||||
}
|
||||
96
test/motion/motion_tokens_test.dart
Normal file
96
test/motion/motion_tokens_test.dart
Normal 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));
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user