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.
|
/// 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,
|
||||||
|
|||||||
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.
|
/// 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;
|
||||||
|
|
||||||
|
|||||||
@ -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),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
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.
|
/// 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),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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