From 5cdf0172d99b50ce8e06409696b58439b012fced Mon Sep 17 00:00:00 2001 From: Eoic Date: Sun, 6 Sep 2026 02:35:37 +0300 Subject: [PATCH 1/3] Make e-ink a responsive theme with instant controls and transitions --- app/docs/eink-theme.md | 38 +++ app/lib/forms/register_form.dart | 7 +- app/lib/main.dart | 6 + app/lib/pages/acquisition_page.dart | 16 +- app/lib/pages/annotations_page.dart | 48 ++- app/lib/pages/auth/oauth_callback_page.dart | 3 +- app/lib/pages/book_details_page.dart | 90 ++++-- app/lib/pages/book_edit_page.dart | 30 +- app/lib/pages/bookmarks_page.dart | 41 ++- app/lib/pages/dashboard_page.dart | 3 +- app/lib/pages/developer_options_page.dart | 72 +---- app/lib/pages/edit_profile_page.dart | 11 +- app/lib/pages/forgot_password_page.dart | 3 + app/lib/pages/goals_page.dart | 3 +- app/lib/pages/library_page.dart | 11 +- app/lib/pages/login_page.dart | 2 + app/lib/pages/notes_page.dart | 40 ++- app/lib/pages/profile_page.dart | 58 +++- app/lib/pages/reader_page.dart | 3 +- app/lib/pages/register_page.dart | 2 + app/lib/pages/shelves_page.dart | 17 +- app/lib/pages/statistics_page.dart | 8 +- app/lib/themes/app_motion.dart | 61 ++++ app/lib/themes/app_theme.dart | 133 ++++++-- app/lib/themes/color_schemes.g.dart | 8 +- app/lib/utils/book_actions.dart | 28 +- app/lib/utils/bulk_book_actions.dart | 9 +- .../acquisition_action_sheets.dart | 7 +- .../acquisition_endpoint_editor.dart | 21 +- .../guarded_bottom_sheet_route.dart | 3 + .../add_book/add_book_choice_sheet.dart | 2 + .../add_book/add_physical_book_sheet.dart | 10 +- .../add_book/book_import_drop_zone.dart | 7 +- .../add_book/book_import_item_card.dart | 18 +- .../add_book/book_import_results_sheet.dart | 24 +- .../widgets/add_book/book_import_sheet.dart | 23 +- .../add_book/digital_book_import_sheet.dart | 5 +- .../annotations/annotation_action_sheet.dart | 3 + .../widgets/auth/auth_continue_button.dart | 6 +- app/lib/widgets/book/book.dart | 20 +- app/lib/widgets/book/book_annotations.dart | 2 + app/lib/widgets/book/book_bookmarks.dart | 2 + app/lib/widgets/book/book_details.dart | 75 +++-- app/lib/widgets/book/book_notes.dart | 2 + app/lib/widgets/book/private_book_cover.dart | 3 + .../book_details/annotation_action_sheet.dart | 2 + .../widgets/book_details/annotation_card.dart | 4 +- .../book_details/annotation_dialog.dart | 2 + .../book_details/book_action_buttons.dart | 3 +- app/lib/widgets/book_details/book_header.dart | 16 +- .../book_details/book_progress_bar.dart | 3 +- .../widgets/book_details/bookmark_dialog.dart | 2 + .../book_details/note_action_sheet.dart | 3 + app/lib/widgets/book_details/note_card.dart | 22 +- app/lib/widgets/book_details/note_dialog.dart | 18 +- .../book_details/update_progress_sheet.dart | 2 + .../widgets/book_edit/cover_image_picker.dart | 5 +- .../widgets/book_form/book_date_field.dart | 3 +- .../widgets/book_form/co_author_editor.dart | 33 +- .../bookmarks/bookmark_action_sheet.dart | 5 + .../widgets/bookmarks/bookmark_list_item.dart | 4 +- app/lib/widgets/buttons/google_sign_in.dart | 9 +- .../context_menu/book_context_menu.dart | 3 + .../widgets/dashboard/reading_goal_card.dart | 3 +- app/lib/widgets/filter/active_filter_bar.dart | 24 +- .../goals/active_goal_details_sheet.dart | 6 +- app/lib/widgets/goals/add_goal_sheet.dart | 25 +- .../widgets/goals/completed_goal_chip.dart | 3 + app/lib/widgets/goals/goal_card.dart | 3 +- .../acquisition_confirmation_dialog.dart | 2 + .../library/acquisition_job_sheets.dart | 9 +- .../library/acquisition_placeholder_card.dart | 7 +- .../acquisition_placeholder_list_item.dart | 10 +- app/lib/widgets/library/book_card.dart | 9 +- app/lib/widgets/library/book_list_item.dart | 19 +- .../widgets/library/bulk_status_sheet.dart | 2 + .../library_advanced_filter_sheet.dart | 63 ++-- .../widgets/library/library_filter_chips.dart | 72 +++-- .../widgets/library/online_results_view.dart | 3 +- .../widgets/library/remote_release_list.dart | 10 +- app/lib/widgets/settings/settings_row.dart | 7 +- app/lib/widgets/shared/app_date_picker.dart | 69 ++++ app/lib/widgets/shared/app_drawer.dart | 19 ++ .../widgets/shared/app_motion_control.dart | 68 ++++ .../shared/app_progress_indicator.dart | 94 ++++++ app/lib/widgets/shared/persistent_save.dart | 8 +- .../widgets/shared/static_date_picker.dart | 299 ++++++++++++++++++ app/lib/widgets/shell/adaptive_app_shell.dart | 24 +- app/lib/widgets/shell/desktop_sidebar.dart | 29 +- app/lib/widgets/shell/eink_bottom_nav.dart | 72 ----- app/lib/widgets/shell/mobile_bottom_nav.dart | 2 + app/lib/widgets/shelves/add_shelf_sheet.dart | 2 + .../widgets/shelves/move_to_shelf_sheet.dart | 14 +- app/lib/widgets/shelves/shelf_card.dart | 7 +- .../widgets/shelves/shelves_filter_chips.dart | 59 ++-- .../widgets/statistics/reading_charts.dart | 16 +- app/lib/widgets/topics/add_topic_sheet.dart | 2 + .../widgets/topics/manage_topics_sheet.dart | 14 +- .../widgets/topics/topic_detail_sheet.dart | 10 +- app/test/themes/eink_theme_test.dart | 109 +++++++ app/test/widgets/app_motion_control_test.dart | 266 ++++++++++++++++ .../widgets/app_motion_integration_test.dart | 194 ++++++++++++ app/test/widgets/shell/eink_layout_test.dart | 65 ++++ app/test/widgets/static_date_picker_test.dart | 162 ++++++++++ 104 files changed, 2448 insertions(+), 556 deletions(-) create mode 100644 app/docs/eink-theme.md create mode 100644 app/lib/themes/app_motion.dart create mode 100644 app/lib/widgets/shared/app_date_picker.dart create mode 100644 app/lib/widgets/shared/app_drawer.dart create mode 100644 app/lib/widgets/shared/app_motion_control.dart create mode 100644 app/lib/widgets/shared/app_progress_indicator.dart create mode 100644 app/lib/widgets/shared/static_date_picker.dart delete mode 100644 app/lib/widgets/shell/eink_bottom_nav.dart create mode 100644 app/test/themes/eink_theme_test.dart create mode 100644 app/test/widgets/app_motion_control_test.dart create mode 100644 app/test/widgets/app_motion_integration_test.dart create mode 100644 app/test/widgets/shell/eink_layout_test.dart create mode 100644 app/test/widgets/static_date_picker_test.dart diff --git a/app/docs/eink-theme.md b/app/docs/eink-theme.md new file mode 100644 index 0000000..17e3ef7 --- /dev/null +++ b/app/docs/eink-theme.md @@ -0,0 +1,38 @@ +# E-ink theme + +E-ink is a theme choice alongside light and dark. Screen width selects the app +shell and page layout; selecting e-ink keeps the current route and responsive +breakpoints. Typography sizes, control dimensions, card margins, and spacing +match the regular themes. Black/white selection states, stronger borders, and +opaque surfaces keep controls legible without shadows or color alone. + +`AppMotion` carries the motion policy independently of layout. E-ink enables it +through its theme extension; the existing reduced-animation preference and the +system reduced-motion setting are also respected by app-controlled transitions. + +When adding UI: + +- Use `AppMotion.duration` and `AppMotion.animationStyle` for animations and + overlays. Stateful animations must also finish or reset when the policy changes. +- Use the shared progress indicators. Indeterminate indicators retain their + loading semantics and a visible static phase; determinate values still update. +- Use `AppMotionControl` for framework controls with private animation + controllers. Its local ticker scope and retained focus let controls update + immediately without freezing an entire page. +- Use the shared date-picker and drawer entry points. Flutter's stock calendar + has internal transitions independent of its route, so reduced-motion calendar + controls change months and years directly. Cancel never saves a selection. +- Keep dimensions and navigation decisions independent of the theme. + +Light and dark keep their normal animation timings. User-driven scrolling and +actual data/progress updates remain available in e-ink. + +Regression coverage lives in `test/widgets/shell/eink_layout_test.dart`, +`test/themes/eink_theme_test.dart`, `test/widgets/app_motion_integration_test.dart`, +`test/widgets/app_motion_control_test.dart`, and +`test/widgets/static_date_picker_test.dart`. + +Validated on `feature/eink-theme`, based on this repository's default `master` +branch: `flutter test --no-pub` passed 1,140 tests (10 skipped), +`flutter analyze --no-pub` reported no issues, and +`flutter build web --release --no-pub` succeeded. diff --git a/app/lib/forms/register_form.dart b/app/lib/forms/register_form.dart index 379cf72..8a8f4a1 100644 --- a/app/lib/forms/register_form.dart +++ b/app/lib/forms/register_form.dart @@ -7,6 +7,8 @@ import 'package:papyrus/widgets/input/name_input.dart'; import 'package:papyrus/widgets/input/password_input.dart'; import 'package:papyrus/widgets/titled_divider.dart'; import 'package:provider/provider.dart'; +import 'package:papyrus/themes/app_motion.dart'; +import 'package:papyrus/widgets/shared/app_progress_indicator.dart'; class RegisterForm extends StatefulWidget { const RegisterForm({super.key}); @@ -39,10 +41,11 @@ class _RegisterForm extends State { setState(() => isRegisterDisabled = true); showDialog( + animationStyle: AppMotion.animationStyle(context), context: context, barrierDismissible: false, builder: (context) => - const Center(child: SizedBox(width: 150, height: 150, child: CircularProgressIndicator(strokeWidth: 8))), + const Center(child: SizedBox(width: 150, height: 150, child: AppCircularProgressIndicator(strokeWidth: 8))), ); try { @@ -57,6 +60,7 @@ class _RegisterForm extends State { } ScaffoldMessenger.of(context).showSnackBar( + snackBarAnimationStyle: AppMotion.animationStyle(context), SnackBar( duration: const Duration(seconds: 5), content: Text(context.read().error ?? "Account creation failed."), @@ -68,6 +72,7 @@ class _RegisterForm extends State { setState(() => isRegisterDisabled = false); Navigator.of(context).pop(); ScaffoldMessenger.of(context).showSnackBar( + snackBarAnimationStyle: AppMotion.animationStyle(context), SnackBar( duration: const Duration(seconds: 5), content: const Text("Account creation failed."), diff --git a/app/lib/main.dart b/app/lib/main.dart index 0e35035..a7169e3 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:papyrus/themes/app_motion.dart'; import 'package:flutter_web_plugins/url_strategy.dart'; import 'package:papyrus/acquisition/acquisition_api_client.dart'; import 'package:papyrus/auth/auth_api_client.dart'; @@ -423,6 +424,11 @@ class _PapyrusState extends State { theme: isEink ? AppTheme.eink : AppTheme.light, darkTheme: isEink ? AppTheme.eink : AppTheme.dark, themeMode: isEink ? ThemeMode.light : preferencesProvider.themeMode, + themeAnimationDuration: isEink || preferencesProvider.reduceAnimations + ? Duration.zero + : kThemeAnimationDuration, + builder: (context, child) => + AppMotionScope(reduceAnimations: isEink || preferencesProvider.reduceAnimations, child: child!), routerConfig: _appRouter.router, ); }, diff --git a/app/lib/pages/acquisition_page.dart b/app/lib/pages/acquisition_page.dart index 9f48bde..bead171 100644 --- a/app/lib/pages/acquisition_page.dart +++ b/app/lib/pages/acquisition_page.dart @@ -14,6 +14,8 @@ import 'package:papyrus/widgets/acquisition/acquisition_endpoint_editor.dart'; import 'package:papyrus/widgets/acquisition/acquisition_settings_section.dart'; import 'package:papyrus/widgets/settings/settings_row.dart'; import 'package:provider/provider.dart'; +import 'package:papyrus/themes/app_motion.dart'; +import 'package:papyrus/widgets/shared/app_progress_indicator.dart'; typedef AcquisitionApiClientFactory = AcquisitionApiClient Function(PapyrusApiConfig config); @@ -220,7 +222,10 @@ class _AcquisitionPageState extends State { await downloadsProvider?.refreshConfiguration(); } catch (_) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Could not remove this integration.'))); + ScaffoldMessenger.of(context).showSnackBar( + snackBarAnimationStyle: AppMotion.animationStyle(context), + const SnackBar(content: Text('Could not remove this integration.')), + ); } } } @@ -243,8 +248,8 @@ class _AcquisitionPageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - if (_loading) const LinearProgressIndicator(), - if (_submittingKeys.isNotEmpty) const LinearProgressIndicator(), + if (_loading) const AppLinearProgressIndicator(), + if (_submittingKeys.isNotEmpty) const AppLinearProgressIndicator(), if (_error != null) _ErrorBanner(message: _error!, onRetry: _load), if (!_loading && _error == null && capabilities != null) ..._buildSettingsSections(capabilities: capabilities, indexers: indexers), @@ -325,6 +330,7 @@ class _AcquisitionPageState extends State { final runEnabled = endpoint.enabled && !_submittingKeys.contains('arr:${endpoint.id}'); return PopupMenuButton( + popUpAnimationStyle: AppMotion.animationStyle(context), tooltip: 'Actions for ${endpoint.name}', onSelected: (value) { if (value == 'edit') _showEndpointSheet(endpoint: endpoint); @@ -347,7 +353,9 @@ class _AcquisitionPageState extends State { } void _showMessage(String message) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); + ScaffoldMessenger.of( + context, + ).showSnackBar(snackBarAnimationStyle: AppMotion.animationStyle(context), SnackBar(content: Text(message))); } String _arrCommandLabel(String command) => switch (command) { diff --git a/app/lib/pages/annotations_page.dart b/app/lib/pages/annotations_page.dart index b955eb4..7f6e5d2 100644 --- a/app/lib/pages/annotations_page.dart +++ b/app/lib/pages/annotations_page.dart @@ -1,3 +1,4 @@ +import 'package:papyrus/widgets/shared/app_drawer.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:papyrus/data/data_store.dart'; @@ -13,6 +14,8 @@ import 'package:papyrus/widgets/book_details/annotation_dialog.dart'; import 'package:papyrus/widgets/library/library_drawer.dart'; import 'package:papyrus/widgets/shared/empty_state.dart'; import 'package:provider/provider.dart'; +import 'package:papyrus/themes/app_motion.dart'; +import 'package:papyrus/widgets/shared/app_motion_control.dart'; /// Annotations page showing all annotations across all books. /// @@ -75,6 +78,7 @@ class _AnnotationsPageState extends State { Widget _buildMobileLayout(BuildContext context, AnnotationsProvider provider) { return Scaffold( key: _scaffoldKey, + drawerEnableOpenDragGesture: !AppMotion.disabled(context), drawer: const LibraryDrawer(currentPath: '/library/annotations'), body: SafeArea( child: Column( @@ -87,7 +91,7 @@ class _AnnotationsPageState extends State { IconButton( icon: const Icon(Icons.menu), onPressed: () { - _scaffoldKey.currentState?.openDrawer(); + openAppDrawer(context, _scaffoldKey.currentState); }, tooltip: 'Library sections', ), @@ -174,6 +178,7 @@ class _AnnotationsPageState extends State { Widget _buildSortButton(AnnotationsProvider provider) { return PopupMenuButton( + popUpAnimationStyle: AppMotion.animationStyle(context), icon: const Icon(Icons.sort), tooltip: 'Sort annotations', onSelected: provider.setSortOption, @@ -211,10 +216,15 @@ class _AnnotationsPageState extends State { children: [ // Clear chip (shown when filters are active) if (provider.activeColors.isNotEmpty) ...[ - ActionChip( - label: const Text('Clear'), - onPressed: provider.clearColorFilters, - avatar: const Icon(Icons.clear, size: 16), + AppMotionControl( + value: null, + builder: (focusNode) => ActionChip( + focusNode: focusNode, + chipAnimationStyle: appChipAnimationStyle(context), + label: const Text('Clear'), + onPressed: provider.clearColorFilters, + avatar: const Icon(Icons.clear, size: 16), + ), ), const SizedBox(width: Spacing.sm), ], @@ -224,15 +234,20 @@ class _AnnotationsPageState extends State { return Padding( padding: const EdgeInsets.only(right: Spacing.sm), - child: FilterChip( - selected: isSelected, - label: Text(highlightColor.displayName), - avatar: Container( - width: 12, - height: 12, - decoration: BoxDecoration(color: highlightColor.accentColor, shape: BoxShape.circle), + child: AppMotionControl( + value: null, + builder: (focusNode) => FilterChip( + focusNode: focusNode, + chipAnimationStyle: appChipAnimationStyle(context), + selected: isSelected, + label: Text(highlightColor.displayName), + avatar: Container( + width: 12, + height: 12, + decoration: BoxDecoration(color: highlightColor.accentColor, shape: BoxShape.circle), + ), + onSelected: (_) => provider.toggleColorFilter(highlightColor), ), - onSelected: (_) => provider.toggleColorFilter(highlightColor), ), ); }), @@ -361,9 +376,10 @@ class _AnnotationsPageState extends State { await provider.deleteAnnotation(annotation.id, repository: repository); } catch (_) { if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('Could not delete. Please try again.'))); + ScaffoldMessenger.of(context).showSnackBar( + snackBarAnimationStyle: AppMotion.animationStyle(context), + const SnackBar(content: Text('Could not delete. Please try again.')), + ); } return; } diff --git a/app/lib/pages/auth/oauth_callback_page.dart b/app/lib/pages/auth/oauth_callback_page.dart index 6af38f5..076f5ed 100644 --- a/app/lib/pages/auth/oauth_callback_page.dart +++ b/app/lib/pages/auth/oauth_callback_page.dart @@ -3,6 +3,7 @@ import 'package:go_router/go_router.dart'; import 'package:papyrus/providers/auth_provider.dart'; import 'package:papyrus/themes/design_tokens.dart'; import 'package:provider/provider.dart'; +import 'package:papyrus/widgets/shared/app_progress_indicator.dart'; class OAuthCallbackPage extends StatefulWidget { final Uri callbackUri; @@ -54,7 +55,7 @@ class _OAuthCallbackPageState extends State { mainAxisSize: MainAxisSize.min, children: [ if (_errorMessage == null) ...[ - const CircularProgressIndicator(), + const AppCircularProgressIndicator(), const SizedBox(height: Spacing.lg), Text('Completing sign-in...', style: theme.textTheme.titleMedium, textAlign: TextAlign.center), ] else ...[ diff --git a/app/lib/pages/book_details_page.dart b/app/lib/pages/book_details_page.dart index 7900afb..69dedcd 100644 --- a/app/lib/pages/book_details_page.dart +++ b/app/lib/pages/book_details_page.dart @@ -35,6 +35,8 @@ import 'package:papyrus/widgets/book_details/update_progress_sheet.dart'; import 'package:papyrus/widgets/annotations/annotation_action_sheet.dart' as annotation_sheets; import 'package:papyrus/widgets/bookmarks/bookmark_action_sheet.dart'; import 'package:provider/provider.dart'; +import 'package:papyrus/themes/app_motion.dart'; +import 'package:papyrus/widgets/shared/app_progress_indicator.dart'; /// Book details page with responsive layouts for desktop and mobile. class BookDetailsPage extends StatefulWidget { @@ -46,7 +48,7 @@ class BookDetailsPage extends StatefulWidget { State createState() => _BookDetailsPageState(); } -class _BookDetailsPageState extends State with SingleTickerProviderStateMixin { +class _BookDetailsPageState extends State with TickerProviderStateMixin { late BookDetailsProvider _provider; late TabController _tabController; bool _isPreparingReader = false; @@ -63,6 +65,14 @@ class _BookDetailsPageState extends State with SingleTickerProv @override void didChangeDependencies() { super.didChangeDependencies(); + final duration = AppMotion.duration(context, kTabScrollDuration); + if (_tabController.animationDuration != duration) { + final index = _tabController.index; + _tabController.removeListener(_onTabChanged); + _tabController.dispose(); + _tabController = TabController(length: 4, vsync: this, initialIndex: index, animationDuration: duration); + _tabController.addListener(_onTabChanged); + } // Connect to DataStore for persistent storage final dataStore = context.read(); _provider.setDataStore(dataStore); @@ -122,7 +132,7 @@ class _BookDetailsPageState extends State with SingleTickerProv Widget _buildLoadingState(BuildContext context) { return Scaffold( appBar: AppBar(leading: const BackButton(), title: const Text('Loading...')), - body: const Center(child: CircularProgressIndicator()), + body: const Center(child: AppCircularProgressIndicator()), ); } @@ -246,6 +256,7 @@ class _BookDetailsPageState extends State with SingleTickerProv title: Text(provider.book!.title, overflow: TextOverflow.ellipsis), actions: [ PopupMenuButton( + popUpAnimationStyle: AppMotion.animationStyle(context), icon: const Icon(Icons.more_vert), onSelected: _onMenuAction, itemBuilder: (context) => [ @@ -377,7 +388,10 @@ class _BookDetailsPageState extends State with SingleTickerProv onSave: (page, position) { _provider.updatePageProgress(page, position); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Progress updated'))); + ScaffoldMessenger.of(context).showSnackBar( + snackBarAnimationStyle: AppMotion.animationStyle(context), + const SnackBar(content: Text('Progress updated')), + ); } }, ); @@ -389,7 +403,10 @@ class _BookDetailsPageState extends State with SingleTickerProv final messenger = ScaffoldMessenger.of(context); if (ReaderBookAdapter.formatFor(book.fileFormat) == null) { - messenger.showSnackBar(const SnackBar(content: Text('This book format is not supported yet.'))); + messenger.showSnackBar( + snackBarAnimationStyle: AppMotion.animationStyle(context), + const SnackBar(content: Text('This book format is not supported yet.')), + ); return; } @@ -466,7 +483,10 @@ class _BookDetailsPageState extends State with SingleTickerProv final mediaCacheService = context.read(); final downloadService = context.read(); - messenger.showSnackBar(const SnackBar(content: Text('Preparing download...'))); + messenger.showSnackBar( + snackBarAnimationStyle: AppMotion.animationStyle(context), + const SnackBar(content: Text('Preparing download...')), + ); try { final cached = await mediaCacheService.getValidCachedBookFile(book, readLocalBookFile: importService.getBookFile); @@ -478,14 +498,23 @@ class _BookDetailsPageState extends State with SingleTickerProv if (!mounted) return; messenger.hideCurrentSnackBar(); if (result.saved) { - messenger.showSnackBar(SnackBar(content: Text('Downloaded "${book.title}"'))); + messenger.showSnackBar( + snackBarAnimationStyle: AppMotion.animationStyle(context), + SnackBar(content: Text('Downloaded "${book.title}"')), + ); } else { - messenger.showSnackBar(const SnackBar(content: Text('Download canceled'))); + messenger.showSnackBar( + snackBarAnimationStyle: AppMotion.animationStyle(context), + const SnackBar(content: Text('Download canceled')), + ); } } catch (_) { if (!mounted) return; messenger.hideCurrentSnackBar(); - messenger.showSnackBar(const SnackBar(content: Text('Could not download this book file.'))); + messenger.showSnackBar( + snackBarAnimationStyle: AppMotion.animationStyle(context), + const SnackBar(content: Text('Could not download this book file.')), + ); } } @@ -514,7 +543,10 @@ class _BookDetailsPageState extends State with SingleTickerProv ); if (note != null && mounted) { - ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Note added'))); + ScaffoldMessenger.of(context).showSnackBar( + snackBarAnimationStyle: AppMotion.animationStyle(context), + const SnackBar(content: Text('Note added')), + ); } } @@ -530,7 +562,10 @@ class _BookDetailsPageState extends State with SingleTickerProv ); if (bookmark != null && mounted) { - ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Bookmark added'))); + ScaffoldMessenger.of(context).showSnackBar( + snackBarAnimationStyle: AppMotion.animationStyle(context), + const SnackBar(content: Text('Bookmark added')), + ); } } @@ -545,7 +580,10 @@ class _BookDetailsPageState extends State with SingleTickerProv ); if (annotation != null && mounted) { - ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Annotation added'))); + ScaffoldMessenger.of(context).showSnackBar( + snackBarAnimationStyle: AppMotion.animationStyle(context), + const SnackBar(content: Text('Annotation added')), + ); } } @@ -574,7 +612,10 @@ class _BookDetailsPageState extends State with SingleTickerProv ); if (updatedNote != null && mounted) { - ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Note updated'))); + ScaffoldMessenger.of(context).showSnackBar( + snackBarAnimationStyle: AppMotion.animationStyle(context), + const SnackBar(content: Text('Note updated')), + ); } } @@ -587,14 +628,18 @@ class _BookDetailsPageState extends State with SingleTickerProv await _provider.deleteNote(note.id, repository: repository); } catch (_) { if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('Could not delete. Please try again.'))); + ScaffoldMessenger.of(context).showSnackBar( + snackBarAnimationStyle: AppMotion.animationStyle(context), + const SnackBar(content: Text('Could not delete. Please try again.')), + ); } return; } if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Note deleted'))); + ScaffoldMessenger.of(context).showSnackBar( + snackBarAnimationStyle: AppMotion.animationStyle(context), + const SnackBar(content: Text('Note deleted')), + ); } } @@ -634,9 +679,10 @@ class _BookDetailsPageState extends State with SingleTickerProv await _provider.deleteAnnotation(annotation.id, repository: repository); } catch (_) { if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('Could not delete. Please try again.'))); + ScaffoldMessenger.of(context).showSnackBar( + snackBarAnimationStyle: AppMotion.animationStyle(context), + const SnackBar(content: Text('Could not delete. Please try again.')), + ); } return; } @@ -694,6 +740,7 @@ class _BookDetailsPageState extends State with SingleTickerProv if (book == null) return; final confirmed = await showDialog( + animationStyle: AppMotion.animationStyle(context), context: context, builder: (context) => AlertDialog( title: const Text('Delete book?'), @@ -735,7 +782,10 @@ class _BookDetailsPageState extends State with SingleTickerProv if (!mounted) return; context.go('/library/books'); - messenger.showSnackBar(SnackBar(content: Text('Deleted "${book.title}"'))); + messenger.showSnackBar( + snackBarAnimationStyle: AppMotion.animationStyle(context), + SnackBar(content: Text('Deleted "${book.title}"')), + ); } void _onMenuAction(String action) async { diff --git a/app/lib/pages/book_edit_page.dart b/app/lib/pages/book_edit_page.dart index 5892344..b3fc114 100644 --- a/app/lib/pages/book_edit_page.dart +++ b/app/lib/pages/book_edit_page.dart @@ -21,6 +21,9 @@ import 'package:papyrus/widgets/book_form/book_text_field.dart'; import 'package:papyrus/widgets/book_form/co_author_editor.dart'; import 'package:papyrus/widgets/book_form/responsive_form_row.dart'; import 'package:provider/provider.dart'; +import 'package:papyrus/themes/app_motion.dart'; +import 'package:papyrus/widgets/shared/app_progress_indicator.dart'; +import 'package:papyrus/widgets/shared/app_motion_control.dart'; /// Page for editing book metadata. class BookEditPage extends StatefulWidget { @@ -141,7 +144,7 @@ class _BookEditPageState extends State { if (provider.isLoading) { return Scaffold( appBar: AppBar(title: const Text('Edit book')), - body: const Center(child: CircularProgressIndicator()), + body: const Center(child: AppCircularProgressIndicator()), ); } @@ -524,11 +527,15 @@ class _BookEditPageState extends State { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - SwitchListTile( - title: const Text('Physical book'), + AppMotionControl( value: isPhysical, - onChanged: (value) => _provider.updateIsPhysical(value), - contentPadding: EdgeInsets.zero, + builder: (focusNode) => SwitchListTile( + focusNode: focusNode, + title: const Text('Physical book'), + value: isPhysical, + onChanged: (value) => _provider.updateIsPhysical(value), + contentPadding: EdgeInsets.zero, + ), ), if (isPhysical) ...[ const SizedBox(height: Spacing.sm), @@ -599,7 +606,7 @@ class _BookEditPageState extends State { prefixIcon: const Icon(Icons.search), suffixIcon: IconButton( icon: provider.isFetching - ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) + ? const SizedBox(width: 20, height: 20, child: AppCircularProgressIndicator(strokeWidth: 2)) : const Icon(Icons.arrow_forward), onPressed: provider.isFetching ? null : () => _searchMetadata(provider), ), @@ -779,12 +786,14 @@ class _BookEditPageState extends State { } ScaffoldMessenger.of(context).showSnackBar( + snackBarAnimationStyle: AppMotion.animationStyle(context), SnackBar(content: Text('Applied metadata from ${result.sourceLabel}'), behavior: SnackBarBehavior.floating), ); } Future _showDiscardDialog() async { final result = await showDialog( + animationStyle: AppMotion.animationStyle(context), context: context, builder: (ctx) => AlertDialog( title: const Text('Discard changes?'), @@ -819,6 +828,7 @@ class _BookEditPageState extends State { Future _handleSave(BuildContext context) async { if (!_formKey.currentState!.validate()) { ScaffoldMessenger.of(context).showSnackBar( + snackBarAnimationStyle: AppMotion.animationStyle(context), const SnackBar(content: Text('Please fix the errors before saving'), behavior: SnackBarBehavior.floating), ); return; @@ -882,14 +892,16 @@ class _BookEditPageState extends State { if (!mounted || !context.mounted) return; - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('Book updated'), behavior: SnackBarBehavior.floating)); + ScaffoldMessenger.of(context).showSnackBar( + snackBarAnimationStyle: AppMotion.animationStyle(context), + const SnackBar(content: Text('Book updated'), behavior: SnackBarBehavior.floating), + ); _navigateToBookDetails(context); } void _showSaveError(BuildContext context, String message) { ScaffoldMessenger.of(context).showSnackBar( + snackBarAnimationStyle: AppMotion.animationStyle(context), SnackBar( content: Text(message), behavior: SnackBarBehavior.floating, diff --git a/app/lib/pages/bookmarks_page.dart b/app/lib/pages/bookmarks_page.dart index 6c36a63..94447cf 100644 --- a/app/lib/pages/bookmarks_page.dart +++ b/app/lib/pages/bookmarks_page.dart @@ -1,3 +1,4 @@ +import 'package:papyrus/widgets/shared/app_drawer.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:papyrus/data/data_store.dart'; @@ -11,6 +12,8 @@ import 'package:papyrus/widgets/bookmarks/bookmark_list_item.dart'; import 'package:papyrus/widgets/library/library_drawer.dart'; import 'package:papyrus/widgets/shared/empty_state.dart'; import 'package:provider/provider.dart'; +import 'package:papyrus/themes/app_motion.dart'; +import 'package:papyrus/widgets/shared/app_motion_control.dart'; /// Color name mapping for filter chip labels. const _colorNames = { @@ -84,6 +87,7 @@ class _BookmarksPageState extends State { Widget _buildMobileLayout(BuildContext context, BookmarksProvider provider) { return Scaffold( key: _scaffoldKey, + drawerEnableOpenDragGesture: !AppMotion.disabled(context), drawer: const LibraryDrawer(currentPath: '/library/bookmarks'), body: SafeArea( child: Column( @@ -96,7 +100,7 @@ class _BookmarksPageState extends State { IconButton( icon: const Icon(Icons.menu), onPressed: () { - _scaffoldKey.currentState?.openDrawer(); + openAppDrawer(context, _scaffoldKey.currentState); }, tooltip: 'Library sections', ), @@ -183,6 +187,7 @@ class _BookmarksPageState extends State { Widget _buildSortButton(BookmarksProvider provider) { return PopupMenuButton( + popUpAnimationStyle: AppMotion.animationStyle(context), icon: const Icon(Icons.sort), tooltip: 'Sort bookmarks', onSelected: provider.setSortOption, @@ -220,10 +225,15 @@ class _BookmarksPageState extends State { children: [ // Clear chip (shown when filters are active) if (provider.activeColors.isNotEmpty) ...[ - ActionChip( - label: const Text('Clear'), - onPressed: provider.clearColorFilters, - avatar: const Icon(Icons.clear, size: 16), + AppMotionControl( + value: null, + builder: (focusNode) => ActionChip( + focusNode: focusNode, + chipAnimationStyle: appChipAnimationStyle(context), + label: const Text('Clear'), + onPressed: provider.clearColorFilters, + avatar: const Icon(Icons.clear, size: 16), + ), ), const SizedBox(width: Spacing.sm), ], @@ -235,15 +245,20 @@ class _BookmarksPageState extends State { return Padding( padding: const EdgeInsets.only(right: Spacing.sm), - child: FilterChip( - selected: isSelected, - label: Text(name), - avatar: Container( - width: 12, - height: 12, - decoration: BoxDecoration(color: color, shape: BoxShape.circle), + child: AppMotionControl( + value: null, + builder: (focusNode) => FilterChip( + focusNode: focusNode, + chipAnimationStyle: appChipAnimationStyle(context), + selected: isSelected, + label: Text(name), + avatar: Container( + width: 12, + height: 12, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ), + onSelected: (_) => provider.toggleColorFilter(hex), ), - onSelected: (_) => provider.toggleColorFilter(hex), ), ); }), diff --git a/app/lib/pages/dashboard_page.dart b/app/lib/pages/dashboard_page.dart index 93b88ad..5563886 100644 --- a/app/lib/pages/dashboard_page.dart +++ b/app/lib/pages/dashboard_page.dart @@ -9,6 +9,7 @@ import 'package:papyrus/widgets/dashboard/reading_goal_card.dart'; import 'package:papyrus/widgets/dashboard/recently_added_section.dart'; import 'package:papyrus/widgets/dashboard/weekly_activity_chart.dart'; import 'package:provider/provider.dart'; +import 'package:papyrus/widgets/shared/app_progress_indicator.dart'; /// Dashboard page displaying reading activity, current books, and quick actions. /// @@ -54,7 +55,7 @@ class _DashboardPageState extends State { final isDesktop = screenWidth >= Breakpoints.desktopSmall; if (provider.isLoading) { - return const Scaffold(body: Center(child: CircularProgressIndicator())); + return const Scaffold(body: Center(child: AppCircularProgressIndicator())); } if (isDesktop) { diff --git a/app/lib/pages/developer_options_page.dart b/app/lib/pages/developer_options_page.dart index b94a4e5..65925f2 100644 --- a/app/lib/pages/developer_options_page.dart +++ b/app/lib/pages/developer_options_page.dart @@ -1,73 +1,15 @@ import 'package:flutter/material.dart'; -import 'package:papyrus/providers/preferences_provider.dart'; import 'package:papyrus/themes/design_tokens.dart'; -import 'package:provider/provider.dart'; -/// Developer options page with debug-only settings. -/// -/// This page is only accessible in debug mode and contains settings -/// useful for development and testing. +/// Developer options use the current theme and the same layout on every display. class DeveloperOptionsPage extends StatelessWidget { const DeveloperOptionsPage({super.key}); @override - Widget build(BuildContext context) { - final prefs = context.watch(); - - if (prefs.isEinkMode) return _buildEinkLayout(context); - return _buildStandardLayout(context); - } - - Widget _buildStandardLayout(BuildContext context) { - context.watch(); - - return Scaffold( - appBar: AppBar(title: const Text('Developer options')), - body: SafeArea( - child: ListView(padding: const EdgeInsets.all(Spacing.md), children: []), - ), - ); - } - - Widget _buildEinkLayout(BuildContext context) { - context.watch(); - - return Scaffold( - body: Column( - children: [ - _buildEinkHeader(context), - const Divider(color: Colors.black, height: 1), - Expanded( - child: ListView(padding: const EdgeInsets.all(Spacing.pageMarginsEink), children: []), - ), - ], - ), - ); - } - - Widget _buildEinkHeader(BuildContext context) { - return Container( - height: ComponentSizes.einkHeaderHeight, - padding: const EdgeInsets.symmetric(horizontal: Spacing.pageMarginsEink), - child: Row( - children: [ - GestureDetector( - onTap: () => Navigator.of(context).pop(), - child: const SizedBox( - width: TouchTargets.einkMin, - height: TouchTargets.einkMin, - child: Center( - child: Text('<', style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold)), - ), - ), - ), - const SizedBox(width: Spacing.sm), - const Text( - 'DEVELOPER OPTIONS', - style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold, letterSpacing: 1), - ), - ], - ), - ); - } + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: const Text('Developer options')), + body: SafeArea( + child: ListView(padding: const EdgeInsets.all(Spacing.md), children: const []), + ), + ); } diff --git a/app/lib/pages/edit_profile_page.dart b/app/lib/pages/edit_profile_page.dart index 23bdc68..c6414f7 100644 --- a/app/lib/pages/edit_profile_page.dart +++ b/app/lib/pages/edit_profile_page.dart @@ -6,6 +6,8 @@ import 'package:go_router/go_router.dart'; import 'package:papyrus/providers/auth_provider.dart'; import 'package:papyrus/themes/design_tokens.dart'; import 'package:provider/provider.dart'; +import 'package:papyrus/themes/app_motion.dart'; +import 'package:papyrus/widgets/shared/app_progress_indicator.dart'; /// Page for editing user profile information (display name and avatar). /// @@ -68,7 +70,7 @@ class _EditProfilePageState extends State { ? const SizedBox( width: 20, height: 20, - child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), + child: AppCircularProgressIndicator(strokeWidth: 2, color: Colors.white), ) : const Text('Save'), ), @@ -255,6 +257,7 @@ class _EditProfilePageState extends State { void _showPhotoOptions(BuildContext context) { showModalBottomSheet( + sheetAnimationStyle: AppMotion.animationStyle(context), context: context, builder: (sheetContext) => SafeArea( child: Column( @@ -297,7 +300,10 @@ class _EditProfilePageState extends State { } } catch (e) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Could not open image picker'))); + ScaffoldMessenger.of(context).showSnackBar( + snackBarAnimationStyle: AppMotion.animationStyle(context), + const SnackBar(content: Text('Could not open image picker')), + ); } } } @@ -355,6 +361,7 @@ class _EditProfilePageState extends State { void _handleBack(BuildContext context) { if (_hasChanges) { showDialog( + animationStyle: AppMotion.animationStyle(context), context: context, builder: (dialogContext) => AlertDialog( title: const Text('Discard changes?'), diff --git a/app/lib/pages/forgot_password_page.dart b/app/lib/pages/forgot_password_page.dart index d334a76..bfc7c8c 100644 --- a/app/lib/pages/forgot_password_page.dart +++ b/app/lib/pages/forgot_password_page.dart @@ -9,6 +9,7 @@ import 'package:papyrus/widgets/auth/auth_switch_link.dart'; import 'package:papyrus/widgets/input/email_input.dart'; import 'package:papyrus/widgets/input/password_input.dart'; import 'package:provider/provider.dart'; +import 'package:papyrus/themes/app_motion.dart'; /// Forgot password page for the Papyrus book management application. /// Provides responsive layouts for mobile and desktop displays. @@ -139,6 +140,7 @@ class _ForgotPasswordPageState extends State { void _showSuccessSnackBar(String message) { ScaffoldMessenger.of(context).showSnackBar( + snackBarAnimationStyle: AppMotion.animationStyle(context), SnackBar( duration: const Duration(seconds: 5), content: Text(message), @@ -149,6 +151,7 @@ class _ForgotPasswordPageState extends State { void _showErrorSnackBar(String message) { ScaffoldMessenger.of(context).showSnackBar( + snackBarAnimationStyle: AppMotion.animationStyle(context), SnackBar( duration: const Duration(seconds: 5), content: Text(message), diff --git a/app/lib/pages/goals_page.dart b/app/lib/pages/goals_page.dart index fb0e8ae..a7c58c7 100644 --- a/app/lib/pages/goals_page.dart +++ b/app/lib/pages/goals_page.dart @@ -10,6 +10,7 @@ import 'package:papyrus/widgets/goals/goal_card.dart'; import 'package:papyrus/widgets/statistics/stat_card.dart'; import 'package:provider/provider.dart'; import 'package:papyrus/widgets/shared/empty_state.dart'; +import 'package:papyrus/widgets/shared/app_progress_indicator.dart'; /// Goals page displaying reading goals with progress tracking. /// @@ -74,7 +75,7 @@ class _GoalsPageState extends State { // ============================================================================ Widget _buildLoadingState(BuildContext context) { - return const Scaffold(body: Center(child: CircularProgressIndicator())); + return const Scaffold(body: Center(child: AppCircularProgressIndicator())); } // ============================================================================ diff --git a/app/lib/pages/library_page.dart b/app/lib/pages/library_page.dart index 5bb6cc3..9bc01b6 100644 --- a/app/lib/pages/library_page.dart +++ b/app/lib/pages/library_page.dart @@ -1,3 +1,4 @@ +import 'package:papyrus/widgets/shared/app_drawer.dart'; import 'dart:async'; import 'package:flutter/material.dart'; @@ -31,6 +32,8 @@ import 'package:papyrus/widgets/add_book/add_book_choice_sheet.dart'; import 'package:papyrus/widgets/shared/empty_state.dart'; import 'package:papyrus/widgets/shared/bottom_sheet_handle.dart'; import 'package:provider/provider.dart'; +import 'package:papyrus/themes/app_motion.dart'; +import 'package:papyrus/widgets/shared/app_progress_indicator.dart'; /// Main library page with responsive layouts for all platforms. /// - Mobile: AppBar with search, filter chips, 2-column grid, FAB @@ -201,6 +204,7 @@ class _LibraryPageState extends State { return Scaffold( key: _scaffoldKey, + drawerEnableOpenDragGesture: !AppMotion.disabled(context), drawer: widget.isShelfView ? null : const LibraryDrawer(), body: SafeArea( child: Column( @@ -280,7 +284,7 @@ class _LibraryPageState extends State { children: [ IconButton( icon: const Icon(Icons.menu), - onPressed: () => _scaffoldKey.currentState?.openDrawer(), + onPressed: () => openAppDrawer(context, _scaffoldKey.currentState), tooltip: 'Library sections', ), const SizedBox(width: Spacing.xs), @@ -435,6 +439,7 @@ class _LibraryPageState extends State { } return showModalBottomSheet( + sheetAnimationStyle: AppMotion.animationStyle(context), context: context, useSafeArea: true, showDragHandle: false, @@ -578,7 +583,7 @@ class _LibraryPageState extends State { ScaffoldMessenger.of(context) ..hideCurrentSnackBar() - ..showSnackBar(SnackBar(content: Text(message))); + ..showSnackBar(snackBarAnimationStyle: AppMotion.animationStyle(context), SnackBar(content: Text(message))); } // ============================================================================ @@ -820,7 +825,7 @@ class _LibraryPageState extends State { } if (isLoading) { - return const Center(child: CircularProgressIndicator()); + return const Center(child: AppCircularProgressIndicator()); } final visibleBooks = acquisitionView.books; diff --git a/app/lib/pages/login_page.dart b/app/lib/pages/login_page.dart index 21277f7..ce3b225 100644 --- a/app/lib/pages/login_page.dart +++ b/app/lib/pages/login_page.dart @@ -12,6 +12,7 @@ import 'package:papyrus/widgets/input/email_input.dart'; import 'package:papyrus/widgets/input/password_input.dart'; import 'package:papyrus/widgets/titled_divider.dart'; import 'package:provider/provider.dart'; +import 'package:papyrus/themes/app_motion.dart'; class LoginPage extends StatefulWidget { const LoginPage({super.key}); @@ -83,6 +84,7 @@ class _LoginPageState extends State { void _showErrorSnackBar(String message) { ScaffoldMessenger.of(context).showSnackBar( + snackBarAnimationStyle: AppMotion.animationStyle(context), SnackBar( duration: const Duration(seconds: 5), content: Text(message), diff --git a/app/lib/pages/notes_page.dart b/app/lib/pages/notes_page.dart index 6afbfe4..37e60b4 100644 --- a/app/lib/pages/notes_page.dart +++ b/app/lib/pages/notes_page.dart @@ -1,3 +1,4 @@ +import 'package:papyrus/widgets/shared/app_drawer.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:papyrus/data/data_store.dart'; @@ -11,6 +12,8 @@ import 'package:papyrus/widgets/book_details/note_dialog.dart'; import 'package:papyrus/widgets/library/library_drawer.dart'; import 'package:papyrus/widgets/shared/empty_state.dart'; import 'package:provider/provider.dart'; +import 'package:papyrus/themes/app_motion.dart'; +import 'package:papyrus/widgets/shared/app_motion_control.dart'; /// Notes page showing all notes across all books. /// @@ -73,6 +76,7 @@ class _NotesPageState extends State { Widget _buildMobileLayout(BuildContext context, NotesProvider provider) { return Scaffold( key: _scaffoldKey, + drawerEnableOpenDragGesture: !AppMotion.disabled(context), drawer: const LibraryDrawer(currentPath: '/library/notes'), body: SafeArea( child: Column( @@ -85,7 +89,7 @@ class _NotesPageState extends State { IconButton( icon: const Icon(Icons.menu), onPressed: () { - _scaffoldKey.currentState?.openDrawer(); + openAppDrawer(context, _scaffoldKey.currentState); }, tooltip: 'Library sections', ), @@ -172,6 +176,7 @@ class _NotesPageState extends State { Widget _buildSortButton(NotesProvider provider) { return PopupMenuButton( + popUpAnimationStyle: AppMotion.animationStyle(context), icon: const Icon(Icons.sort), tooltip: 'Sort notes', onSelected: provider.setSortOption, @@ -207,10 +212,15 @@ class _NotesPageState extends State { children: [ // Clear chip (shown when filters are active) if (provider.activeTags.isNotEmpty) ...[ - ActionChip( - label: const Text('Clear'), - onPressed: provider.clearTagFilters, - avatar: const Icon(Icons.clear, size: 16), + AppMotionControl( + value: null, + builder: (focusNode) => ActionChip( + focusNode: focusNode, + chipAnimationStyle: appChipAnimationStyle(context), + label: const Text('Clear'), + onPressed: provider.clearTagFilters, + avatar: const Icon(Icons.clear, size: 16), + ), ), const SizedBox(width: Spacing.sm), ], @@ -220,10 +230,15 @@ class _NotesPageState extends State { return Padding( padding: const EdgeInsets.only(right: Spacing.sm), - child: FilterChip( - selected: isSelected, - label: Text(tag), - onSelected: (_) => provider.toggleTagFilter(tag), + child: AppMotionControl( + value: null, + builder: (focusNode) => FilterChip( + focusNode: focusNode, + chipAnimationStyle: appChipAnimationStyle(context), + selected: isSelected, + label: Text(tag), + onSelected: (_) => provider.toggleTagFilter(tag), + ), ), ); }), @@ -332,9 +347,10 @@ class _NotesPageState extends State { await provider.deleteNote(note.id, repository: repository); } catch (_) { if (mounted) { - ScaffoldMessenger.of( - this.context, - ).showSnackBar(const SnackBar(content: Text('Could not delete. Please try again.'))); + ScaffoldMessenger.of(this.context).showSnackBar( + snackBarAnimationStyle: AppMotion.animationStyle(this.context), + const SnackBar(content: Text('Could not delete. Please try again.')), + ); } return; } diff --git a/app/lib/pages/profile_page.dart b/app/lib/pages/profile_page.dart index a197fc0..0173ae3 100644 --- a/app/lib/pages/profile_page.dart +++ b/app/lib/pages/profile_page.dart @@ -18,6 +18,7 @@ import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/widgets/settings/settings_row.dart'; import 'package:papyrus/widgets/settings/settings_section.dart'; import 'package:provider/provider.dart'; +import 'package:papyrus/themes/app_motion.dart'; enum _ProfileSection { account, @@ -1314,6 +1315,7 @@ class _ProfilePageState extends State { void _showLogoutConfirmation(BuildContext context) { showDialog( + animationStyle: AppMotion.animationStyle(context), context: context, builder: (context) => AlertDialog( title: const Text('Log out'), @@ -1342,6 +1344,7 @@ class _ProfilePageState extends State { void _showManageSyncServersSheet(BuildContext context) { showModalBottomSheet( + sheetAnimationStyle: AppMotion.animationStyle(context), context: context, builder: (sheetContext) => SafeArea( child: Consumer( @@ -1378,6 +1381,7 @@ class _ProfilePageState extends State { Navigator.pop(sheetContext); }, trailing: PopupMenuButton( + popUpAnimationStyle: AppMotion.animationStyle(context), onSelected: (value) { if (value == 'edit') { Navigator.pop(sheetContext); @@ -1412,9 +1416,11 @@ class _ProfilePageState extends State { final settings = context.read(); final urlController = TextEditingController(text: server?.url ?? ''); final messenger = ScaffoldMessenger.of(context); + final snackBarAnimationStyle = AppMotion.animationStyle(context); try { await showDialog( + animationStyle: AppMotion.animationStyle(context), context: context, builder: (dialogContext) => AlertDialog( title: Text(server == null ? 'Add custom server' : 'Edit custom server'), @@ -1436,7 +1442,10 @@ class _ProfilePageState extends State { } if (dialogContext.mounted) Navigator.pop(dialogContext); } catch (error) { - messenger.showSnackBar(SnackBar(content: Text('Could not save server: $error'))); + messenger.showSnackBar( + snackBarAnimationStyle: snackBarAnimationStyle, + SnackBar(content: Text('Could not save server: $error')), + ); } }, child: const Text('Save'), @@ -1451,22 +1460,34 @@ class _ProfilePageState extends State { Future _handleReconnectSync(BuildContext context) async { final messenger = ScaffoldMessenger.of(context); + final snackBarAnimationStyle = AppMotion.animationStyle(context); try { await context.read().reconnect(); - messenger.showSnackBar(const SnackBar(content: Text('Sync reconnect requested.'))); + messenger.showSnackBar( + snackBarAnimationStyle: snackBarAnimationStyle, + const SnackBar(content: Text('Sync reconnect requested.')), + ); } catch (error) { - messenger.showSnackBar(SnackBar(content: Text('Could not reconnect sync: $error'))); + messenger.showSnackBar( + snackBarAnimationStyle: snackBarAnimationStyle, + SnackBar(content: Text('Could not reconnect sync: $error')), + ); } } Future _retryFailedMediaUploads(BuildContext context) async { final messenger = ScaffoldMessenger.of(context); + final snackBarAnimationStyle = AppMotion.animationStyle(context); await context.read().retryFailed(); - messenger.showSnackBar(const SnackBar(content: Text('Media uploads will retry on the next sync.'))); + messenger.showSnackBar( + snackBarAnimationStyle: snackBarAnimationStyle, + const SnackBar(content: Text('Media uploads will retry on the next sync.')), + ); } void _showOfflineBackupActions(BuildContext context) { showModalBottomSheet( + sheetAnimationStyle: AppMotion.animationStyle(context), context: context, builder: (sheetContext) => SafeArea( child: Column( @@ -1497,7 +1518,10 @@ class _ProfilePageState extends State { } void _showBackupUnavailable(BuildContext context, String action) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('$action is not available yet.'))); + ScaffoldMessenger.of(context).showSnackBar( + snackBarAnimationStyle: AppMotion.animationStyle(context), + SnackBar(content: Text('$action is not available yet.')), + ); } Future _confirmClearLocalLibrary(BuildContext context) async { @@ -1511,11 +1535,18 @@ class _ProfilePageState extends State { if (!confirmed || !context.mounted) return; final messenger = ScaffoldMessenger.of(context); + final snackBarAnimationStyle = AppMotion.animationStyle(context); try { await context.read().clearGuestLibrary(); - messenger.showSnackBar(const SnackBar(content: Text('Local library cleared.'))); + messenger.showSnackBar( + snackBarAnimationStyle: snackBarAnimationStyle, + const SnackBar(content: Text('Local library cleared.')), + ); } catch (error) { - messenger.showSnackBar(SnackBar(content: Text('Could not clear local library: $error'))); + messenger.showSnackBar( + snackBarAnimationStyle: snackBarAnimationStyle, + SnackBar(content: Text('Could not clear local library: $error')), + ); } } @@ -1530,6 +1561,7 @@ class _ProfilePageState extends State { if (!confirmed || !context.mounted) return; final messenger = ScaffoldMessenger.of(context); + final snackBarAnimationStyle = AppMotion.animationStyle(context); try { final scope = context.read().activeScope; final powerSyncService = context.read(); @@ -1538,9 +1570,15 @@ class _ProfilePageState extends State { if (scope != null) { await importService.clearCoverFiles(scope); } - messenger.showSnackBar(const SnackBar(content: Text('Local copy cleared.'))); + messenger.showSnackBar( + snackBarAnimationStyle: snackBarAnimationStyle, + const SnackBar(content: Text('Local copy cleared.')), + ); } catch (error) { - messenger.showSnackBar(SnackBar(content: Text('Could not clear local copy: $error'))); + messenger.showSnackBar( + snackBarAnimationStyle: snackBarAnimationStyle, + SnackBar(content: Text('Could not clear local copy: $error')), + ); } } @@ -1551,6 +1589,7 @@ class _ProfilePageState extends State { required String actionLabel, }) async { return await showDialog( + animationStyle: AppMotion.animationStyle(context), context: context, builder: (dialogContext) => AlertDialog( title: Text(title), @@ -1774,6 +1813,7 @@ class _ProfilePageState extends State { required ValueChanged onSelected, }) { showModalBottomSheet( + sheetAnimationStyle: AppMotion.animationStyle(context), context: context, builder: (sheetContext) => SafeArea( child: Column( diff --git a/app/lib/pages/reader_page.dart b/app/lib/pages/reader_page.dart index 6368163..17e8c65 100644 --- a/app/lib/pages/reader_page.dart +++ b/app/lib/pages/reader_page.dart @@ -13,6 +13,7 @@ import 'package:papyrus/services/book_import_service_stub.dart' if (dart.library.js_interop) 'package:papyrus/services/book_import_service.dart'; import 'package:papyrus_reader/papyrus_reader.dart'; import 'package:provider/provider.dart'; +import 'package:papyrus/widgets/shared/app_progress_indicator.dart'; class ReaderPage extends StatefulWidget { const ReaderPage({super.key, required this.bookId}); @@ -123,7 +124,7 @@ class _ReaderPageState extends State { final format = _format; final preferences = _initialPreferences; if (book == null || bytes == null || format == null || preferences == null) { - return const Scaffold(body: Center(child: CircularProgressIndicator())); + return const Scaffold(body: Center(child: AppCircularProgressIndicator())); } return PapyrusReader( diff --git a/app/lib/pages/register_page.dart b/app/lib/pages/register_page.dart index 3e6e43e..07f3f10 100644 --- a/app/lib/pages/register_page.dart +++ b/app/lib/pages/register_page.dart @@ -13,6 +13,7 @@ import 'package:papyrus/widgets/input/name_input.dart'; import 'package:papyrus/widgets/input/password_input.dart'; import 'package:papyrus/widgets/titled_divider.dart'; import 'package:provider/provider.dart'; +import 'package:papyrus/themes/app_motion.dart'; class RegisterPage extends StatefulWidget { const RegisterPage({super.key}); @@ -93,6 +94,7 @@ class _RegisterPageState extends State { void _showErrorSnackBar(String message) { ScaffoldMessenger.of(context).showSnackBar( + snackBarAnimationStyle: AppMotion.animationStyle(context), SnackBar( duration: const Duration(seconds: 5), content: Text(message), diff --git a/app/lib/pages/shelves_page.dart b/app/lib/pages/shelves_page.dart index dca49e2..03b1fe9 100644 --- a/app/lib/pages/shelves_page.dart +++ b/app/lib/pages/shelves_page.dart @@ -1,3 +1,4 @@ +import 'package:papyrus/widgets/shared/app_drawer.dart'; import 'package:flutter/material.dart'; import 'package:papyrus/data/data_store.dart'; import 'package:papyrus/models/shelf.dart'; @@ -11,6 +12,8 @@ import 'package:papyrus/widgets/shelves/add_shelf_sheet.dart'; import 'package:papyrus/widgets/shelves/shelf_card.dart'; import 'package:papyrus/widgets/shelves/shelves_filter_chips.dart'; import 'package:provider/provider.dart'; +import 'package:papyrus/themes/app_motion.dart'; +import 'package:papyrus/widgets/shared/app_progress_indicator.dart'; /// Shelves page for managing book collections. /// @@ -78,7 +81,7 @@ class _ShelvesPageState extends State { // ============================================================================ Widget _buildLoadingState(BuildContext context) { - return const Scaffold(body: Center(child: CircularProgressIndicator())); + return const Scaffold(body: Center(child: AppCircularProgressIndicator())); } // ============================================================================ @@ -90,6 +93,7 @@ class _ShelvesPageState extends State { return Scaffold( key: _scaffoldKey, + drawerEnableOpenDragGesture: !AppMotion.disabled(context), drawer: const LibraryDrawer(currentPath: '/library/shelves'), body: SafeArea( child: Column( @@ -101,7 +105,7 @@ class _ShelvesPageState extends State { IconButton( icon: const Icon(Icons.menu), onPressed: () { - _scaffoldKey.currentState?.openDrawer(); + openAppDrawer(context, _scaffoldKey.currentState); }, tooltip: 'Library sections', ), @@ -366,6 +370,7 @@ class _ShelvesPageState extends State { final colorScheme = Theme.of(context).colorScheme; showModalBottomSheet( + sheetAnimationStyle: AppMotion.animationStyle(context), context: context, useRootNavigator: true, shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xl))), @@ -416,6 +421,7 @@ class _ShelvesPageState extends State { final colorScheme = Theme.of(context).colorScheme; showDialog( + animationStyle: AppMotion.animationStyle(context), context: context, builder: (context) => AlertDialog( title: const Text('Delete shelf'), @@ -429,9 +435,10 @@ class _ShelvesPageState extends State { if (context.mounted) Navigator.of(context).pop(); } catch (_) { if (context.mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('Could not delete shelf. Please try again.'))); + ScaffoldMessenger.of(context).showSnackBar( + snackBarAnimationStyle: AppMotion.animationStyle(context), + const SnackBar(content: Text('Could not delete shelf. Please try again.')), + ); } } }, diff --git a/app/lib/pages/statistics_page.dart b/app/lib/pages/statistics_page.dart index 48861be..44b5567 100644 --- a/app/lib/pages/statistics_page.dart +++ b/app/lib/pages/statistics_page.dart @@ -5,6 +5,8 @@ import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/widgets/statistics/reading_charts.dart'; import 'package:papyrus/widgets/statistics/stat_card.dart'; import 'package:provider/provider.dart'; +import 'package:papyrus/widgets/shared/app_progress_indicator.dart'; +import 'package:papyrus/widgets/shared/app_date_picker.dart'; /// Statistics page displaying reading analytics and charts. /// @@ -69,7 +71,7 @@ class _StatisticsPageState extends State { // ============================================================================ Widget _buildLoadingState(BuildContext context) { - return const Scaffold(body: Center(child: CircularProgressIndicator())); + return const Scaffold(body: Center(child: AppCircularProgressIndicator())); } // ============================================================================ @@ -381,7 +383,7 @@ class _StatisticsPageState extends State { const SizedBox(height: 4), ClipRRect( borderRadius: BorderRadius.circular(AppRadius.sm), - child: LinearProgressIndicator( + child: AppLinearProgressIndicator( value: genre.percentage.clamp(0.0, 1.0), minHeight: 6, backgroundColor: colorScheme.surfaceContainerHighest, @@ -560,7 +562,7 @@ class _StatisticsPageState extends State { ? DateTimeRange(start: provider.customStartDate!, end: provider.customEndDate!) : DateTimeRange(start: now.subtract(const Duration(days: 30)), end: now); - final picked = await showDateRangePicker( + final picked = await showAppDateRangePicker( context: context, useRootNavigator: false, initialDateRange: initialRange, diff --git a/app/lib/themes/app_motion.dart b/app/lib/themes/app_motion.dart new file mode 100644 index 0000000..48814a1 --- /dev/null +++ b/app/lib/themes/app_motion.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; + +/// Motion policy carried by the theme, independent of responsive layout. +@immutable +class AppMotion extends ThemeExtension { + final bool reduceAnimations; + + const AppMotion({this.reduceAnimations = false}); + + static bool disabled(BuildContext context) => + (Theme.of(context).extension()?.reduceAnimations ?? false) || MediaQuery.disableAnimationsOf(context); + + static Duration duration(BuildContext context, Duration normalDuration) => + disabled(context) ? Duration.zero : normalDuration; + + static AnimationStyle? animationStyle(BuildContext context) => disabled(context) ? AnimationStyle.noAnimation : null; + + @override + AppMotion copyWith({bool? reduceAnimations}) => + AppMotion(reduceAnimations: reduceAnimations ?? this.reduceAnimations); + + @override + AppMotion lerp(covariant AppMotion? other, double t) => + AppMotion(reduceAnimations: reduceAnimations || (other?.reduceAnimations ?? false)); +} + +class AppMotionScope extends StatelessWidget { + final bool reduceAnimations; + final Widget child; + + const AppMotionScope({super.key, required this.reduceAnimations, required this.child}); + + @override + Widget build(BuildContext context) { + final media = MediaQuery.of(context); + final disabled = reduceAnimations || AppMotion.disabled(context); + return MediaQuery( + data: media.copyWith(disableAnimations: disabled), + child: HeroMode(enabled: !disabled, child: child), + ); + } +} + +class InstantPageTransitionsBuilder extends PageTransitionsBuilder { + const InstantPageTransitionsBuilder(); + + @override + Duration get transitionDuration => Duration.zero; + + @override + Duration get reverseTransitionDuration => Duration.zero; + + @override + Widget buildTransitions( + PageRoute route, + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, + ) => child; +} diff --git a/app/lib/themes/app_theme.dart b/app/lib/themes/app_theme.dart index ef7528b..237d40e 100644 --- a/app/lib/themes/app_theme.dart +++ b/app/lib/themes/app_theme.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'app_motion.dart'; import 'color_schemes.g.dart'; import 'design_tokens.dart'; @@ -58,12 +59,86 @@ class AppTheme { static ThemeData get eink => ThemeData( useMaterial3: true, colorScheme: einkColorScheme, - // Typography - larger for e-ink + extensions: const [AppMotion(reduceAnimations: true)], + pageTransitionsTheme: const PageTransitionsTheme( + builders: { + TargetPlatform.android: InstantPageTransitionsBuilder(), + TargetPlatform.iOS: InstantPageTransitionsBuilder(), + TargetPlatform.macOS: InstantPageTransitionsBuilder(), + TargetPlatform.windows: InstantPageTransitionsBuilder(), + TargetPlatform.linux: InstantPageTransitionsBuilder(), + TargetPlatform.fuchsia: InstantPageTransitionsBuilder(), + }, + ), textTheme: _einkTextTheme, // Component themes - e-ink specific elevatedButtonTheme: _einkElevatedButtonTheme(), outlinedButtonTheme: _einkOutlinedButtonTheme(), textButtonTheme: _einkTextButtonTheme(), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: EinkColors.black, + foregroundColor: EinkColors.white, + disabledBackgroundColor: EinkColors.lightGray, + disabledForegroundColor: EinkColors.darkGray, + shape: const RoundedRectangleBorder(), + animationDuration: Duration.zero, + ), + ), + iconButtonTheme: const IconButtonThemeData(style: ButtonStyle(animationDuration: Duration.zero)), + navigationBarTheme: NavigationBarThemeData( + elevation: 0, + backgroundColor: EinkColors.white, + surfaceTintColor: Colors.transparent, + indicatorColor: EinkColors.black, + indicatorShape: const RoundedRectangleBorder(), + iconTheme: WidgetStateProperty.resolveWith( + (states) => IconThemeData(color: states.contains(WidgetState.selected) ? EinkColors.white : EinkColors.black), + ), + labelTextStyle: WidgetStateProperty.resolveWith( + (states) => TextStyle( + color: EinkColors.black, + fontSize: 12, + fontWeight: states.contains(WidgetState.selected) ? FontWeight.w700 : FontWeight.w500, + ), + ), + ), + chipTheme: const ChipThemeData( + backgroundColor: EinkColors.white, + selectedColor: EinkColors.lightGray, + secondarySelectedColor: EinkColors.lightGray, + labelStyle: TextStyle(color: EinkColors.black), + secondaryLabelStyle: TextStyle(color: EinkColors.black), + checkmarkColor: EinkColors.black, + side: BorderSide(color: EinkColors.black, width: BorderWidths.einkDefault), + elevation: 0, + pressElevation: 0, + ), + popupMenuTheme: const PopupMenuThemeData( + color: EinkColors.white, + surfaceTintColor: Colors.transparent, + elevation: 0, + shape: RoundedRectangleBorder( + side: BorderSide(color: EinkColors.black, width: BorderWidths.einkDefault), + ), + ), + bottomSheetTheme: const BottomSheetThemeData( + backgroundColor: EinkColors.white, + surfaceTintColor: Colors.transparent, + elevation: 0, + modalElevation: 0, + shape: RoundedRectangleBorder( + side: BorderSide(color: EinkColors.black, width: BorderWidths.einkDefault), + ), + ), + dialogTheme: const DialogThemeData( + backgroundColor: EinkColors.white, + surfaceTintColor: Colors.transparent, + elevation: 0, + shape: RoundedRectangleBorder( + side: BorderSide(color: EinkColors.black, width: BorderWidths.einkDefault), + ), + ), inputDecorationTheme: _einkInputDecorationTheme(), cardTheme: _einkCardTheme(), appBarTheme: _einkAppBarTheme(), @@ -98,24 +173,14 @@ class AppTheme { labelSmall: TextStyle(fontSize: 11, fontWeight: FontWeight.w500, letterSpacing: 0.5), ); - // E-ink typography: larger minimum size, bolder weights - static const TextTheme _einkTextTheme = TextTheme( - displayLarge: TextStyle(fontSize: 57, fontWeight: FontWeight.w500, letterSpacing: -0.25), - displayMedium: TextStyle(fontSize: 45, fontWeight: FontWeight.w500, letterSpacing: 0), - displaySmall: TextStyle(fontSize: 36, fontWeight: FontWeight.w500, letterSpacing: 0), - headlineLarge: TextStyle(fontSize: 32, fontWeight: FontWeight.w600, letterSpacing: 0), - headlineMedium: TextStyle(fontSize: 28, fontWeight: FontWeight.w600, letterSpacing: 0), - headlineSmall: TextStyle(fontSize: 24, fontWeight: FontWeight.w600, letterSpacing: 0), - titleLarge: TextStyle(fontSize: 22, fontWeight: FontWeight.w500, letterSpacing: 0), - titleMedium: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, letterSpacing: 0.15), - titleSmall: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, letterSpacing: 0.1), - bodyLarge: TextStyle(fontSize: 18, fontWeight: FontWeight.w400, letterSpacing: 0.5), - bodyMedium: TextStyle(fontSize: 16, fontWeight: FontWeight.w400, letterSpacing: 0.25), - bodySmall: TextStyle(fontSize: 14, fontWeight: FontWeight.w400, letterSpacing: 0.4), - labelLarge: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, letterSpacing: 0.1), - labelMedium: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, letterSpacing: 0.5), - labelSmall: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, letterSpacing: 0.5), - ); + static TextTheme get _einkTextTheme => _textTheme + .copyWith( + bodyMedium: _textTheme.bodyMedium!.copyWith(fontWeight: FontWeight.w500), + bodySmall: _textTheme.bodySmall!.copyWith(fontWeight: FontWeight.w500), + labelMedium: _textTheme.labelMedium!.copyWith(fontWeight: FontWeight.w600), + labelSmall: _textTheme.labelSmall!.copyWith(fontWeight: FontWeight.w600), + ) + .apply(bodyColor: EinkColors.black, displayColor: EinkColors.black); // =========================================================================== // STANDARD COMPONENT THEMES @@ -251,7 +316,8 @@ class AppTheme { static ElevatedButtonThemeData _einkElevatedButtonTheme() { return ElevatedButtonThemeData( style: ElevatedButton.styleFrom( - minimumSize: const Size.fromHeight(ComponentSizes.buttonHeightEink), + minimumSize: const Size.fromHeight(ComponentSizes.buttonHeightMobile), + animationDuration: Duration.zero, padding: const EdgeInsets.symmetric( horizontal: Spacing.buttonPaddingHorizontal, vertical: Spacing.buttonPaddingVertical, @@ -260,7 +326,7 @@ class AppTheme { elevation: 0, backgroundColor: EinkColors.black, foregroundColor: EinkColors.white, - textStyle: const TextStyle(fontSize: 20, fontWeight: FontWeight.w600, letterSpacing: 0.5), + textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), ), ); } @@ -268,7 +334,8 @@ class AppTheme { static OutlinedButtonThemeData _einkOutlinedButtonTheme() { return OutlinedButtonThemeData( style: OutlinedButton.styleFrom( - minimumSize: const Size.fromHeight(ComponentSizes.buttonHeightEink), + minimumSize: const Size.fromHeight(ComponentSizes.buttonHeightMobile), + animationDuration: Duration.zero, padding: const EdgeInsets.symmetric( horizontal: Spacing.buttonPaddingHorizontal, vertical: Spacing.buttonPaddingVertical, @@ -277,7 +344,7 @@ class AppTheme { side: const BorderSide(color: EinkColors.black, width: BorderWidths.einkDefault), backgroundColor: EinkColors.white, foregroundColor: EinkColors.black, - textStyle: const TextStyle(fontSize: 20, fontWeight: FontWeight.w600, letterSpacing: 0.5), + textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), ), ); } @@ -285,9 +352,10 @@ class AppTheme { static TextButtonThemeData _einkTextButtonTheme() { return TextButtonThemeData( style: TextButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + animationDuration: Duration.zero, foregroundColor: EinkColors.black, - textStyle: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, decoration: TextDecoration.underline), + textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), ), ); } @@ -316,10 +384,9 @@ class AppTheme { borderRadius: BorderRadius.zero, borderSide: BorderSide(color: EinkColors.black, width: BorderWidths.einkError), ), - contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 20), + contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 16), labelStyle: TextStyle(color: EinkColors.black, fontSize: 16, fontWeight: FontWeight.w700), - hintStyle: TextStyle(color: EinkColors.mediumGray, fontSize: 20), - floatingLabelBehavior: FloatingLabelBehavior.always, + hintStyle: TextStyle(color: EinkColors.darkGray), ); } @@ -330,7 +397,7 @@ class AppTheme { borderRadius: BorderRadius.zero, side: BorderSide(color: EinkColors.black, width: BorderWidths.einkDefault), ), - margin: EdgeInsets.all(Spacing.md), + margin: EdgeInsets.all(Spacing.sm), ); } @@ -340,7 +407,7 @@ class AppTheme { scrolledUnderElevation: 0, backgroundColor: EinkColors.white, foregroundColor: EinkColors.black, - titleTextStyle: TextStyle(color: EinkColors.black, fontSize: 24, fontWeight: FontWeight.w700), + titleTextStyle: TextStyle(color: EinkColors.black, fontSize: 22, fontWeight: FontWeight.w700), shape: Border( bottom: BorderSide(color: EinkColors.black, width: BorderWidths.einkDefault), ), @@ -360,16 +427,16 @@ class AppTheme { } static DividerThemeData _einkDividerTheme() { - return const DividerThemeData(color: EinkColors.lightGray, thickness: 2, space: Spacing.lg); + return const DividerThemeData(color: EinkColors.black, thickness: 2, space: Spacing.md); } static SnackBarThemeData _einkSnackBarTheme() { return const SnackBarThemeData( backgroundColor: EinkColors.black, - contentTextStyle: TextStyle(color: EinkColors.white, fontSize: 16), + contentTextStyle: TextStyle(color: EinkColors.white), actionTextColor: EinkColors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.zero), - behavior: SnackBarBehavior.fixed, + behavior: SnackBarBehavior.floating, ); } } diff --git a/app/lib/themes/color_schemes.g.dart b/app/lib/themes/color_schemes.g.dart index 4a8455c..3bbe382 100644 --- a/app/lib/themes/color_schemes.g.dart +++ b/app/lib/themes/color_schemes.g.dart @@ -87,8 +87,8 @@ const einkColorScheme = ColorScheme( // Primary elements: pure black primary: Color(0xFF000000), onPrimary: Color(0xFFFFFFFF), - primaryContainer: Color(0xFFF5F5F5), - onPrimaryContainer: Color(0xFF000000), + primaryContainer: Color(0xFF000000), + onPrimaryContainer: Color(0xFFFFFFFF), // Secondary: dark gray secondary: Color(0xFF404040), onSecondary: Color(0xFFFFFFFF), @@ -116,8 +116,8 @@ const einkColorScheme = ColorScheme( inversePrimary: Color(0xFF000000), // Shadows: none (use borders instead) shadow: Color(0x00000000), - surfaceTint: Color(0xFF000000), - outlineVariant: Color(0xFFC0C0C0), + surfaceTint: Color(0x00000000), + outlineVariant: Color(0xFF000000), scrim: Color(0xFF000000), ); diff --git a/app/lib/utils/book_actions.dart b/app/lib/utils/book_actions.dart index b85b9c9..8b8a82a 100644 --- a/app/lib/utils/book_actions.dart +++ b/app/lib/utils/book_actions.dart @@ -16,6 +16,7 @@ import 'package:papyrus/widgets/context_menu/book_context_menu.dart'; import 'package:papyrus/widgets/shelves/move_to_shelf_sheet.dart'; import 'package:papyrus/widgets/topics/manage_topics_sheet.dart'; import 'package:provider/provider.dart'; +import 'package:papyrus/themes/app_motion.dart'; /// Show the book context menu with standard actions. /// @@ -78,9 +79,10 @@ Future toggleBookFavorite(BuildContext context, String bookId, bool curren await context.read().toggleFavorite(bookId, currentFavorite); } catch (_) { if (context.mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('Could not save favorite. Please try again.'))); + ScaffoldMessenger.of(context).showSnackBar( + snackBarAnimationStyle: AppMotion.animationStyle(context), + const SnackBar(content: Text('Could not save favorite. Please try again.')), + ); } } } @@ -91,7 +93,10 @@ Future _downloadBookFile(BuildContext context, Book book) async { final mediaCacheService = context.read(); final downloadService = context.read(); - messenger.showSnackBar(const SnackBar(content: Text('Preparing download...'))); + messenger.showSnackBar( + snackBarAnimationStyle: AppMotion.animationStyle(context), + const SnackBar(content: Text('Preparing download...')), + ); try { final cached = await mediaCacheService.getValidCachedBookFile(book, readLocalBookFile: importService.getBookFile); @@ -110,14 +115,23 @@ Future _downloadBookFile(BuildContext context, Book book) async { if (!context.mounted) return; messenger.hideCurrentSnackBar(); if (result.saved) { - messenger.showSnackBar(SnackBar(content: Text('Downloaded "${book.title}"'))); + messenger.showSnackBar( + snackBarAnimationStyle: AppMotion.animationStyle(context), + SnackBar(content: Text('Downloaded "${book.title}"')), + ); } else { - messenger.showSnackBar(const SnackBar(content: Text('Download canceled'))); + messenger.showSnackBar( + snackBarAnimationStyle: AppMotion.animationStyle(context), + const SnackBar(content: Text('Download canceled')), + ); } } catch (_) { if (!context.mounted) return; messenger.hideCurrentSnackBar(); - messenger.showSnackBar(const SnackBar(content: Text('Could not download this book file.'))); + messenger.showSnackBar( + snackBarAnimationStyle: AppMotion.animationStyle(context), + const SnackBar(content: Text('Could not download this book file.')), + ); } } diff --git a/app/lib/utils/bulk_book_actions.dart b/app/lib/utils/bulk_book_actions.dart index 5e983b8..2a6301f 100644 --- a/app/lib/utils/bulk_book_actions.dart +++ b/app/lib/utils/bulk_book_actions.dart @@ -13,6 +13,7 @@ import 'package:papyrus/widgets/library/bulk_status_sheet.dart'; import 'package:papyrus/widgets/shelves/move_to_shelf_sheet.dart'; import 'package:papyrus/widgets/topics/manage_topics_sheet.dart'; import 'package:provider/provider.dart'; +import 'package:papyrus/themes/app_motion.dart'; // ============================================================================= // LOW-LEVEL BULK OPERATIONS @@ -133,9 +134,10 @@ Future handleBulkToggleFavorite(BuildContext context, LibraryProvider libr libraryProvider.exitSelectionMode(); } catch (_) { if (context.mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('Could not save favorites. Please try again.'))); + ScaffoldMessenger.of(context).showSnackBar( + snackBarAnimationStyle: AppMotion.animationStyle(context), + const SnackBar(content: Text('Could not save favorites. Please try again.')), + ); } } } @@ -146,6 +148,7 @@ void handleBulkDelete(BuildContext context, LibraryProvider libraryProvider) { final count = libraryProvider.selectedCount; showDialog( + animationStyle: AppMotion.animationStyle(context), context: context, builder: (context) => AlertDialog( title: const Text('Delete books?'), diff --git a/app/lib/widgets/acquisition/acquisition_action_sheets.dart b/app/lib/widgets/acquisition/acquisition_action_sheets.dart index 5708a7e..4e17896 100644 --- a/app/lib/widgets/acquisition/acquisition_action_sheets.dart +++ b/app/lib/widgets/acquisition/acquisition_action_sheets.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/widgets/shared/bottom_sheet_handle.dart'; import 'package:papyrus/widgets/shared/bottom_sheet_header.dart'; +import 'package:papyrus/themes/app_motion.dart'; typedef AcquisitionCommandLabel = String Function(String command); @@ -13,6 +14,7 @@ Future showAcquisitionCommandSheet({ required AcquisitionCommandLabel commandLabel, }) { return showModalBottomSheet( + sheetAnimationStyle: AppMotion.animationStyle(context), context: context, useSafeArea: true, showDragHandle: false, @@ -57,6 +59,7 @@ Future?> showAcquisitionIdsSheet({required BuildContext context, requi var enteredIds = ''; return showModalBottomSheet>( + sheetAnimationStyle: AppMotion.animationStyle(context), context: context, isScrollControlled: true, useSafeArea: true, @@ -68,7 +71,8 @@ Future?> showAcquisitionIdsSheet({required BuildContext context, requi key: const Key('acquisition-arr-ids-sheet'), padding: EdgeInsets.zero, child: AnimatedPadding( - duration: const Duration(milliseconds: 150), + key: ValueKey(AppMotion.disabled(context)), + duration: AppMotion.duration(context, const Duration(milliseconds: 150)), curve: Curves.easeOut, padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(sheetContext).bottom), child: SingleChildScrollView( @@ -113,6 +117,7 @@ Future?> showAcquisitionIdsSheet({required BuildContext context, requi Future showAcquisitionRemoveDialog({required BuildContext context, required String endpointName}) { return showDialog( + animationStyle: AppMotion.animationStyle(context), context: context, builder: (dialogContext) { final colorScheme = Theme.of(dialogContext).colorScheme; diff --git a/app/lib/widgets/acquisition/acquisition_endpoint_editor.dart b/app/lib/widgets/acquisition/acquisition_endpoint_editor.dart index 1b42025..3be0510 100644 --- a/app/lib/widgets/acquisition/acquisition_endpoint_editor.dart +++ b/app/lib/widgets/acquisition/acquisition_endpoint_editor.dart @@ -7,6 +7,9 @@ import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/widgets/acquisition/guarded_bottom_sheet_route.dart'; import 'package:papyrus/widgets/shared/bottom_sheet_handle.dart'; import 'package:papyrus/widgets/shared/bottom_sheet_header.dart'; +import 'package:papyrus/themes/app_motion.dart'; +import 'package:papyrus/widgets/shared/app_progress_indicator.dart'; +import 'package:papyrus/widgets/shared/app_motion_control.dart'; typedef AcquisitionEndpointTestCallback = Future Function({ @@ -62,7 +65,8 @@ Future showAcquisitionEndpointEditor({ final maxEditorHeight = math.max(0.0, availableHeight * .92); return AnimatedPadding( - duration: const Duration(milliseconds: 150), + key: ValueKey(AppMotion.disabled(sheetContext)), + duration: AppMotion.duration(sheetContext, const Duration(milliseconds: 150)), curve: Curves.easeOut, padding: EdgeInsets.only(bottom: viewInsets.bottom), child: ConstrainedBox( @@ -293,11 +297,16 @@ class _AcquisitionEndpointEditorState extends State { ], if (widget.endpoint != null) ...[ const SizedBox(height: Spacing.formFieldSpacing), - SwitchListTile( - contentPadding: EdgeInsets.zero, - title: const Text('Enabled'), + AppMotionControl( value: _enabled, - onChanged: _busy ? null : (enabled) => setState(() => _enabled = enabled), + enabled: !_busy, + builder: (focusNode) => SwitchListTile( + focusNode: focusNode, + contentPadding: EdgeInsets.zero, + title: const Text('Enabled'), + value: _enabled, + onChanged: _busy ? null : (enabled) => setState(() => _enabled = enabled), + ), ), ], const SizedBox(height: Spacing.formFieldSpacing), @@ -307,7 +316,7 @@ class _AcquisitionEndpointEditorState extends State { key: const Key('acquisition-test-connection'), onPressed: _busy ? null : _testConnection, icon: _testing - ? const SizedBox.square(dimension: 18, child: CircularProgressIndicator(strokeWidth: 2)) + ? const SizedBox.square(dimension: 18, child: AppCircularProgressIndicator(strokeWidth: 2)) : const Icon(Icons.cable_outlined), label: const Text('Test connection'), ), diff --git a/app/lib/widgets/acquisition/guarded_bottom_sheet_route.dart b/app/lib/widgets/acquisition/guarded_bottom_sheet_route.dart index e1ed34e..1e86159 100644 --- a/app/lib/widgets/acquisition/guarded_bottom_sheet_route.dart +++ b/app/lib/widgets/acquisition/guarded_bottom_sheet_route.dart @@ -1,5 +1,6 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:papyrus/themes/app_motion.dart'; Future showGuardedModalBottomSheet({ required BuildContext context, @@ -22,6 +23,7 @@ Future showGuardedModalBottomSheet({ barrierOnTapHint: localizations.scrimOnTapHint(localizations.bottomSheetLabel), modalBarrierColor: Theme.of(context).bottomSheetTheme.modalBarrierColor, shape: shape, + sheetAnimationStyle: AppMotion.animationStyle(context), ), ); } @@ -35,6 +37,7 @@ class _GuardedModalBottomSheetRoute extends ModalBottomSheetRoute { required super.barrierOnTapHint, required super.modalBarrierColor, required super.shape, + super.sheetAnimationStyle, }) : _busy = busy, super( isScrollControlled: true, diff --git a/app/lib/widgets/add_book/add_book_choice_sheet.dart b/app/lib/widgets/add_book/add_book_choice_sheet.dart index 6a14503..91e4b11 100644 --- a/app/lib/widgets/add_book/add_book_choice_sheet.dart +++ b/app/lib/widgets/add_book/add_book_choice_sheet.dart @@ -3,6 +3,7 @@ import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/widgets/add_book/add_physical_book_sheet.dart'; import 'package:papyrus/widgets/add_book/book_import_sheet.dart'; import 'package:papyrus/widgets/shared/bottom_sheet_handle.dart'; +import 'package:papyrus/themes/app_motion.dart'; /// Choice sheet for selecting digital import, physical entry, or optional online search. class AddBookChoiceSheet extends StatefulWidget { @@ -24,6 +25,7 @@ class AddBookChoiceSheet extends StatefulWidget { }) async { Future<_AddBookChoice?>? sheetCompleted; final choice = await showModalBottomSheet<_AddBookChoice>( + sheetAnimationStyle: AppMotion.animationStyle(context), context: context, isScrollControlled: true, useRootNavigator: true, diff --git a/app/lib/widgets/add_book/add_physical_book_sheet.dart b/app/lib/widgets/add_book/add_physical_book_sheet.dart index 840cd40..8166b8f 100644 --- a/app/lib/widgets/add_book/add_physical_book_sheet.dart +++ b/app/lib/widgets/add_book/add_physical_book_sheet.dart @@ -15,6 +15,8 @@ import 'package:papyrus/widgets/book_form/book_text_field.dart'; import 'package:papyrus/widgets/book_form/co_author_editor.dart'; import 'package:provider/provider.dart'; import 'package:uuid/uuid.dart'; +import 'package:papyrus/themes/app_motion.dart'; +import 'package:papyrus/widgets/shared/app_progress_indicator.dart'; /// ISBN lookup states. enum _IsbnLookupState { idle, fetching, found, notFound, error } @@ -26,6 +28,7 @@ class AddPhysicalBookSheet extends StatelessWidget { /// Show the sheet as a bottom sheet. static Future show(BuildContext context) { return showModalBottomSheet( + sheetAnimationStyle: AppMotion.animationStyle(context), context: context, isScrollControlled: true, useRootNavigator: true, @@ -246,7 +249,10 @@ class _PhysicalBookContentState extends State<_PhysicalBookContent> { final messenger = ScaffoldMessenger.of(context); Navigator.of(context).pop(); - messenger.showSnackBar(SnackBar(content: Text('Added "${book.title}" to library'))); + messenger.showSnackBar( + snackBarAnimationStyle: AppMotion.animationStyle(context), + SnackBar(content: Text('Added "${book.title}" to library')), + ); } // ============================================================================ @@ -465,7 +471,7 @@ class _PhysicalBookContentState extends State<_PhysicalBookContent> { ? const SizedBox( width: 20, height: 20, - child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), + child: AppCircularProgressIndicator(strokeWidth: 2, color: Colors.white), ) : const Icon(Icons.search), ), diff --git a/app/lib/widgets/add_book/book_import_drop_zone.dart b/app/lib/widgets/add_book/book_import_drop_zone.dart index fe00ec3..419843c 100644 --- a/app/lib/widgets/add_book/book_import_drop_zone.dart +++ b/app/lib/widgets/add_book/book_import_drop_zone.dart @@ -6,6 +6,8 @@ import 'package:flutter/services.dart'; import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/widgets/add_book/book_import_batch_item.dart'; import 'package:papyrus/widgets/add_book/book_import_controller.dart'; +import 'package:papyrus/themes/app_motion.dart'; +import 'package:papyrus/widgets/shared/app_progress_indicator.dart'; typedef DroppedBookFilesCallback = void Function(List files, {String? feedback}); @@ -62,7 +64,8 @@ class _BookImportDropZoneState extends State { }, onShowFocusHighlight: (value) => setState(() => _isFocused = value), child: AnimatedContainer( - duration: const Duration(milliseconds: 180), + key: ValueKey(AppMotion.disabled(context)), + duration: AppMotion.duration(context, const Duration(milliseconds: 180)), curve: Curves.easeOutCubic, decoration: BoxDecoration(color: backgroundColor, borderRadius: BorderRadius.circular(AppRadius.xl)), child: CustomPaint( @@ -90,7 +93,7 @@ class _BookImportDropZoneState extends State { mainAxisSize: MainAxisSize.min, children: [ if (_isBusy) - const SizedBox.square(dimension: 48, child: CircularProgressIndicator(strokeWidth: 3)) + const SizedBox.square(dimension: 48, child: AppCircularProgressIndicator(strokeWidth: 3)) else Icon(Icons.cloud_upload_outlined, size: 48, color: colorScheme.primary), const SizedBox(height: Spacing.md), diff --git a/app/lib/widgets/add_book/book_import_item_card.dart b/app/lib/widgets/add_book/book_import_item_card.dart index 43a6f52..9883b79 100644 --- a/app/lib/widgets/add_book/book_import_item_card.dart +++ b/app/lib/widgets/add_book/book_import_item_card.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/widgets/add_book/book_import_batch_item.dart'; +import 'package:papyrus/themes/app_motion.dart'; +import 'package:papyrus/widgets/shared/app_progress_indicator.dart'; enum BookImportItemCardPresentation { progress, summary } @@ -45,8 +47,8 @@ class BookImportItemCard extends StatelessWidget { final textTheme = Theme.of(context).textTheme; return AnimatedContainer( - key: ValueKey('${item.id}-${item.status}'), - duration: const Duration(milliseconds: 250), + key: ValueKey('${item.id}-${item.status}-${AppMotion.disabled(context)}'), + duration: AppMotion.duration(context, const Duration(milliseconds: 250)), margin: const EdgeInsets.only(bottom: Spacing.xs), decoration: BoxDecoration( color: _added @@ -66,7 +68,11 @@ class BookImportItemCard extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: Spacing.md, vertical: Spacing.sm), child: Row( children: [ - AnimatedSwitcher(duration: const Duration(milliseconds: 200), child: _statusWidget(colorScheme)), + AnimatedSwitcher( + key: ValueKey(AppMotion.disabled(context)), + duration: AppMotion.duration(context, const Duration(milliseconds: 200)), + child: _statusWidget(colorScheme), + ), const SizedBox(width: Spacing.md), Expanded( child: Column( @@ -197,19 +203,19 @@ class BookImportItemCard extends StatelessWidget { key: const ValueKey('queued-icon'), width: 24, height: 24, - child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onSurfaceVariant), + child: AppCircularProgressIndicator(strokeWidth: 2, color: colorScheme.onSurfaceVariant), ), BookImportBatchStatus.processing => const SizedBox( key: ValueKey('processing-icon'), width: 24, height: 24, - child: CircularProgressIndicator(strokeWidth: 2), + child: AppCircularProgressIndicator(strokeWidth: 2), ), BookImportBatchStatus.adding => const SizedBox( key: ValueKey('adding-icon'), width: 24, height: 24, - child: CircularProgressIndicator(strokeWidth: 2), + child: AppCircularProgressIndicator(strokeWidth: 2), ), BookImportBatchStatus.ready => Icon( Icons.check_circle_outline, diff --git a/app/lib/widgets/add_book/book_import_results_sheet.dart b/app/lib/widgets/add_book/book_import_results_sheet.dart index e362c2c..2fcae1c 100644 --- a/app/lib/widgets/add_book/book_import_results_sheet.dart +++ b/app/lib/widgets/add_book/book_import_results_sheet.dart @@ -15,6 +15,8 @@ import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/widgets/add_book/add_book_sheet_scaffold.dart'; import 'package:papyrus/widgets/add_book/book_import_batch_item.dart'; import 'package:provider/provider.dart'; +import 'package:papyrus/themes/app_motion.dart'; +import 'package:papyrus/widgets/shared/app_progress_indicator.dart'; typedef BookImportProcessor = Future Function(Uint8List bytes, String filename); typedef ImportedBookFileDeleter = Future Function(String bookId); @@ -98,6 +100,7 @@ class BookImportResultsSheet extends StatefulWidget { final messenger = ScaffoldMessenger.maybeOf(context); return showModalBottomSheet( + sheetAnimationStyle: AppMotion.animationStyle(context), context: context, isScrollControlled: true, useRootNavigator: true, @@ -119,6 +122,7 @@ class BookImportResultsSheet extends StatefulWidget { onCompleted: (books) { final count = books.length; messenger?.showSnackBar( + snackBarAnimationStyle: AppMotion.animationStyle(context), SnackBar(content: Text('Added $count ${count == 1 ? 'book' : 'books'} to library')), ); }, @@ -355,9 +359,10 @@ class _BookImportResultsSheetState extends State { } if (!deleted) { setState(() {}); - ScaffoldMessenger.maybeOf( - context, - )?.showSnackBar(const SnackBar(content: Text('Could not remove the imported file. Please try again.'))); + ScaffoldMessenger.maybeOf(context)?.showSnackBar( + snackBarAnimationStyle: AppMotion.animationStyle(context), + const SnackBar(content: Text('Could not remove the imported file. Please try again.')), + ); return; } final currentIndex = _indexOf(id); @@ -439,9 +444,10 @@ class _BookImportResultsSheetState extends State { void _restoreAfterCloseFailure() { if (!mounted) return; setState(() => _isClosing = false); - ScaffoldMessenger.maybeOf( - context, - )?.showSnackBar(const SnackBar(content: Text('Could not remove temporary files. Please try again.'))); + ScaffoldMessenger.maybeOf(context)?.showSnackBar( + snackBarAnimationStyle: AppMotion.animationStyle(context), + const SnackBar(content: Text('Could not remove temporary files. Please try again.')), + ); } @override @@ -539,7 +545,7 @@ class _ImportResultRow extends StatelessWidget { if (isRemoving) const Padding( padding: EdgeInsets.all(Spacing.sm), - child: SizedBox.square(dimension: 20, child: CircularProgressIndicator(strokeWidth: 2)), + child: SizedBox.square(dimension: 20, child: AppCircularProgressIndicator(strokeWidth: 2)), ) else IconButton(tooltip: 'Remove ${item.file.name}', onPressed: onRemove, icon: const Icon(Icons.close)), @@ -554,13 +560,13 @@ class _ImportResultRow extends StatelessWidget { BookImportBatchStatus.queued => const Icon(Icons.schedule_outlined), BookImportBatchStatus.processing => const SizedBox.square( dimension: 24, - child: CircularProgressIndicator(strokeWidth: 2), + child: AppCircularProgressIndicator(strokeWidth: 2), ), BookImportBatchStatus.ready => Icon(Icons.check_circle_outline, color: colorScheme.primary), BookImportBatchStatus.processingFailed => Icon(Icons.error_outline, color: colorScheme.error), BookImportBatchStatus.adding => const SizedBox.square( dimension: 24, - child: CircularProgressIndicator(strokeWidth: 2), + child: AppCircularProgressIndicator(strokeWidth: 2), ), BookImportBatchStatus.added => Icon(Icons.check_circle, color: colorScheme.primary), BookImportBatchStatus.commitFailed => Icon(Icons.error_outline, color: colorScheme.error), diff --git a/app/lib/widgets/add_book/book_import_sheet.dart b/app/lib/widgets/add_book/book_import_sheet.dart index 3df1dc8..ccefec3 100644 --- a/app/lib/widgets/add_book/book_import_sheet.dart +++ b/app/lib/widgets/add_book/book_import_sheet.dart @@ -17,6 +17,7 @@ import 'package:papyrus/widgets/add_book/book_import_batch_item.dart'; import 'package:papyrus/widgets/add_book/book_import_controller.dart'; import 'package:papyrus/widgets/add_book/book_import_sheet_sections.dart'; import 'package:provider/provider.dart'; +import 'package:papyrus/themes/app_motion.dart'; export 'package:papyrus/widgets/add_book/book_import_controller.dart' show BookImportProcessor, DigitalBookFilePicker, ImportedBookCommitter, ImportedBookFileDeleter; @@ -68,6 +69,7 @@ class BookImportSheet extends StatefulWidget { committer ?? (BookImportResult result, String filename) => _commitResult(context, result, filename); return showModalBottomSheet( + sheetAnimationStyle: AppMotion.animationStyle(context), context: context, isScrollControlled: true, useRootNavigator: true, @@ -89,9 +91,10 @@ class BookImportSheet extends StatefulWidget { onClose: () => Navigator.of(sheetContext).pop(), onCompleted: (books) { final count = books.length; - ScaffoldMessenger.maybeOf( - context, - )?.showSnackBar(SnackBar(content: Text('$count ${count == 1 ? 'book' : 'books'} added to library'))); + ScaffoldMessenger.maybeOf(context)?.showSnackBar( + snackBarAnimationStyle: AppMotion.animationStyle(context), + SnackBar(content: Text('$count ${count == 1 ? 'book' : 'books'} added to library')), + ); }, ), ); @@ -220,9 +223,10 @@ class _BookImportSheetState extends State { Future _removeItem(String id) async { final result = await _controller.removeItem(id); if (!mounted || result != BookImportRemoveResult.cleanupFailed) return; - ScaffoldMessenger.maybeOf( - context, - )?.showSnackBar(const SnackBar(content: Text('Could not remove the imported file.'))); + ScaffoldMessenger.maybeOf(context)?.showSnackBar( + snackBarAnimationStyle: AppMotion.animationStyle(context), + const SnackBar(content: Text('Could not remove the imported file.')), + ); } Future _requestClose() { @@ -246,9 +250,10 @@ class _BookImportSheetState extends State { widget.onClose(); case BookImportCloseResult.cleanupFailed: case BookImportCloseResult.processingCleanupFailed: - ScaffoldMessenger.maybeOf( - context, - )?.showSnackBar(const SnackBar(content: Text('Could not remove temporary files. Please try again.'))); + ScaffoldMessenger.maybeOf(context)?.showSnackBar( + snackBarAnimationStyle: AppMotion.animationStyle(context), + const SnackBar(content: Text('Could not remove temporary files. Please try again.')), + ); } } } diff --git a/app/lib/widgets/add_book/digital_book_import_sheet.dart b/app/lib/widgets/add_book/digital_book_import_sheet.dart index f9892c4..b0196be 100644 --- a/app/lib/widgets/add_book/digital_book_import_sheet.dart +++ b/app/lib/widgets/add_book/digital_book_import_sheet.dart @@ -4,6 +4,8 @@ import 'package:flutter/material.dart'; import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/widgets/add_book/add_book_sheet_scaffold.dart'; import 'package:papyrus/widgets/add_book/book_import_batch_item.dart'; +import 'package:papyrus/themes/app_motion.dart'; +import 'package:papyrus/widgets/shared/app_progress_indicator.dart'; typedef DigitalBookFilePicker = Future> Function(); @@ -29,6 +31,7 @@ class DigitalBookImportSheet extends StatefulWidget { static Future?> show(BuildContext context, {DigitalBookFilePicker? pickFiles}) async { Future?>? sheetCompleted; final files = await showModalBottomSheet>( + sheetAnimationStyle: AppMotion.animationStyle(context), context: context, isScrollControlled: true, useRootNavigator: true, @@ -122,7 +125,7 @@ class _DigitalBookImportSheetState extends State { child: OutlinedButton.icon( onPressed: _isPicking ? null : _browse, icon: _isPicking - ? const SizedBox.square(dimension: 18, child: CircularProgressIndicator(strokeWidth: 2)) + ? const SizedBox.square(dimension: 18, child: AppCircularProgressIndicator(strokeWidth: 2)) : const Icon(Icons.upload_file), label: const Text('Browse files'), ), diff --git a/app/lib/widgets/annotations/annotation_action_sheet.dart b/app/lib/widgets/annotations/annotation_action_sheet.dart index c833d71..0096dfc 100644 --- a/app/lib/widgets/annotations/annotation_action_sheet.dart +++ b/app/lib/widgets/annotations/annotation_action_sheet.dart @@ -6,6 +6,7 @@ import 'package:papyrus/models/annotation.dart'; import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/widgets/shared/bottom_sheet_handle.dart'; import 'package:papyrus/widgets/shared/bottom_sheet_header.dart'; +import 'package:papyrus/themes/app_motion.dart'; // ============================================================================= // ANNOTATION NOTE SHEET @@ -25,6 +26,7 @@ class AnnotationNoteSheet extends StatefulWidget { FutureOr Function(String)? onSave, }) { return showModalBottomSheet( + sheetAnimationStyle: AppMotion.animationStyle(context), context: context, isScrollControlled: true, shape: const RoundedRectangleBorder( @@ -113,6 +115,7 @@ class DeleteAnnotationDialog { /// Show the delete confirmation dialog. Returns true if confirmed. static Future show(BuildContext context, {required Annotation annotation, required String bookTitle}) async { final result = await showDialog( + animationStyle: AppMotion.animationStyle(context), context: context, builder: (context) => AlertDialog( title: const Text('Delete annotation'), diff --git a/app/lib/widgets/auth/auth_continue_button.dart b/app/lib/widgets/auth/auth_continue_button.dart index 9a81c7b..eeb2484 100644 --- a/app/lib/widgets/auth/auth_continue_button.dart +++ b/app/lib/widgets/auth/auth_continue_button.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/widgets/shared/app_progress_indicator.dart'; /// Continue/submit button with loading state for auth pages. class AuthContinueButton extends StatelessWidget { @@ -32,10 +33,7 @@ class AuthContinueButton extends StatelessWidget { ? SizedBox( width: 24, height: 24, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation(theme.colorScheme.onPrimary), - ), + child: AppCircularProgressIndicator(strokeWidth: 2, color: theme.colorScheme.onPrimary), ) : Row( mainAxisAlignment: MainAxisAlignment.center, diff --git a/app/lib/widgets/book/book.dart b/app/lib/widgets/book/book.dart index e336952..c839889 100644 --- a/app/lib/widgets/book/book.dart +++ b/app/lib/widgets/book/book.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:papyrus/models/book.dart' as models; import 'package:papyrus/providers/enums/library_reading_status.dart'; +import 'package:papyrus/themes/app_motion.dart'; class Book extends StatefulWidget { final String id; @@ -33,13 +34,24 @@ class _BookState extends State with SingleTickerProviderStateMixin { } @override - Widget build(BuildContext context) { - if (isFinished) { + void didChangeDependencies() { + super.didChangeDependencies(); + _updateAnimation(); + } + + void _updateAnimation() { + animationController.duration = AppMotion.duration(context, const Duration(milliseconds: 250)); + if (AppMotion.disabled(context)) { + animationController.value = isFinished ? 1 : 0; + } else if (isFinished) { animationController.forward(); } else { animationController.reverse(); } + } + @override + Widget build(BuildContext context) { return GridTile( child: InkWell( borderRadius: BorderRadius.circular(8.0), @@ -48,6 +60,7 @@ class _BookState extends State with SingleTickerProviderStateMixin { }, onLongPress: () { showModalBottomSheet( + sheetAnimationStyle: AppMotion.animationStyle(context), isScrollControlled: true, shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(18.0))), context: context, @@ -66,6 +79,7 @@ class _BookState extends State with SingleTickerProviderStateMixin { onPressed: () { setState(() { isFinished = !isFinished; + _updateAnimation(); context.pop(); }); }, @@ -149,7 +163,7 @@ class _BookState extends State with SingleTickerProviderStateMixin { @override void dispose() { - super.dispose(); animationController.dispose(); + super.dispose(); } } diff --git a/app/lib/widgets/book/book_annotations.dart b/app/lib/widgets/book/book_annotations.dart index 8af1069..143ceab 100644 --- a/app/lib/widgets/book/book_annotations.dart +++ b/app/lib/widgets/book/book_annotations.dart @@ -4,6 +4,7 @@ import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/widgets/book_details/annotation_card.dart'; import 'package:papyrus/widgets/book_details/empty_annotations_state.dart'; import 'package:papyrus/widgets/input/search_field.dart'; +import 'package:papyrus/themes/app_motion.dart'; /// Sort options for annotations within a single book. enum _AnnotationSort { dateNewest, dateOldest, position, color } @@ -201,6 +202,7 @@ class _BookAnnotationsState extends State { Widget _buildSortButton() { return PopupMenuButton<_AnnotationSort>( + popUpAnimationStyle: AppMotion.animationStyle(context), icon: const Icon(Icons.sort), tooltip: 'Sort annotations', onSelected: (option) => setState(() => _sortOption = option), diff --git a/app/lib/widgets/book/book_bookmarks.dart b/app/lib/widgets/book/book_bookmarks.dart index 618a9c8..2bfb0eb 100644 --- a/app/lib/widgets/book/book_bookmarks.dart +++ b/app/lib/widgets/book/book_bookmarks.dart @@ -4,6 +4,7 @@ import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/widgets/book_details/empty_bookmarks_state.dart'; import 'package:papyrus/widgets/bookmarks/bookmark_list_item.dart'; import 'package:papyrus/widgets/input/search_field.dart'; +import 'package:papyrus/themes/app_motion.dart'; /// Sort options for bookmarks within a single book. enum _BookmarkSort { dateNewest, dateOldest, position } @@ -189,6 +190,7 @@ class _BookBookmarksState extends State { Widget _buildSortButton() { return PopupMenuButton<_BookmarkSort>( + popUpAnimationStyle: AppMotion.animationStyle(context), icon: const Icon(Icons.sort), tooltip: 'Sort bookmarks', onSelected: (option) => setState(() => _sortOption = option), diff --git a/app/lib/widgets/book/book_details.dart b/app/lib/widgets/book/book_details.dart index 64c03c3..6cde6c5 100644 --- a/app/lib/widgets/book/book_details.dart +++ b/app/lib/widgets/book/book_details.dart @@ -7,6 +7,7 @@ import 'package:papyrus/widgets/shelves/move_to_shelf_sheet.dart'; import 'package:papyrus/widgets/topics/manage_topics_sheet.dart'; import 'package:papyrus/widgets/topics/topic_detail_sheet.dart'; import 'package:provider/provider.dart'; +import 'package:papyrus/widgets/shared/app_motion_control.dart'; /// Details tab content for book details page. /// Shows description, information grid, shelves, and topics. @@ -166,20 +167,30 @@ class _BookDetailsState extends State { runSpacing: Spacing.sm, children: [ ...shelves.map((shelf) { - return ActionChip( - avatar: Icon(shelf.displayIcon, size: 16, color: shelf.color), - label: Text(shelf.name), - visualDensity: VisualDensity.compact, - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - onPressed: () {}, + return AppMotionControl( + value: null, + builder: (focusNode) => ActionChip( + focusNode: focusNode, + chipAnimationStyle: appChipAnimationStyle(context), + avatar: Icon(shelf.displayIcon, size: 16, color: shelf.color), + label: Text(shelf.name), + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + onPressed: () {}, + ), ); }), - ActionChip( - avatar: const Icon(Icons.add, size: 16), - label: Text(shelves.isEmpty ? 'Add to shelf' : 'Edit'), - visualDensity: VisualDensity.compact, - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - onPressed: () => _showMoveToShelfSheet(context), + AppMotionControl( + value: null, + builder: (focusNode) => ActionChip( + focusNode: focusNode, + chipAnimationStyle: appChipAnimationStyle(context), + avatar: const Icon(Icons.add, size: 16), + label: Text(shelves.isEmpty ? 'Add to shelf' : 'Edit'), + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + onPressed: () => _showMoveToShelfSheet(context), + ), ), ], ); @@ -194,24 +205,34 @@ class _BookDetailsState extends State { runSpacing: Spacing.sm, children: [ ...tags.map((tag) { - return ActionChip( - avatar: Container( - width: 8, - height: 8, - decoration: BoxDecoration(color: tag.color, shape: BoxShape.circle), + return AppMotionControl( + value: null, + builder: (focusNode) => ActionChip( + focusNode: focusNode, + chipAnimationStyle: appChipAnimationStyle(context), + avatar: Container( + width: 8, + height: 8, + decoration: BoxDecoration(color: tag.color, shape: BoxShape.circle), + ), + label: Text(tag.name), + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + onPressed: () => TopicDetailSheet.show(context, tag: tag), ), - label: Text(tag.name), - visualDensity: VisualDensity.compact, - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - onPressed: () => TopicDetailSheet.show(context, tag: tag), ); }), - ActionChip( - avatar: const Icon(Icons.add, size: 16), - label: Text(tags.isEmpty ? 'Add topics' : 'Edit'), - visualDensity: VisualDensity.compact, - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - onPressed: () => _showManageTopicsSheet(context), + AppMotionControl( + value: null, + builder: (focusNode) => ActionChip( + focusNode: focusNode, + chipAnimationStyle: appChipAnimationStyle(context), + avatar: const Icon(Icons.add, size: 16), + label: Text(tags.isEmpty ? 'Add topics' : 'Edit'), + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + onPressed: () => _showManageTopicsSheet(context), + ), ), ], ); diff --git a/app/lib/widgets/book/book_notes.dart b/app/lib/widgets/book/book_notes.dart index 7498b78..7a3fa14 100644 --- a/app/lib/widgets/book/book_notes.dart +++ b/app/lib/widgets/book/book_notes.dart @@ -4,6 +4,7 @@ import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/widgets/book_details/empty_notes_state.dart'; import 'package:papyrus/widgets/book_details/note_card.dart'; import 'package:papyrus/widgets/input/search_field.dart'; +import 'package:papyrus/themes/app_motion.dart'; /// Sort options for notes within a single book. enum _NoteSort { dateNewest, dateOldest, title } @@ -202,6 +203,7 @@ class _BookNotesState extends State { Widget _buildSortButton() { return PopupMenuButton<_NoteSort>( + popUpAnimationStyle: AppMotion.animationStyle(context), icon: const Icon(Icons.sort), tooltip: 'Sort notes', onSelected: (option) => setState(() => _sortOption = option), diff --git a/app/lib/widgets/book/private_book_cover.dart b/app/lib/widgets/book/private_book_cover.dart index d15f210..29382f4 100644 --- a/app/lib/widgets/book/private_book_cover.dart +++ b/app/lib/widgets/book/private_book_cover.dart @@ -2,6 +2,7 @@ import 'dart:typed_data'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; +import 'package:papyrus/themes/app_motion.dart'; import 'package:papyrus/media/cover_storage_bucket.dart'; import 'package:papyrus/media/local_cover_image_provider.dart'; import 'package:papyrus/media/media_cache_service.dart'; @@ -266,6 +267,8 @@ class _PrivateBookCoverState extends State { if (_hasPublicUrl) { return CachedNetworkImage( + fadeInDuration: AppMotion.duration(context, const Duration(milliseconds: 500)), + fadeOutDuration: AppMotion.duration(context, const Duration(milliseconds: 1000)), imageUrl: widget.imageUrl!, fit: widget.fit, placeholder: (_, _) => _buildLoadingPlaceholder(context), diff --git a/app/lib/widgets/book_details/annotation_action_sheet.dart b/app/lib/widgets/book_details/annotation_action_sheet.dart index ea789d3..e7ee324 100644 --- a/app/lib/widgets/book_details/annotation_action_sheet.dart +++ b/app/lib/widgets/book_details/annotation_action_sheet.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:papyrus/models/annotation.dart'; import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/themes/app_motion.dart'; /// Result of annotation action sheet selection. enum AnnotationAction { edit, delete } @@ -14,6 +15,7 @@ class AnnotationActionSheet extends StatelessWidget { /// Shows the action sheet and returns the selected action. static Future show(BuildContext context, {required Annotation annotation}) async { return showModalBottomSheet( + sheetAnimationStyle: AppMotion.animationStyle(context), context: context, builder: (context) => AnnotationActionSheet(annotation: annotation), ); diff --git a/app/lib/widgets/book_details/annotation_card.dart b/app/lib/widgets/book_details/annotation_card.dart index a0835c0..942ac49 100644 --- a/app/lib/widgets/book_details/annotation_card.dart +++ b/app/lib/widgets/book_details/annotation_card.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:papyrus/models/annotation.dart'; import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/themes/app_motion.dart'; /// A card widget for displaying a book annotation (highlight). /// @@ -131,8 +132,9 @@ class _AnnotationCardState extends State { /// Shown only on mouse hover with animated opacity. Widget _buildActionMenu(ColorScheme colorScheme) { return AnimatedOpacity( + key: ValueKey(AppMotion.disabled(context)), opacity: _isHovered ? 1.0 : 0.0, - duration: const Duration(milliseconds: 150), + duration: AppMotion.duration(context, const Duration(milliseconds: 150)), child: IconButton( icon: const Icon(Icons.more_vert), iconSize: IconSizes.action, diff --git a/app/lib/widgets/book_details/annotation_dialog.dart b/app/lib/widgets/book_details/annotation_dialog.dart index 27e3bf0..339146a 100644 --- a/app/lib/widgets/book_details/annotation_dialog.dart +++ b/app/lib/widgets/book_details/annotation_dialog.dart @@ -8,6 +8,7 @@ import 'package:papyrus/models/annotation.dart'; import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/widgets/shared/bottom_sheet_handle.dart'; import 'package:papyrus/widgets/shared/bottom_sheet_header.dart'; +import 'package:papyrus/themes/app_motion.dart'; /// Bottom sheet for creating or editing an annotation manually. class AnnotationDialog extends StatefulWidget { @@ -25,6 +26,7 @@ class AnnotationDialog extends StatefulWidget { FutureOr Function(Annotation)? onSave, }) { return showModalBottomSheet( + sheetAnimationStyle: AppMotion.animationStyle(context), context: context, isScrollControlled: true, useRootNavigator: true, diff --git a/app/lib/widgets/book_details/book_action_buttons.dart b/app/lib/widgets/book_details/book_action_buttons.dart index 05f6d38..27682be 100644 --- a/app/lib/widgets/book_details/book_action_buttons.dart +++ b/app/lib/widgets/book_details/book_action_buttons.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:papyrus/models/book.dart'; import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/widgets/shared/app_progress_indicator.dart'; enum BookReadingActionState { ready, download, checking, syncing, failed, downloading, unavailable } @@ -50,7 +51,7 @@ class BookActionButtons extends StatelessWidget { BookReadingActionState.download => const Icon(Icons.download_outlined), BookReadingActionState.downloading => const SizedBox.square( dimension: 18, - child: CircularProgressIndicator(strokeWidth: 2), + child: AppCircularProgressIndicator(strokeWidth: 2), ), BookReadingActionState.checking => const Icon(Icons.hourglass_empty), BookReadingActionState.syncing => const Icon(Icons.cloud_sync_outlined), diff --git a/app/lib/widgets/book_details/book_header.dart b/app/lib/widgets/book_details/book_header.dart index 8d1c862..43308d2 100644 --- a/app/lib/widgets/book_details/book_header.dart +++ b/app/lib/widgets/book_details/book_header.dart @@ -4,6 +4,7 @@ import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/widgets/book_details/book_action_buttons.dart'; import 'package:papyrus/widgets/book_details/book_cover_image.dart'; import 'package:papyrus/widgets/book_details/book_progress_bar.dart'; +import 'package:papyrus/widgets/shared/app_motion_control.dart'; /// Header section for book details page. /// Contains cover image, title, author, metadata, progress bar, and action buttons. @@ -221,11 +222,16 @@ class BookHeader extends StatelessWidget { ...book.topics .take(2) .map( - (topic) => Chip( - label: Text(topic), - visualDensity: VisualDensity.compact, - padding: EdgeInsets.zero, - labelPadding: const EdgeInsets.symmetric(horizontal: Spacing.sm), + (topic) => AppMotionControl( + value: null, + builder: (focusNode) => Chip( + focusNode: focusNode, + chipAnimationStyle: appChipAnimationStyle(context), + label: Text(topic), + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + labelPadding: const EdgeInsets.symmetric(horizontal: Spacing.sm), + ), ), ), ], diff --git a/app/lib/widgets/book_details/book_progress_bar.dart b/app/lib/widgets/book_details/book_progress_bar.dart index 63a111d..319059b 100644 --- a/app/lib/widgets/book_details/book_progress_bar.dart +++ b/app/lib/widgets/book_details/book_progress_bar.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/widgets/shared/app_progress_indicator.dart'; /// Progress bar widget for book reading progress. /// Shows a linear progress bar with an optional label. @@ -35,7 +36,7 @@ class BookProgressBar extends StatelessWidget { Expanded( child: ClipRRect( borderRadius: BorderRadius.circular(barHeight / 2), - child: LinearProgressIndicator( + child: AppLinearProgressIndicator( value: progress, backgroundColor: colorScheme.surfaceContainerHighest, color: progress >= 1.0 ? colorScheme.tertiary : colorScheme.primary, diff --git a/app/lib/widgets/book_details/bookmark_dialog.dart b/app/lib/widgets/book_details/bookmark_dialog.dart index 97f60b3..1c3b7fd 100644 --- a/app/lib/widgets/book_details/bookmark_dialog.dart +++ b/app/lib/widgets/book_details/bookmark_dialog.dart @@ -8,6 +8,7 @@ import 'package:papyrus/widgets/shared/bottom_sheet_handle.dart'; import 'package:papyrus/widgets/shared/bottom_sheet_header.dart'; import 'package:papyrus/widgets/shared/persistent_save.dart'; import 'package:uuid/uuid.dart'; +import 'package:papyrus/themes/app_motion.dart'; /// Bottom sheet for creating or editing a bookmark manually. class BookmarkDialog extends StatefulWidget { @@ -27,6 +28,7 @@ class BookmarkDialog extends StatefulWidget { FutureOr Function(Bookmark)? onSave, }) { return showModalBottomSheet( + sheetAnimationStyle: AppMotion.animationStyle(context), context: context, isScrollControlled: true, useRootNavigator: true, diff --git a/app/lib/widgets/book_details/note_action_sheet.dart b/app/lib/widgets/book_details/note_action_sheet.dart index 7a2c90a..fd84d93 100644 --- a/app/lib/widgets/book_details/note_action_sheet.dart +++ b/app/lib/widgets/book_details/note_action_sheet.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:papyrus/models/note.dart'; import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/widgets/shared/bottom_sheet_handle.dart'; +import 'package:papyrus/themes/app_motion.dart'; /// Result of note action sheet selection. enum NoteAction { edit, delete } @@ -15,6 +16,7 @@ class NoteActionSheet extends StatelessWidget { /// Shows the action sheet and returns the selected action. static Future show(BuildContext context, {required Note note}) async { return showModalBottomSheet( + sheetAnimationStyle: AppMotion.animationStyle(context), context: context, builder: (context) => NoteActionSheet(note: note), ); @@ -77,6 +79,7 @@ class DeleteNoteDialog extends StatelessWidget { /// Shows the delete confirmation dialog. static Future show(BuildContext context, {required Note note}) async { final result = await showDialog( + animationStyle: AppMotion.animationStyle(context), context: context, builder: (context) => DeleteNoteDialog(note: note), ); diff --git a/app/lib/widgets/book_details/note_card.dart b/app/lib/widgets/book_details/note_card.dart index 31be18c..fdb58f7 100644 --- a/app/lib/widgets/book_details/note_card.dart +++ b/app/lib/widgets/book_details/note_card.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:papyrus/models/note.dart'; import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/themes/app_motion.dart'; +import 'package:papyrus/widgets/shared/app_motion_control.dart'; /// A card widget for displaying a note with title, content preview, and metadata. /// @@ -122,8 +124,9 @@ class _NoteCardState extends State { ), if (widget.showActionMenu) AnimatedOpacity( + key: ValueKey(AppMotion.disabled(context)), opacity: _isHovered ? 1.0 : 0.0, - duration: const Duration(milliseconds: 150), + duration: AppMotion.duration(context, const Duration(milliseconds: 150)), child: IconButton( icon: const Icon(Icons.more_vert), iconSize: IconSizes.action, @@ -154,12 +157,17 @@ class _NoteCardState extends State { spacing: Spacing.xs, runSpacing: Spacing.xs, children: widget.note.tags.map((tag) { - return Chip( - label: Text(tag), - visualDensity: VisualDensity.compact, - padding: EdgeInsets.zero, - labelPadding: const EdgeInsets.symmetric(horizontal: Spacing.sm), - labelStyle: textTheme.labelSmall, + return AppMotionControl( + value: null, + builder: (focusNode) => Chip( + focusNode: focusNode, + chipAnimationStyle: appChipAnimationStyle(context), + label: Text(tag), + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + labelPadding: const EdgeInsets.symmetric(horizontal: Spacing.sm), + labelStyle: textTheme.labelSmall, + ), ); }).toList(), ), diff --git a/app/lib/widgets/book_details/note_dialog.dart b/app/lib/widgets/book_details/note_dialog.dart index 63dd5d0..acc8bc7 100644 --- a/app/lib/widgets/book_details/note_dialog.dart +++ b/app/lib/widgets/book_details/note_dialog.dart @@ -7,6 +7,8 @@ import 'package:papyrus/models/note.dart'; import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/widgets/shared/bottom_sheet_handle.dart'; import 'package:papyrus/widgets/shared/bottom_sheet_header.dart'; +import 'package:papyrus/themes/app_motion.dart'; +import 'package:papyrus/widgets/shared/app_motion_control.dart'; /// Dialog for adding or editing a note. class NoteDialog extends StatelessWidget { @@ -26,6 +28,7 @@ class NoteDialog extends StatelessWidget { FutureOr Function(Note)? onSave, }) async { return showModalBottomSheet( + sheetAnimationStyle: AppMotion.animationStyle(context), context: context, isScrollControlled: true, useRootNavigator: true, @@ -253,11 +256,16 @@ class _BottomSheetNoteState extends State<_BottomSheetNote> with PersistentSave< spacing: Spacing.xs, runSpacing: Spacing.xs, children: _tags.map((tag) { - return Chip( - label: Text(tag), - deleteIcon: const Icon(Icons.close, size: 18), - onDeleted: () => _removeTag(tag), - visualDensity: VisualDensity.compact, + return AppMotionControl( + value: null, + builder: (focusNode) => Chip( + focusNode: focusNode, + chipAnimationStyle: appChipAnimationStyle(context), + label: Text(tag), + deleteIcon: const Icon(Icons.close, size: 18), + onDeleted: () => _removeTag(tag), + visualDensity: VisualDensity.compact, + ), ); }).toList(), ) diff --git a/app/lib/widgets/book_details/update_progress_sheet.dart b/app/lib/widgets/book_details/update_progress_sheet.dart index 76c5398..fe13fea 100644 --- a/app/lib/widgets/book_details/update_progress_sheet.dart +++ b/app/lib/widgets/book_details/update_progress_sheet.dart @@ -4,6 +4,7 @@ import 'package:papyrus/models/book.dart'; import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/widgets/shared/bottom_sheet_handle.dart'; import 'package:papyrus/widgets/shared/bottom_sheet_header.dart'; +import 'package:papyrus/themes/app_motion.dart'; /// Bottom sheet for manually updating reading progress of a physical book. class UpdateProgressSheet extends StatefulWidget { @@ -19,6 +20,7 @@ class UpdateProgressSheet extends StatefulWidget { required void Function(int page, double position) onSave, }) { return showModalBottomSheet( + sheetAnimationStyle: AppMotion.animationStyle(context), context: context, isScrollControlled: true, shape: const RoundedRectangleBorder( diff --git a/app/lib/widgets/book_edit/cover_image_picker.dart b/app/lib/widgets/book_edit/cover_image_picker.dart index c6944a6..186cc93 100644 --- a/app/lib/widgets/book_edit/cover_image_picker.dart +++ b/app/lib/widgets/book_edit/cover_image_picker.dart @@ -3,6 +3,7 @@ import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/widgets/book/private_book_cover.dart'; +import 'package:papyrus/widgets/shared/app_progress_indicator.dart'; /// Widget for picking book cover images from file or URL. class CoverImagePicker extends StatefulWidget { @@ -199,7 +200,7 @@ class _CoverImagePickerState extends State { Widget _buildCoverImage(BuildContext context) { if (_isLoading) { - return const Center(child: CircularProgressIndicator()); + return const Center(child: AppCircularProgressIndicator()); } if (_imageBytes != null) { @@ -222,7 +223,7 @@ class _CoverImagePickerState extends State { errorBuilder: (_, e, s) => _buildPlaceholder(context), loadingBuilder: (context, child, loadingProgress) { if (loadingProgress == null) return child; - return const Center(child: CircularProgressIndicator()); + return const Center(child: AppCircularProgressIndicator()); }, ), Positioned(top: 8, right: 8, child: _buildRemoveButton(context)), diff --git a/app/lib/widgets/book_form/book_date_field.dart b/app/lib/widgets/book_form/book_date_field.dart index 12fea7b..cef8623 100644 --- a/app/lib/widgets/book_form/book_date_field.dart +++ b/app/lib/widgets/book_form/book_date_field.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/widgets/shared/app_date_picker.dart'; /// Reusable date picker field for book forms (add and edit). class BookDateField extends StatelessWidget { @@ -45,7 +46,7 @@ class BookDateField extends StatelessWidget { } Future _pickDate(BuildContext context) async { - final picked = await showDatePicker( + final picked = await showAppDatePicker( context: context, initialDate: value ?? DateTime.now(), firstDate: DateTime(1000), diff --git a/app/lib/widgets/book_form/co_author_editor.dart b/app/lib/widgets/book_form/co_author_editor.dart index 80ba1e3..c00e839 100644 --- a/app/lib/widgets/book_form/co_author_editor.dart +++ b/app/lib/widgets/book_form/co_author_editor.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/themes/app_motion.dart'; +import 'package:papyrus/widgets/shared/app_motion_control.dart'; /// Editable co-author chips with add dialog. Used in book forms. class CoAuthorEditor extends StatelessWidget { @@ -22,18 +24,28 @@ class CoAuthorEditor extends StatelessWidget { runSpacing: Spacing.xs, children: [ ...coAuthors.map( - (author) => Chip( - label: Text(author), - onDeleted: () { - final updated = List.from(coAuthors)..remove(author); - onChanged(updated); - }, + (author) => AppMotionControl( + value: null, + builder: (focusNode) => Chip( + focusNode: focusNode, + chipAnimationStyle: appChipAnimationStyle(context), + label: Text(author), + onDeleted: () { + final updated = List.from(coAuthors)..remove(author); + onChanged(updated); + }, + ), ), ), - ActionChip( - avatar: const Icon(Icons.add, size: 18), - label: const Text('Add'), - onPressed: () => _showAddDialog(context), + AppMotionControl( + value: null, + builder: (focusNode) => ActionChip( + focusNode: focusNode, + chipAnimationStyle: appChipAnimationStyle(context), + avatar: const Icon(Icons.add, size: 18), + label: const Text('Add'), + onPressed: () => _showAddDialog(context), + ), ), ], ), @@ -45,6 +57,7 @@ class CoAuthorEditor extends StatelessWidget { final controller = TextEditingController(); showDialog( + animationStyle: AppMotion.animationStyle(context), context: context, builder: (ctx) => AlertDialog( title: const Text('Add co-author'), diff --git a/app/lib/widgets/bookmarks/bookmark_action_sheet.dart b/app/lib/widgets/bookmarks/bookmark_action_sheet.dart index e0469dc..764a1a6 100644 --- a/app/lib/widgets/bookmarks/bookmark_action_sheet.dart +++ b/app/lib/widgets/bookmarks/bookmark_action_sheet.dart @@ -6,6 +6,7 @@ import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/widgets/shared/bottom_sheet_handle.dart'; import 'package:papyrus/widgets/shared/bottom_sheet_header.dart'; import 'package:papyrus/widgets/shared/persistent_save.dart'; +import 'package:papyrus/themes/app_motion.dart'; // ============================================================================= // BOOKMARK ACTION SHEET (action chooser) @@ -23,6 +24,7 @@ class BookmarkActionSheet extends StatelessWidget { /// Shows the action sheet and returns the selected action. static Future show(BuildContext context, {required Bookmark bookmark}) async { return showModalBottomSheet( + sheetAnimationStyle: AppMotion.animationStyle(context), context: context, builder: (context) => BookmarkActionSheet(bookmark: bookmark), ); @@ -116,6 +118,7 @@ class BookmarkNoteSheet extends StatefulWidget { FutureOr Function(String)? onSave, }) { return showModalBottomSheet( + sheetAnimationStyle: AppMotion.animationStyle(context), context: context, isScrollControlled: true, useRootNavigator: true, @@ -214,6 +217,7 @@ class BookmarkColorSheet extends StatefulWidget { FutureOr Function(String)? onSave, }) { return showModalBottomSheet( + sheetAnimationStyle: AppMotion.animationStyle(context), context: context, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.bottomSheet)), @@ -321,6 +325,7 @@ class DeleteBookmarkDialog { FutureOr Function()? onDelete, }) async { final result = await showDialog( + animationStyle: AppMotion.animationStyle(context), context: context, builder: (context) => _DeleteBookmarkConfirmation(bookmark: bookmark, bookTitle: bookTitle, onDelete: onDelete), ); diff --git a/app/lib/widgets/bookmarks/bookmark_list_item.dart b/app/lib/widgets/bookmarks/bookmark_list_item.dart index 47a40d7..7f95a9c 100644 --- a/app/lib/widgets/bookmarks/bookmark_list_item.dart +++ b/app/lib/widgets/bookmarks/bookmark_list_item.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:papyrus/models/bookmark.dart'; import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/themes/app_motion.dart'; /// A card widget for displaying a single bookmark. /// @@ -109,8 +110,9 @@ class _BookmarkListItemState extends State { /// Shown only on mouse hover with animated opacity. Widget _buildActionMenu(ColorScheme colorScheme) { return AnimatedOpacity( + key: ValueKey(AppMotion.disabled(context)), opacity: _isHovered ? 1.0 : 0.0, - duration: const Duration(milliseconds: 150), + duration: AppMotion.duration(context, const Duration(milliseconds: 150)), child: IconButton( icon: const Icon(Icons.more_vert), iconSize: IconSizes.action, diff --git a/app/lib/widgets/buttons/google_sign_in.dart b/app/lib/widgets/buttons/google_sign_in.dart index cba8b95..697efe5 100644 --- a/app/lib/widgets/buttons/google_sign_in.dart +++ b/app/lib/widgets/buttons/google_sign_in.dart @@ -3,6 +3,8 @@ import 'package:go_router/go_router.dart'; import 'package:papyrus/providers/auth_provider.dart'; import 'package:papyrus/themes/design_tokens.dart'; import 'package:provider/provider.dart'; +import 'package:papyrus/themes/app_motion.dart'; +import 'package:papyrus/widgets/shared/app_progress_indicator.dart'; /// A Google Sign-in button with loading state and error handling. class GoogleSignInButton extends StatefulWidget { @@ -42,6 +44,7 @@ class _GoogleSignInButtonState extends State { } else if (provider.error != null) { widget.onError?.call(); ScaffoldMessenger.of(context).showSnackBar( + snackBarAnimationStyle: AppMotion.animationStyle(context), SnackBar( duration: const Duration(seconds: 5), content: Text(provider.error!), @@ -54,6 +57,7 @@ class _GoogleSignInButtonState extends State { widget.onError?.call(); ScaffoldMessenger.of(context).showSnackBar( + snackBarAnimationStyle: AppMotion.animationStyle(context), SnackBar( duration: const Duration(seconds: 5), content: const Text('Failed to sign in with Google account.'), @@ -78,10 +82,7 @@ class _GoogleSignInButtonState extends State { child: SizedBox( width: 24, height: 24, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation(theme.colorScheme.primary), - ), + child: AppCircularProgressIndicator(strokeWidth: 2, color: theme.colorScheme.primary), ), ), ); diff --git a/app/lib/widgets/context_menu/book_context_menu.dart b/app/lib/widgets/context_menu/book_context_menu.dart index 3c0ba92..14a280f 100644 --- a/app/lib/widgets/context_menu/book_context_menu.dart +++ b/app/lib/widgets/context_menu/book_context_menu.dart @@ -3,6 +3,7 @@ import 'package:papyrus/models/book.dart'; import 'package:papyrus/providers/enums/library_reading_status.dart'; import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/widgets/book/private_book_cover.dart'; +import 'package:papyrus/themes/app_motion.dart'; /// Context menu for book actions. class BookContextMenu { @@ -21,6 +22,7 @@ class BookContextMenu { VoidCallback? onDelete, }) { showModalBottomSheet( + sheetAnimationStyle: AppMotion.animationStyle(context), context: context, useRootNavigator: true, isScrollControlled: true, @@ -44,6 +46,7 @@ class BookContextMenu { static void _confirmDelete(BuildContext context, Book book, VoidCallback? onDelete) { showDialog( + animationStyle: AppMotion.animationStyle(context), context: context, builder: (context) => AlertDialog( title: const Text('Delete book?'), diff --git a/app/lib/widgets/dashboard/reading_goal_card.dart b/app/lib/widgets/dashboard/reading_goal_card.dart index 37538b3..815ce66 100644 --- a/app/lib/widgets/dashboard/reading_goal_card.dart +++ b/app/lib/widgets/dashboard/reading_goal_card.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:papyrus/models/reading_goal.dart'; import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/widgets/shared/app_progress_indicator.dart'; /// Card displaying the user's reading goals progress. /// Supports multiple goals displayed in a compact list. @@ -97,7 +98,7 @@ class ReadingGoalCard extends StatelessWidget { const SizedBox(height: Spacing.xs), ClipRRect( borderRadius: BorderRadius.circular(2), - child: LinearProgressIndicator( + child: AppLinearProgressIndicator( value: goal.progress, backgroundColor: colorScheme.surfaceContainerHighest, color: goal.isCompleted ? colorScheme.tertiary : colorScheme.primary, diff --git a/app/lib/widgets/filter/active_filter_bar.dart b/app/lib/widgets/filter/active_filter_bar.dart index df73687..390e377 100644 --- a/app/lib/widgets/filter/active_filter_bar.dart +++ b/app/lib/widgets/filter/active_filter_bar.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:papyrus/models/active_filter.dart'; import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/widgets/shared/app_motion_control.dart'; /// Horizontal scrolling bar showing active filters with remove buttons. /// Only visible when filters are active. @@ -66,15 +67,20 @@ class _ActiveFilterChip extends StatelessWidget { Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - return Chip( - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - visualDensity: VisualDensity.compact, - backgroundColor: colorScheme.secondaryContainer, - side: BorderSide.none, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppRadius.sm)), - label: _buildLabel(context), - deleteIcon: Icon(Icons.close, size: 18, color: colorScheme.onSecondaryContainer), - onDeleted: onRemoved, + return AppMotionControl( + value: null, + builder: (focusNode) => Chip( + focusNode: focusNode, + chipAnimationStyle: appChipAnimationStyle(context), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + backgroundColor: colorScheme.secondaryContainer, + side: BorderSide.none, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppRadius.sm)), + label: _buildLabel(context), + deleteIcon: Icon(Icons.close, size: 18, color: colorScheme.onSecondaryContainer), + onDeleted: onRemoved, + ), ); } diff --git a/app/lib/widgets/goals/active_goal_details_sheet.dart b/app/lib/widgets/goals/active_goal_details_sheet.dart index e14e6e3..fa6426e 100644 --- a/app/lib/widgets/goals/active_goal_details_sheet.dart +++ b/app/lib/widgets/goals/active_goal_details_sheet.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:papyrus/models/reading_goal.dart'; import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/widgets/shared/bottom_sheet_handle.dart'; +import 'package:papyrus/themes/app_motion.dart'; +import 'package:papyrus/widgets/shared/app_progress_indicator.dart'; /// Bottom sheet for viewing and editing an active goal. class ActiveGoalDetailsSheet extends StatefulWidget { @@ -28,6 +30,7 @@ class ActiveGoalDetailsSheet extends StatefulWidget { VoidCallback? onDelete, }) { return showModalBottomSheet( + sheetAnimationStyle: AppMotion.animationStyle(context), context: context, isScrollControlled: true, shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xl))), @@ -280,7 +283,7 @@ class _ActiveGoalDetailsSheetState extends State { const SizedBox(height: Spacing.xs), ClipRRect( borderRadius: BorderRadius.circular(AppRadius.sm), - child: LinearProgressIndicator( + child: AppLinearProgressIndicator( value: progress, minHeight: 8, backgroundColor: colorScheme.surfaceContainerHighest, @@ -503,6 +506,7 @@ class _ActiveGoalDetailsSheetState extends State { final colorScheme = Theme.of(context).colorScheme; showDialog( + animationStyle: AppMotion.animationStyle(context), context: context, builder: (context) => AlertDialog( title: const Text('Delete goal?'), diff --git a/app/lib/widgets/goals/add_goal_sheet.dart b/app/lib/widgets/goals/add_goal_sheet.dart index 80384bd..cbd5bfd 100644 --- a/app/lib/widgets/goals/add_goal_sheet.dart +++ b/app/lib/widgets/goals/add_goal_sheet.dart @@ -2,6 +2,9 @@ import 'package:flutter/material.dart'; import 'package:papyrus/models/reading_goal.dart'; import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/widgets/shared/bottom_sheet_handle.dart'; +import 'package:papyrus/themes/app_motion.dart'; +import 'package:papyrus/widgets/shared/app_date_picker.dart'; +import 'package:papyrus/widgets/shared/app_motion_control.dart'; /// The type of goal scheduling. enum GoalScheduleType { @@ -39,6 +42,7 @@ class AddGoalSheet extends StatefulWidget { onCreate, }) { return showModalBottomSheet( + sheetAnimationStyle: AppMotion.animationStyle(context), context: context, isScrollControlled: true, shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xl))), @@ -256,12 +260,17 @@ class _AddGoalSheetState extends State { Wrap( spacing: Spacing.sm, children: List.generate(presets.length, (i) { - return ChoiceChip( - label: Text(presetLabels[i]), - selected: _durationMinutes == presets[i], - onSelected: (_) { - setState(() => _durationMinutes = presets[i]); - }, + return AppMotionControl( + value: null, + builder: (focusNode) => ChoiceChip( + focusNode: focusNode, + chipAnimationStyle: appChipAnimationStyle(context), + label: Text(presetLabels[i]), + selected: _durationMinutes == presets[i], + onSelected: (_) { + setState(() => _durationMinutes = presets[i]); + }, + ), ); }), ), @@ -346,7 +355,7 @@ class _AddGoalSheetState extends State { } Future _pickStartDate(BuildContext context) async { - final picked = await showDatePicker( + final picked = await showAppDatePicker( context: context, initialDate: _startDate, firstDate: DateTime.now().subtract(const Duration(days: 365)), @@ -364,7 +373,7 @@ class _AddGoalSheetState extends State { } Future _pickEndDate(BuildContext context) async { - final picked = await showDatePicker( + final picked = await showAppDatePicker( context: context, initialDate: _endDate, firstDate: _startDate, diff --git a/app/lib/widgets/goals/completed_goal_chip.dart b/app/lib/widgets/goals/completed_goal_chip.dart index 5701c70..bee0b40 100644 --- a/app/lib/widgets/goals/completed_goal_chip.dart +++ b/app/lib/widgets/goals/completed_goal_chip.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:papyrus/models/reading_goal.dart'; import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/widgets/shared/bottom_sheet_handle.dart'; +import 'package:papyrus/themes/app_motion.dart'; /// Card displaying a completed goal with details. class CompletedGoalChip extends StatelessWidget { @@ -203,6 +204,7 @@ class CompletedGoalChip extends StatelessWidget { /// without needing a [CompletedGoalChip] instance. static void showDetailsSheet(BuildContext context, {required ReadingGoal goal, VoidCallback? onDelete}) { showModalBottomSheet( + sheetAnimationStyle: AppMotion.animationStyle(context), context: context, shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xl))), builder: (context) => _CompletedGoalDetailsSheet(goal: goal, onDelete: onDelete), @@ -401,6 +403,7 @@ class _CompletedGoalDetailsSheet extends StatelessWidget { final colorScheme = Theme.of(context).colorScheme; showDialog( + animationStyle: AppMotion.animationStyle(context), context: context, builder: (context) => AlertDialog( title: const Text('Delete goal?'), diff --git a/app/lib/widgets/goals/goal_card.dart b/app/lib/widgets/goals/goal_card.dart index ae48849..f24c931 100644 --- a/app/lib/widgets/goals/goal_card.dart +++ b/app/lib/widgets/goals/goal_card.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:papyrus/models/reading_goal.dart'; import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/widgets/shared/app_progress_indicator.dart'; /// Unified goal card widget displaying consistent linear progress. /// @@ -187,7 +188,7 @@ class GoalCard extends StatelessWidget { Widget _buildProgressBar(ColorScheme colorScheme) { return ClipRRect( borderRadius: BorderRadius.circular(AppRadius.sm), - child: LinearProgressIndicator( + child: AppLinearProgressIndicator( value: goal.progress.clamp(0.0, 1.0), minHeight: 8, backgroundColor: colorScheme.surfaceContainerHighest, diff --git a/app/lib/widgets/library/acquisition_confirmation_dialog.dart b/app/lib/widgets/library/acquisition_confirmation_dialog.dart index 800c3f9..d347a5a 100644 --- a/app/lib/widgets/library/acquisition_confirmation_dialog.dart +++ b/app/lib/widgets/library/acquisition_confirmation_dialog.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:papyrus/themes/app_motion.dart'; Future showAcquisitionConfirmationDialog({ required BuildContext context, @@ -7,6 +8,7 @@ Future showAcquisitionConfirmationDialog({ required String actionLabel, }) async { return await showDialog( + animationStyle: AppMotion.animationStyle(context), context: context, builder: (dialogContext) => AlertDialog( title: Text(title), diff --git a/app/lib/widgets/library/acquisition_job_sheets.dart b/app/lib/widgets/library/acquisition_job_sheets.dart index c909ac2..3fdd0b5 100644 --- a/app/lib/widgets/library/acquisition_job_sheets.dart +++ b/app/lib/widgets/library/acquisition_job_sheets.dart @@ -5,6 +5,8 @@ import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/widgets/library/acquisition_confirmation_dialog.dart'; import 'package:papyrus/widgets/library/acquisition_status_text.dart'; import 'package:papyrus/widgets/shared/bottom_sheet_handle.dart'; +import 'package:papyrus/themes/app_motion.dart'; +import 'package:papyrus/widgets/shared/app_progress_indicator.dart'; Future showAcquisitionJobDetailsSheet({ required BuildContext context, @@ -12,6 +14,7 @@ Future showAcquisitionJobDetailsSheet({ required AcquisitionJob job, }) async { final action = await showModalBottomSheet<_AcquisitionJobAction>( + sheetAnimationStyle: AppMotion.animationStyle(context), context: context, useRootNavigator: true, useSafeArea: true, @@ -102,7 +105,7 @@ Future _handleActionOutcome({ ScaffoldMessenger.of(context) ..hideCurrentSnackBar() - ..showSnackBar(SnackBar(content: Text(message))); + ..showSnackBar(snackBarAnimationStyle: AppMotion.animationStyle(context), SnackBar(content: Text(message))); final currentJob = provider.jobById(job.id); @@ -184,7 +187,7 @@ class _AcquisitionJobDetailsContent extends StatelessWidget { Text(acquisitionStatusLabel(job), style: textTheme.bodyMedium), if (job.progress case final progress?) ...[ const SizedBox(height: Spacing.md), - LinearProgressIndicator(value: progress), + AppLinearProgressIndicator(value: progress), ], if (job.downloadedBytes != null || job.totalBytes != null) ...[ const SizedBox(height: Spacing.sm), @@ -267,7 +270,7 @@ class _AcquisitionFileChoicesState extends State<_AcquisitionFileChoices> { if (snapshot.connectionState != ConnectionState.done) { return Row( children: [ - const SizedBox.square(dimension: 20, child: CircularProgressIndicator(strokeWidth: 2)), + const SizedBox.square(dimension: 20, child: AppCircularProgressIndicator(strokeWidth: 2)), const SizedBox(width: Spacing.sm), Text('Loading files…', style: textTheme.bodyMedium), ], diff --git a/app/lib/widgets/library/acquisition_placeholder_card.dart b/app/lib/widgets/library/acquisition_placeholder_card.dart index 8588280..4c47cd8 100644 --- a/app/lib/widgets/library/acquisition_placeholder_card.dart +++ b/app/lib/widgets/library/acquisition_placeholder_card.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:papyrus/acquisition/acquisition_models.dart'; import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/widgets/library/acquisition_status_text.dart'; +import 'package:papyrus/themes/app_motion.dart'; +import 'package:papyrus/widgets/shared/app_progress_indicator.dart'; class AcquisitionPlaceholderCard extends StatefulWidget { final AcquisitionJob job; @@ -91,8 +93,9 @@ class _AcquisitionPlaceholderCardState extends State top: Spacing.xs, right: Spacing.xs, child: AnimatedOpacity( + key: ValueKey(AppMotion.disabled(context)), opacity: _isHovered ? 1 : 0, - duration: const Duration(milliseconds: 150), + duration: AppMotion.duration(context, const Duration(milliseconds: 150)), child: _SelectionIconButton(selected: false, onTap: widget.onEnterSelectionMode), ), ), @@ -100,7 +103,7 @@ class _AcquisitionPlaceholderCardState extends State ), ), if (widget.job.progress case final progress?) - LinearProgressIndicator( + AppLinearProgressIndicator( value: progress, backgroundColor: colorScheme.surfaceContainerHighest, color: widget.job.requiresAttention ? colorScheme.error : colorScheme.primary, diff --git a/app/lib/widgets/library/acquisition_placeholder_list_item.dart b/app/lib/widgets/library/acquisition_placeholder_list_item.dart index b8cba95..735d64b 100644 --- a/app/lib/widgets/library/acquisition_placeholder_list_item.dart +++ b/app/lib/widgets/library/acquisition_placeholder_list_item.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:papyrus/acquisition/acquisition_models.dart'; import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/widgets/library/acquisition_status_text.dart'; +import 'package:papyrus/widgets/shared/app_progress_indicator.dart'; +import 'package:papyrus/widgets/shared/app_motion_control.dart'; class AcquisitionPlaceholderListItem extends StatelessWidget { final AcquisitionJob job; @@ -54,7 +56,11 @@ class AcquisitionPlaceholderListItem extends StatelessWidget { child: Row( children: [ if (isSelectionMode) ...[ - Checkbox(value: isSelected, onChanged: (_) => onSelectToggle?.call()), + AppMotionControl( + value: isSelected, + builder: (focusNode) => + Checkbox(focusNode: focusNode, value: isSelected, onChanged: (_) => onSelectToggle?.call()), + ), const SizedBox(width: Spacing.sm), ], SizedBox( @@ -107,7 +113,7 @@ class AcquisitionPlaceholderListItem extends StatelessWidget { ], if (job.progress case final progress?) ...[ const SizedBox(height: Spacing.xs), - LinearProgressIndicator( + AppLinearProgressIndicator( value: progress, backgroundColor: colorScheme.surfaceContainerHighest, color: job.requiresAttention ? colorScheme.error : colorScheme.primary, diff --git a/app/lib/widgets/library/book_card.dart b/app/lib/widgets/library/book_card.dart index b488abb..29679f4 100644 --- a/app/lib/widgets/library/book_card.dart +++ b/app/lib/widgets/library/book_card.dart @@ -8,6 +8,8 @@ import 'package:papyrus/utils/book_actions.dart'; import 'package:papyrus/widgets/book/private_book_cover.dart'; import 'package:papyrus/widgets/library/acquisition_status_text.dart'; import 'package:papyrus/widgets/library/book_account_status_badge.dart'; +import 'package:papyrus/themes/app_motion.dart'; +import 'package:papyrus/widgets/shared/app_progress_indicator.dart'; /// Responsive book card for grid display. /// - Mobile: 171×256 with 8px gap @@ -121,8 +123,9 @@ class _BookCardState extends State { top: Spacing.xs, right: Spacing.xs, child: AnimatedOpacity( + key: ValueKey(AppMotion.disabled(context)), opacity: _isHovered ? 1.0 : 0.0, - duration: const Duration(milliseconds: 150), + duration: AppMotion.duration(context, const Duration(milliseconds: 150)), child: Row( mainAxisSize: MainAxisSize.min, children: [ @@ -196,14 +199,14 @@ class _BookCardState extends State { // Progress bar if (widget.acquisitionJob case final job?) ...[ if (job.progress case final progress?) - LinearProgressIndicator( + AppLinearProgressIndicator( value: progress, backgroundColor: colorScheme.surfaceContainerHighest, color: job.requiresAttention ? colorScheme.error : colorScheme.primary, minHeight: 3, ), ] else if (widget.showProgress && widget.book.progress > 0) - LinearProgressIndicator( + AppLinearProgressIndicator( value: widget.book.progress, backgroundColor: colorScheme.surfaceContainerHighest, color: widget.book.readingStatus == LibraryReadingStatus.completed diff --git a/app/lib/widgets/library/book_list_item.dart b/app/lib/widgets/library/book_list_item.dart index d1362b5..5815ea1 100644 --- a/app/lib/widgets/library/book_list_item.dart +++ b/app/lib/widgets/library/book_list_item.dart @@ -8,6 +8,9 @@ import 'package:papyrus/utils/book_actions.dart'; import 'package:papyrus/widgets/book/private_book_cover.dart'; import 'package:papyrus/widgets/library/acquisition_status_text.dart'; import 'package:papyrus/widgets/library/book_account_status_badge.dart'; +import 'package:papyrus/themes/app_motion.dart'; +import 'package:papyrus/widgets/shared/app_progress_indicator.dart'; +import 'package:papyrus/widgets/shared/app_motion_control.dart'; /// List row for displaying a book with cover thumbnail, title, author, /// progress, format badge, and favorite indicator. @@ -101,7 +104,14 @@ class _BookListItemState extends State { children: [ // Selection checkbox (leading) if (inSelection) ...[ - Checkbox(value: isSelected, onChanged: (_) => onSelectionToggle?.call()), + AppMotionControl( + value: isSelected, + builder: (focusNode) => Checkbox( + focusNode: focusNode, + value: isSelected, + onChanged: (_) => onSelectionToggle?.call(), + ), + ), const SizedBox(width: Spacing.sm), ], // Cover thumbnail @@ -155,7 +165,7 @@ class _BookListItemState extends State { ], if (job.progress case final progress?) ...[ const SizedBox(height: Spacing.xs), - LinearProgressIndicator( + AppLinearProgressIndicator( value: progress, backgroundColor: colorScheme.surfaceContainerHighest, color: job.requiresAttention ? colorScheme.error : colorScheme.primary, @@ -167,7 +177,7 @@ class _BookListItemState extends State { Row( children: [ Expanded( - child: LinearProgressIndicator( + child: AppLinearProgressIndicator( value: widget.book.progress, backgroundColor: colorScheme.surfaceContainerHighest, color: widget.book.readingStatus == LibraryReadingStatus.completed @@ -226,8 +236,9 @@ class _BookListItemState extends State { // Overflow menu - show on hover (desktop only) if (_isDesktop && !isAcquisition) AnimatedOpacity( + key: ValueKey(AppMotion.disabled(context)), opacity: _isHovered ? 1.0 : 0.0, - duration: const Duration(milliseconds: 150), + duration: AppMotion.duration(context, const Duration(milliseconds: 150)), child: IconButton( icon: const Icon(Icons.more_vert), iconSize: IconSizes.action, diff --git a/app/lib/widgets/library/bulk_status_sheet.dart b/app/lib/widgets/library/bulk_status_sheet.dart index 6537d9d..c609681 100644 --- a/app/lib/widgets/library/bulk_status_sheet.dart +++ b/app/lib/widgets/library/bulk_status_sheet.dart @@ -3,6 +3,7 @@ import 'package:papyrus/providers/enums/library_reading_status.dart'; import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/utils/text_utils.dart'; import 'package:papyrus/widgets/shared/bottom_sheet_handle.dart'; +import 'package:papyrus/themes/app_motion.dart'; final statusTiles = [ (icon: Icons.auto_stories, status: LibraryReadingStatus.inProgress, title: "in progress"), @@ -24,6 +25,7 @@ class BulkStatusSheet extends StatelessWidget { required void Function(LibraryReadingStatus status) onStatusSelected, }) { return showModalBottomSheet( + sheetAnimationStyle: AppMotion.animationStyle(context), context: context, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.bottomSheet)), diff --git a/app/lib/widgets/library/library_advanced_filter_sheet.dart b/app/lib/widgets/library/library_advanced_filter_sheet.dart index 2c76415..8618dcd 100644 --- a/app/lib/widgets/library/library_advanced_filter_sheet.dart +++ b/app/lib/widgets/library/library_advanced_filter_sheet.dart @@ -8,6 +8,9 @@ import 'package:papyrus/providers/library_provider.dart'; import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/utils/book_language.dart'; import 'package:papyrus/widgets/shared/bottom_sheet_handle.dart'; +import 'package:papyrus/themes/app_motion.dart'; +import 'package:papyrus/widgets/shared/app_date_picker.dart'; +import 'package:papyrus/widgets/shared/app_motion_control.dart'; bool _isEinkTheme(ThemeData theme) { final border = theme.inputDecorationTheme.border; @@ -40,6 +43,7 @@ class LibraryAdvancedFilterSheet extends StatefulWidget { LibraryFilterOptions? filterOptions, }) { return showModalBottomSheet( + sheetAnimationStyle: AppMotion.animationStyle(context), context: context, useRootNavigator: true, useSafeArea: true, @@ -525,7 +529,7 @@ class _SearchableFacetState extends State<_SearchableFacet> { child: Column( children: [ SizedBox( - height: isEink ? ComponentSizes.inputHeightEink : TouchTargets.mobileRecommended, + height: TouchTargets.mobileRecommended, child: TextField( controller: _searchController, decoration: InputDecoration( @@ -538,9 +542,7 @@ class _SearchableFacetState extends State<_SearchableFacet> { filled: true, fillColor: isEink ? inputDecorationTheme.fillColor : colorScheme.surfaceContainerHighest, isDense: true, - contentPadding: isEink - ? inputDecorationTheme.contentPadding - : const EdgeInsets.symmetric(vertical: 12, horizontal: Spacing.md), + contentPadding: const EdgeInsets.symmetric(vertical: 12, horizontal: Spacing.md), border: isEink ? inputDecorationTheme.border : OutlineInputBorder( @@ -642,7 +644,11 @@ class _FacetOptionRow extends StatelessWidget { ExcludeFocus( child: ExcludeSemantics( child: IgnorePointer( - child: Checkbox(value: isSelected, onChanged: (_) => toggleSelection()), + child: AppMotionControl( + value: isSelected, + builder: (focusNode) => + Checkbox(focusNode: focusNode, value: isSelected, onChanged: (_) => toggleSelection()), + ), ), ), ), @@ -702,24 +708,29 @@ Widget _selectionChip( final colorScheme = theme.colorScheme; final isEink = _isEinkTheme(theme); - return FilterChip( - label: Text(label), - selected: isSelected, - showCheckmark: true, - checkmarkColor: colorScheme.onSecondaryContainer, - side: BorderSide( - color: isSelected ? Colors.transparent : colorScheme.outlineVariant, - width: isEink ? BorderWidths.einkDefault : BorderWidths.thin, - ), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(isEink ? AppRadius.none : AppRadius.md)), - backgroundColor: Colors.transparent, - selectedColor: colorScheme.secondaryContainer, - labelStyle: theme.textTheme.labelLarge?.copyWith( - color: isSelected ? colorScheme.onSecondaryContainer : colorScheme.onSurfaceVariant, + return AppMotionControl( + value: null, + builder: (focusNode) => FilterChip( + focusNode: focusNode, + chipAnimationStyle: appChipAnimationStyle(context), + label: Text(label), + selected: isSelected, + showCheckmark: true, + checkmarkColor: colorScheme.onSecondaryContainer, + side: BorderSide( + color: isSelected ? Colors.transparent : colorScheme.outlineVariant, + width: isEink ? BorderWidths.einkDefault : BorderWidths.thin, + ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(isEink ? AppRadius.none : AppRadius.md)), + backgroundColor: Colors.transparent, + selectedColor: colorScheme.secondaryContainer, + labelStyle: theme.textTheme.labelLarge?.copyWith( + color: isSelected ? colorScheme.onSecondaryContainer : colorScheme.onSurfaceVariant, + ), + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + onSelected: (_) => onSelected(), ), - visualDensity: VisualDensity.compact, - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - onSelected: (_) => onSelected(), ); } @@ -863,7 +874,11 @@ class _ProgressFilterFieldState extends State<_ProgressFilterField> { ExcludeFocus( child: ExcludeSemantics( child: IgnorePointer( - child: Switch(value: isEnabled, onChanged: (_) => toggleEnabled()), + child: AppMotionControl( + value: isEnabled, + builder: (focusNode) => + Switch(focusNode: focusNode, value: isEnabled, onChanged: (_) => toggleEnabled()), + ), ), ), ), @@ -957,7 +972,7 @@ class _DateRangeField extends StatelessWidget { Future _pickRange(BuildContext context) async { final now = DateTime.now(); - final selectedRange = await showDateRangePicker( + final selectedRange = await showAppDateRangePicker( context: context, firstDate: DateTime(1000), lastDate: DateTime(now.year + 10, 12, 31), diff --git a/app/lib/widgets/library/library_filter_chips.dart b/app/lib/widgets/library/library_filter_chips.dart index 446f9bc..6863e6e 100644 --- a/app/lib/widgets/library/library_filter_chips.dart +++ b/app/lib/widgets/library/library_filter_chips.dart @@ -10,6 +10,8 @@ import 'package:papyrus/providers/enums/library_view_mode.dart'; import 'package:papyrus/providers/library_provider.dart'; import 'package:papyrus/utils/book_language.dart'; import 'package:provider/provider.dart'; +import 'package:papyrus/themes/app_motion.dart'; +import 'package:papyrus/widgets/shared/app_motion_control.dart'; class _ChipEntry { final String id; @@ -54,22 +56,27 @@ class _DropdownFilterChip extends StatelessWidget { button: true, selected: isSelected, label: '$semanticLabel: $label', - child: ActionChip( - tooltip: tooltip, - avatar: Icon(icon, size: 18, color: foregroundColor), - label: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text(label), - const SizedBox(width: 2), - Icon(Icons.keyboard_arrow_down_rounded, size: 20, color: foregroundColor), - ], + child: AppMotionControl( + value: null, + builder: (focusNode) => ActionChip( + focusNode: focusNode, + chipAnimationStyle: appChipAnimationStyle(context), + tooltip: tooltip, + avatar: Icon(icon, size: 18, color: foregroundColor), + label: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(label), + const SizedBox(width: 2), + Icon(Icons.keyboard_arrow_down_rounded, size: 20, color: foregroundColor), + ], + ), + labelStyle: TextStyle(color: foregroundColor, fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal), + backgroundColor: isSelected ? colorScheme.secondaryContainer : colorScheme.surfaceContainerLow, + side: BorderSide(color: isSelected ? colorScheme.secondaryContainer : colorScheme.outlineVariant), + shape: const StadiumBorder(), + onPressed: onPressed, ), - labelStyle: TextStyle(color: foregroundColor, fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal), - backgroundColor: isSelected ? colorScheme.secondaryContainer : colorScheme.surfaceContainerLow, - side: BorderSide(color: isSelected ? colorScheme.secondaryContainer : colorScheme.outlineVariant), - shape: const StadiumBorder(), - onPressed: onPressed, ), ); } @@ -169,20 +176,24 @@ class _MultiSelectionSheetState extends State<_MultiSelectionSheet> { final option = visibleOptions[index]; final isSelected = _selectedValues.contains(option.value); - return CheckboxListTile( + return AppMotionControl( value: isSelected, - secondary: option.icon == null ? null : Icon(option.icon), - title: Text(option.label), - controlAffinity: ListTileControlAffinity.trailing, - onChanged: (_) { - setState(() { - if (isSelected) { - _selectedValues.remove(option.value); - } else { - _selectedValues.add(option.value); - } - }); - }, + builder: (focusNode) => CheckboxListTile( + focusNode: focusNode, + value: isSelected, + secondary: option.icon == null ? null : Icon(option.icon), + title: Text(option.label), + controlAffinity: ListTileControlAffinity.trailing, + onChanged: (_) { + setState(() { + if (isSelected) { + _selectedValues.remove(option.value); + } else { + _selectedValues.add(option.value); + } + }); + }, + ), ); }, ), @@ -234,6 +245,7 @@ Future _showSingleSelectionSheet( required T selectedValue, }) { return showModalBottomSheet( + sheetAnimationStyle: AppMotion.animationStyle(context), context: context, useSafeArea: true, useRootNavigator: true, @@ -251,6 +263,7 @@ Future?> _showMultiSelectionSheet( bool searchable = false, }) { return showModalBottomSheet>( + sheetAnimationStyle: AppMotion.animationStyle(context), context: context, useSafeArea: true, useRootNavigator: true, @@ -547,7 +560,8 @@ class LibraryFilterChips extends StatelessWidget { return SizedBox( height: 48, child: AnimatedSwitcher( - duration: const Duration(milliseconds: 200), + key: ValueKey(AppMotion.disabled(context)), + duration: AppMotion.duration(context, const Duration(milliseconds: 200)), switchInCurve: Curves.easeOutCubic, switchOutCurve: Curves.easeInCubic, transitionBuilder: (child, animation) { diff --git a/app/lib/widgets/library/online_results_view.dart b/app/lib/widgets/library/online_results_view.dart index e8bf113..cac208f 100644 --- a/app/lib/widgets/library/online_results_view.dart +++ b/app/lib/widgets/library/online_results_view.dart @@ -3,6 +3,7 @@ import 'package:papyrus/acquisition/acquisition_models.dart'; import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/widgets/library/remote_release_list.dart'; import 'package:papyrus/widgets/shared/empty_state.dart'; +import 'package:papyrus/widgets/shared/app_progress_indicator.dart'; class OnlineResultsView extends StatelessWidget { final bool hasSearched; @@ -35,7 +36,7 @@ class OnlineResultsView extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - const CircularProgressIndicator(), + const AppCircularProgressIndicator(), const SizedBox(height: Spacing.md), Text( query.trim().isEmpty diff --git a/app/lib/widgets/library/remote_release_list.dart b/app/lib/widgets/library/remote_release_list.dart index 22143af..fd6e365 100644 --- a/app/lib/widgets/library/remote_release_list.dart +++ b/app/lib/widgets/library/remote_release_list.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:papyrus/acquisition/acquisition_models.dart'; import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/widgets/shared/app_motion_control.dart'; class RemoteReleaseList extends StatelessWidget { final List releases; @@ -57,7 +58,14 @@ class RemoteReleaseList extends StatelessWidget { padding: const EdgeInsets.symmetric(vertical: Spacing.sm), child: Row( children: [ - Checkbox(value: selected, onChanged: (_) => onToggleSelection(release.releaseToken)), + AppMotionControl( + value: selected, + builder: (focusNode) => Checkbox( + focusNode: focusNode, + value: selected, + onChanged: (_) => onToggleSelection(release.releaseToken), + ), + ), const SizedBox(width: Spacing.sm), Expanded( child: Column( diff --git a/app/lib/widgets/settings/settings_row.dart b/app/lib/widgets/settings/settings_row.dart index cc1d152..7b5f4c9 100644 --- a/app/lib/widgets/settings/settings_row.dart +++ b/app/lib/widgets/settings/settings_row.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/widgets/shared/app_motion_control.dart'; /// A row widget for displaying a setting with optional value and navigation. /// @@ -138,7 +139,11 @@ class SettingsToggleRow extends StatelessWidget { child: Row( children: [ Expanded(child: Text(label, style: textTheme.bodyLarge)), - Switch(value: value, onChanged: onChanged), + AppMotionControl( + value: value, + enabled: onChanged != null, + builder: (focusNode) => Switch(focusNode: focusNode, value: value, onChanged: onChanged), + ), ], ), ); diff --git a/app/lib/widgets/shared/app_date_picker.dart b/app/lib/widgets/shared/app_date_picker.dart new file mode 100644 index 0000000..9c2de65 --- /dev/null +++ b/app/lib/widgets/shared/app_date_picker.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; +import 'package:papyrus/themes/app_motion.dart'; +import 'package:papyrus/widgets/shared/static_date_picker.dart'; + +/// Uses instant calendar controls as well as an instant route when motion is disabled. +Future showAppDatePicker({ + required BuildContext context, + required DateTime initialDate, + required DateTime firstDate, + required DateTime lastDate, +}) { + if (!AppMotion.disabled(context)) { + return showDatePicker(context: context, initialDate: initialDate, firstDate: firstDate, lastDate: lastDate); + } + return showDialog( + context: context, + animationStyle: AnimationStyle.noAnimation, + builder: (context) => StaticDatePicker(initialDate: initialDate, firstDate: firstDate, lastDate: lastDate), + ); +} + +Future showAppDateRangePicker({ + required BuildContext context, + DateTimeRange? initialDateRange, + required DateTime firstDate, + required DateTime lastDate, + bool useRootNavigator = true, + DatePickerEntryMode initialEntryMode = DatePickerEntryMode.calendar, + String? helpText, + String? cancelText, + String? confirmText, + String? saveText, + TransitionBuilder? builder, +}) { + if (!AppMotion.disabled(context)) { + return showDateRangePicker( + context: context, + initialDateRange: initialDateRange, + firstDate: firstDate, + lastDate: lastDate, + useRootNavigator: useRootNavigator, + initialEntryMode: initialEntryMode, + helpText: helpText, + cancelText: cancelText, + confirmText: confirmText, + saveText: saveText, + builder: builder, + ); + } + return showDialog( + context: context, + useRootNavigator: useRootNavigator, + useSafeArea: false, + animationStyle: AnimationStyle.noAnimation, + builder: (context) { + final dialog = StaticDatePicker( + selectRange: true, + initialRange: initialDateRange, + firstDate: firstDate, + lastDate: lastDate, + initialEntryMode: initialEntryMode, + helpText: helpText, + cancelText: cancelText, + confirmText: saveText ?? confirmText, + ); + return builder == null ? dialog : builder(context, dialog); + }, + ); +} diff --git a/app/lib/widgets/shared/app_drawer.dart b/app/lib/widgets/shared/app_drawer.dart new file mode 100644 index 0000000..ffd9a62 --- /dev/null +++ b/app/lib/widgets/shared/app_drawer.dart @@ -0,0 +1,19 @@ +import 'package:flutter/material.dart'; +import 'package:papyrus/themes/app_motion.dart'; + +/// Opens the same drawer without a sliding transition on e-ink displays. +void openAppDrawer(BuildContext context, ScaffoldState? scaffold) { + if (scaffold == null) return; + if (!AppMotion.disabled(context)) { + scaffold.openDrawer(); + return; + } + final drawer = scaffold.widget.drawer; + if (drawer == null) return; + showDialog( + context: context, + useSafeArea: false, + animationStyle: AnimationStyle.noAnimation, + builder: (_) => Align(alignment: AlignmentDirectional.centerStart, child: drawer), + ); +} diff --git a/app/lib/widgets/shared/app_motion_control.dart b/app/lib/widgets/shared/app_motion_control.dart new file mode 100644 index 0000000..f6cab10 --- /dev/null +++ b/app/lib/widgets/shared/app_motion_control.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; +import 'package:papyrus/themes/app_motion.dart'; + +/// Recreates only the built-in toggle's animation state when motion is disabled. +/// The focus node belongs to this wrapper so keyboard interaction can continue. +/// Pass a constant value for chips: their native duration overrides handle value +/// changes, while this wrapper refreshes those overrides when the theme changes. +class AppMotionControl extends StatefulWidget { + final Object? value; + final bool enabled; + final FocusNode? focusNode; + final Widget Function(FocusNode focusNode) builder; + + const AppMotionControl({super.key, required this.value, this.enabled = true, this.focusNode, required this.builder}); + + @override + State createState() => _AppMotionControlState(); +} + +class _AppMotionControlState extends State { + final FocusNode _ownedFocusNode = FocusNode(debugLabel: 'AppMotionControl'); + Key? _previousKey; + + @override + Widget build(BuildContext context) { + final disabled = AppMotion.disabled(context); + final focusNode = widget.focusNode ?? _ownedFocusNode; + final key = ValueKey((disabled, disabled ? widget.value : null, disabled ? widget.enabled : null)); + if (_previousKey != null && key != _previousKey && focusNode.hasFocus) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && focusNode.canRequestFocus) focusNode.requestFocus(); + }); + } + _previousKey = key; + final child = KeyedSubtree(key: key, child: widget.builder(focusNode)); + if (!disabled) return child; + return TickerMode( + enabled: false, + child: ListenableBuilder( + listenable: focusNode, + child: child, + builder: (context, child) => DecoratedBox( + position: DecorationPosition.foreground, + decoration: BoxDecoration( + border: focusNode.hasFocus ? Border.all(color: Theme.of(context).colorScheme.onSurface, width: 2) : null, + ), + child: child, + ), + ), + ); + } + + @override + void dispose() { + _ownedFocusNode.dispose(); + super.dispose(); + } +} + +final _instantChipAnimationStyle = ChipAnimationStyle( + enableAnimation: AnimationStyle.noAnimation, + selectAnimation: AnimationStyle.noAnimation, + avatarDrawerAnimation: AnimationStyle.noAnimation, + deleteDrawerAnimation: AnimationStyle.noAnimation, +); + +ChipAnimationStyle? appChipAnimationStyle(BuildContext context) => + AppMotion.disabled(context) ? _instantChipAnimationStyle : null; diff --git a/app/lib/widgets/shared/app_progress_indicator.dart b/app/lib/widgets/shared/app_progress_indicator.dart new file mode 100644 index 0000000..601b607 --- /dev/null +++ b/app/lib/widgets/shared/app_progress_indicator.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; +import 'package:papyrus/themes/app_motion.dart'; + +/// A loader whose indeterminate state stays static when motion is disabled. +class AppCircularProgressIndicator extends StatelessWidget { + final double? value; + final Color? color; + final Color? backgroundColor; + final double strokeWidth; + final String? semanticsLabel; + + const AppCircularProgressIndicator({ + super.key, + this.value, + this.color, + this.backgroundColor, + this.strokeWidth = 4, + this.semanticsLabel, + }); + + @override + Widget build(BuildContext context) => _MotionProgress( + builder: (controller) => CircularProgressIndicator( + value: value, + color: color, + backgroundColor: backgroundColor, + strokeWidth: strokeWidth, + semanticsLabel: semanticsLabel, + controller: value == null ? controller : null, + ), + ); +} + +class AppLinearProgressIndicator extends StatelessWidget { + final double? value; + final Color? color; + final Color? backgroundColor; + final double? minHeight; + final BorderRadiusGeometry? borderRadius; + final String? semanticsLabel; + + const AppLinearProgressIndicator({ + super.key, + this.value, + this.color, + this.backgroundColor, + this.minHeight, + this.borderRadius, + this.semanticsLabel, + }); + + @override + Widget build(BuildContext context) => _MotionProgress( + builder: (controller) => LinearProgressIndicator( + value: value, + color: color, + backgroundColor: backgroundColor, + minHeight: minHeight, + borderRadius: borderRadius, + semanticsLabel: semanticsLabel, + controller: value == null ? controller : null, + ), + ); +} + +class _MotionProgress extends StatefulWidget { + final Widget Function(AnimationController? controller) builder; + const _MotionProgress({required this.builder}); + + @override + State<_MotionProgress> createState() => _MotionProgressState(); +} + +class _MotionProgressState extends State<_MotionProgress> with SingleTickerProviderStateMixin { + late final AnimationController _staticPhase; + + @override + void initState() { + super.initState(); + _staticPhase = AnimationController(vsync: this, value: 0.5); + } + + @override + void dispose() { + _staticPhase.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final disabled = AppMotion.disabled(context); + return TickerMode(enabled: !disabled, child: widget.builder(disabled ? _staticPhase : null)); + } +} diff --git a/app/lib/widgets/shared/persistent_save.dart b/app/lib/widgets/shared/persistent_save.dart index f12ccad..dc9c638 100644 --- a/app/lib/widgets/shared/persistent_save.dart +++ b/app/lib/widgets/shared/persistent_save.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:papyrus/themes/app_motion.dart'; /// Keeps editors open until their local write succeeds. mixin PersistentSave on State { @@ -14,9 +15,10 @@ mixin PersistentSave on State { return mounted; } catch (_) { if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('Could not save changes. Please try again.'))); + ScaffoldMessenger.of(context).showSnackBar( + snackBarAnimationStyle: AppMotion.animationStyle(context), + const SnackBar(content: Text('Could not save changes. Please try again.')), + ); } return false; } finally { diff --git a/app/lib/widgets/shared/static_date_picker.dart b/app/lib/widgets/shared/static_date_picker.dart new file mode 100644 index 0000000..c2ff7f4 --- /dev/null +++ b/app/lib/widgets/shared/static_date_picker.dart @@ -0,0 +1,299 @@ +import 'package:flutter/material.dart'; + +/// Calendar controls for displays where even the SDK's internal page and +/// year-selection transitions must be instant. Selection remains local until Save. +class StaticDatePicker extends StatefulWidget { + final DateTime firstDate; + final DateTime lastDate; + final DateTime? initialDate; + final DateTimeRange? initialRange; + final bool selectRange; + final DatePickerEntryMode initialEntryMode; + final String? helpText; + final String? cancelText; + final String? confirmText; + + const StaticDatePicker({ + super.key, + required this.firstDate, + required this.lastDate, + this.initialDate, + this.initialRange, + this.selectRange = false, + this.initialEntryMode = DatePickerEntryMode.calendar, + this.helpText, + this.cancelText, + this.confirmText, + }); + + @override + State createState() => _StaticDatePickerState(); +} + +class _StaticDatePickerState extends State { + final _formKey = GlobalKey(); + DateTime? _start; + DateTime? _end; + late DateTime _month; + late bool _input; + bool _years = false; + + DateTime get _first => DateUtils.dateOnly(widget.firstDate); + DateTime get _last => DateUtils.dateOnly(widget.lastDate); + + @override + void initState() { + super.initState(); + final initialStart = widget.initialRange?.start ?? widget.initialDate; + _start = initialStart == null ? null : DateUtils.dateOnly(initialStart); + _end = widget.initialRange == null ? null : DateUtils.dateOnly(widget.initialRange!.end); + var displayed = _start ?? DateTime.now(); + if (displayed.isBefore(_first)) displayed = _first; + if (displayed.isAfter(_last)) displayed = _last; + _month = DateTime(displayed.year, displayed.month); + _input = + widget.initialEntryMode == DatePickerEntryMode.input || + widget.initialEntryMode == DatePickerEntryMode.inputOnly; + } + + void _select(DateTime date) => setState(() { + if (!widget.selectRange || _start == null || _end != null || date.isBefore(_start!)) { + _start = date; + _end = null; + } else { + _end = date; + } + }); + + void _save() { + if (_input) { + _formKey.currentState!.save(); + if (!_formKey.currentState!.validate()) return; + } + if (_start == null || (widget.selectRange && (_end == null || _end!.isBefore(_start!)))) return; + Navigator.of(context).pop(widget.selectRange ? DateTimeRange(start: _start!, end: _end!) : _start); + } + + void _toggleEntryMode() { + if (_input) { + _formKey.currentState!.save(); + if (!_formKey.currentState!.validate()) return; + } + setState(() { + _input = !_input; + if (_start != null) _month = DateTime(_start!.year, _start!.month); + }); + } + + @override + Widget build(BuildContext context) { + final labels = MaterialLocalizations.of(context); + final allowModeSwitch = + widget.initialEntryMode != DatePickerEntryMode.calendarOnly && + widget.initialEntryMode != DatePickerEntryMode.inputOnly; + final content = Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(24, 16, 16, 8), + child: Row( + children: [ + Expanded( + child: Text( + widget.helpText ?? (widget.selectRange ? labels.dateRangePickerHelpText : labels.datePickerHelpText), + ), + ), + if (allowModeSwitch) + IconButton( + tooltip: _input ? labels.calendarModeButtonLabel : labels.inputDateModeButtonLabel, + icon: Icon(_input ? Icons.calendar_today : Icons.edit_outlined), + onPressed: _toggleEntryMode, + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Text( + widget.selectRange + ? '${_start == null ? labels.dateRangeStartLabel : labels.formatMediumDate(_start!)} – ' + '${_end == null ? labels.dateRangeEndLabel : labels.formatMediumDate(_end!)}' + : _start == null + ? '' + : labels.formatMediumDate(_start!), + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + const Divider(), + Flexible(child: SingleChildScrollView(child: _input ? _buildInput(labels) : _buildCalendar(labels))), + Padding( + padding: const EdgeInsets.all(8), + child: OverflowBar( + alignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(widget.cancelText ?? labels.cancelButtonLabel), + ), + TextButton(onPressed: _save, child: Text(widget.confirmText ?? labels.okButtonLabel)), + ], + ), + ), + ], + ); + if (widget.selectRange) { + return Dialog.fullscreen( + child: SafeArea( + child: Align( + alignment: Alignment.topCenter, + child: ConstrainedBox(constraints: const BoxConstraints(maxWidth: 400), child: content), + ), + ), + ); + } + return Dialog(child: SizedBox(width: 328, child: content)); + } + + Widget _buildInput(MaterialLocalizations labels) => Padding( + padding: const EdgeInsets.all(24), + child: Form( + key: _formKey, + child: Column( + children: [ + InputDatePickerFormField( + initialDate: _start, + firstDate: _first, + lastDate: _last, + fieldLabelText: widget.selectRange ? labels.dateRangeStartLabel : null, + onDateSaved: (value) => _start = value, + ), + if (widget.selectRange) ...[ + const SizedBox(height: 16), + InputDatePickerFormField( + initialDate: _end, + firstDate: _first, + lastDate: _last, + fieldLabelText: labels.dateRangeEndLabel, + selectableDayPredicate: (day) => _start == null || !day.isBefore(_start!), + onDateSaved: (value) => _end = value, + ), + ], + ], + ), + ), + ); + + Widget _buildCalendar(MaterialLocalizations labels) { + final previous = DateTime(_month.year, _month.month - 1); + final next = DateTime(_month.year, _month.month + 1); + return Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row( + children: [ + Expanded( + child: TextButton( + onPressed: () => setState(() => _years = !_years), + child: Text(labels.formatMonthYear(_month)), + ), + ), + IconButton( + tooltip: labels.previousMonthTooltip, + onPressed: DateTime(_month.year, _month.month).isAfter(DateTime(_first.year, _first.month)) + ? () => setState(() { + _month = previous; + _years = false; + }) + : null, + icon: const BackButtonIcon(), + ), + IconButton( + tooltip: labels.nextMonthTooltip, + onPressed: next.isAfter(_last) + ? null + : () => setState(() { + _month = next; + _years = false; + }), + icon: const Icon(Icons.chevron_right), + ), + ], + ), + ), + if (_years) + SizedBox( + height: 280, + child: YearPicker( + firstDate: _first, + lastDate: _last, + selectedDate: _month, + onChanged: (date) => setState(() { + var month = DateTime(date.year, _month.month); + if (month.isBefore(DateTime(_first.year, _first.month))) month = DateTime(_first.year, _first.month); + if (month.isAfter(_last)) month = DateTime(_last.year, _last.month); + _month = month; + _years = false; + }), + ), + ) + else + _buildDays(labels), + ], + ); + } + + Widget _buildDays(MaterialLocalizations labels) { + final offset = (DateTime(_month.year, _month.month).weekday % 7 - labels.firstDayOfWeekIndex + 7) % 7; + final count = DateUtils.getDaysInMonth(_month.year, _month.month); + final colors = Theme.of(context).colorScheme; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Table( + children: [ + TableRow( + children: List.generate( + 7, + (i) => SizedBox( + height: 36, + child: Center(child: Text(labels.narrowWeekdays[(i + labels.firstDayOfWeekIndex) % 7])), + ), + ), + ), + for (var row = 0; row < 6; row++) + TableRow( + children: List.generate(7, (column) { + final day = row * 7 + column - offset + 1; + if (day < 1 || day > count) return const SizedBox(height: 40); + final date = DateTime(_month.year, _month.month, day); + final selected = DateUtils.isSameDay(date, _start) || DateUtils.isSameDay(date, _end); + final inRange = _start != null && _end != null && date.isAfter(_start!) && date.isBefore(_end!); + return SizedBox( + height: 40, + child: TextButton( + style: TextButton.styleFrom( + minimumSize: Size.zero, + padding: EdgeInsets.zero, + backgroundColor: selected + ? colors.primary + : inRange + ? colors.primaryContainer + : null, + foregroundColor: selected + ? colors.onPrimary + : inRange + ? colors.onPrimaryContainer + : colors.onSurface, + ), + onPressed: date.isBefore(_first) || date.isAfter(_last) ? null : () => _select(date), + child: Text(labels.formatDecimal(day)), + ), + ); + }), + ), + ], + ), + ); + } +} diff --git a/app/lib/widgets/shell/adaptive_app_shell.dart b/app/lib/widgets/shell/adaptive_app_shell.dart index ac7060c..cd9b15b 100644 --- a/app/lib/widgets/shell/adaptive_app_shell.dart +++ b/app/lib/widgets/shell/adaptive_app_shell.dart @@ -1,10 +1,9 @@ +import 'package:papyrus/themes/app_motion.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:papyrus/data/data_store.dart'; -import 'package:papyrus/providers/preferences_provider.dart'; import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/widgets/shell/desktop_sidebar.dart'; -import 'package:papyrus/widgets/shell/eink_bottom_nav.dart'; import 'package:papyrus/widgets/shell/mobile_bottom_nav.dart'; import 'package:provider/provider.dart'; @@ -27,10 +26,9 @@ class AppShellNavItem { }); } -/// Main app shell that adapts to platform and display mode. +/// Main app shell that adapts to available screen width. /// - Desktop: Permanent collapsible sidebar /// - Mobile: Bottom navigation bar -/// - E-ink: Text-based bottom navigation class AdaptiveAppShell extends StatelessWidget { final Widget child; @@ -105,17 +103,11 @@ class AdaptiveAppShell extends StatelessWidget { @override Widget build(BuildContext context) { - final prefs = context.watch(); final dataStore = context.watch(); final navItems = buildNavItems(dataStore); final screenWidth = MediaQuery.of(context).size.width; final isDesktop = screenWidth >= Breakpoints.desktopSmall; - // E-ink mode uses special text-based navigation - if (prefs.isEinkMode) { - return _buildEinkShell(context, navItems); - } - // Desktop uses sidebar, mobile uses bottom nav if (isDesktop) { return _buildDesktopShell(context, navItems); @@ -150,21 +142,11 @@ class AdaptiveAppShell extends StatelessWidget { currentPath: currentPath, onNavigate: (path) => context.go(path), ), + drawerEnableOpenDragGesture: !AppMotion.disabled(context), drawer: isInLibrary ? _buildLibraryDrawer(context, navItems) : null, ); } - Widget _buildEinkShell(BuildContext context, List navItems) { - return Scaffold( - body: child, - bottomNavigationBar: EinkBottomNav( - items: navItems, - currentPath: GoRouterState.of(context).uri.toString(), - onNavigate: (path) => context.go(path), - ), - ); - } - Widget _buildLibraryDrawer(BuildContext context, List navItems) { final currentPath = GoRouterState.of(context).uri.toString(); final libraryItem = navItems.firstWhere((item) => item.path == '/library'); diff --git a/app/lib/widgets/shell/desktop_sidebar.dart b/app/lib/widgets/shell/desktop_sidebar.dart index a4a0272..3f24176 100644 --- a/app/lib/widgets/shell/desktop_sidebar.dart +++ b/app/lib/widgets/shell/desktop_sidebar.dart @@ -172,6 +172,7 @@ class DesktopSidebar extends StatelessWidget { final sidebarProvider = context.watch(); final isExpanded = item.path == '/library' && sidebarProvider.isLibraryExpanded; final isSelected = isNavItemSelected(currentPath, item); + final hasSelectedBackground = isSelected && !isExpanded; return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -193,16 +194,14 @@ class DesktopSidebar extends StatelessWidget { height: 48, padding: const EdgeInsets.symmetric(horizontal: Spacing.md), decoration: BoxDecoration( - color: isSelected && !isExpanded - ? Theme.of(context).colorScheme.primaryContainer - : Colors.transparent, + color: hasSelectedBackground ? Theme.of(context).colorScheme.primaryContainer : Colors.transparent, borderRadius: BorderRadius.circular(AppRadius.md), ), child: Row( children: [ Icon( isSelected ? item.selectedIcon ?? item.icon : item.icon, - color: isSelected + color: hasSelectedBackground ? Theme.of(context).colorScheme.onPrimaryContainer : Theme.of(context).colorScheme.onSurfaceVariant, size: IconSizes.navigation, @@ -212,7 +211,7 @@ class DesktopSidebar extends StatelessWidget { child: Text( item.label, style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: isSelected + color: hasSelectedBackground ? Theme.of(context).colorScheme.onPrimaryContainer : Theme.of(context).colorScheme.onSurfaceVariant, fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, @@ -276,16 +275,20 @@ class DesktopSidebar extends StatelessWidget { Expanded( child: Row( children: [ - Text( - item.label, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: isSelected - ? Theme.of(context).colorScheme.onPrimaryContainer - : Theme.of(context).colorScheme.onSurfaceVariant, - fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, + Expanded( + child: Text( + item.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: isSelected + ? Theme.of(context).colorScheme.onPrimaryContainer + : Theme.of(context).colorScheme.onSurfaceVariant, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, + ), ), ), - Spacer(), + const SizedBox(width: Spacing.xs), if (item.count != null) Container( diff --git a/app/lib/widgets/shell/eink_bottom_nav.dart b/app/lib/widgets/shell/eink_bottom_nav.dart deleted file mode 100644 index efebcbc..0000000 --- a/app/lib/widgets/shell/eink_bottom_nav.dart +++ /dev/null @@ -1,72 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:papyrus/themes/design_tokens.dart'; -import 'package:papyrus/utils/navigation_utils.dart'; -import 'package:papyrus/widgets/shell/adaptive_app_shell.dart'; - -/// E-ink optimized bottom navigation with text labels and high contrast. -/// Uses larger touch targets and no animations. -class EinkBottomNav extends StatelessWidget { - final List items; - final String currentPath; - final void Function(String path) onNavigate; - - const EinkBottomNav({super.key, required this.items, required this.currentPath, required this.onNavigate}); - - @override - Widget build(BuildContext context) { - // Only show main nav items - final mainItems = items.where((item) => !isChildPath(item.path)).toList(); - final colorScheme = Theme.of(context).colorScheme; - - return Container( - height: TouchTargets.einkRecommended, - decoration: BoxDecoration( - color: colorScheme.surface, - border: Border( - top: BorderSide(color: colorScheme.outline, width: BorderWidths.einkDefault), - ), - ), - child: Row( - children: mainItems.map((item) { - final isSelected = isNavItemSelected(currentPath, item); - return Expanded(child: _buildNavItem(context, item, isSelected)); - }).toList(), - ), - ); - } - - Widget _buildNavItem(BuildContext context, AppShellNavItem item, bool isSelected) { - final colorScheme = Theme.of(context).colorScheme; - - return Material( - color: Colors.transparent, - child: InkWell( - onTap: () { - // For library, navigate to books subpage - if (item.path == '/library') { - onNavigate('/library/books'); - } else { - onNavigate(item.path); - } - }, - child: Container( - decoration: BoxDecoration( - color: isSelected ? colorScheme.primary : Colors.transparent, - border: Border(left: BorderSide(color: colorScheme.outline, width: 1)), - ), - child: Center( - child: Text( - item.label, - style: Theme.of(context).textTheme.labelLarge?.copyWith( - color: isSelected ? colorScheme.onPrimary : colorScheme.onSurface, - fontWeight: FontWeight.bold, - letterSpacing: 1.2, - ), - textAlign: TextAlign.center, - ), - ), - ), - ), - ); - } -} diff --git a/app/lib/widgets/shell/mobile_bottom_nav.dart b/app/lib/widgets/shell/mobile_bottom_nav.dart index 1fe56ec..d675985 100644 --- a/app/lib/widgets/shell/mobile_bottom_nav.dart +++ b/app/lib/widgets/shell/mobile_bottom_nav.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:papyrus/themes/app_motion.dart'; import 'package:papyrus/utils/navigation_utils.dart'; import 'package:papyrus/widgets/shell/adaptive_app_shell.dart'; @@ -17,6 +18,7 @@ class MobileBottomNav extends StatelessWidget { final currentIndex = _getCurrentIndex(mainItems); return NavigationBar( + animationDuration: AppMotion.duration(context, const Duration(milliseconds: 500)), selectedIndex: currentIndex, onDestinationSelected: (index) { final item = mainItems[index]; diff --git a/app/lib/widgets/shelves/add_shelf_sheet.dart b/app/lib/widgets/shelves/add_shelf_sheet.dart index e521fe7..c551ccd 100644 --- a/app/lib/widgets/shelves/add_shelf_sheet.dart +++ b/app/lib/widgets/shelves/add_shelf_sheet.dart @@ -6,6 +6,7 @@ import 'package:papyrus/models/shelf.dart'; import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/utils/color_utils.dart'; import 'package:papyrus/widgets/shared/bottom_sheet_handle.dart'; +import 'package:papyrus/themes/app_motion.dart'; /// Bottom sheet for creating or editing a shelf. class AddShelfSheet extends StatefulWidget { @@ -24,6 +25,7 @@ class AddShelfSheet extends StatefulWidget { FutureOr Function(String name, String? description, String? colorHex, IconData? icon)? onSave, }) { return showModalBottomSheet( + sheetAnimationStyle: AppMotion.animationStyle(context), context: context, isScrollControlled: true, useRootNavigator: true, diff --git a/app/lib/widgets/shelves/move_to_shelf_sheet.dart b/app/lib/widgets/shelves/move_to_shelf_sheet.dart index 55fdc89..0485c83 100644 --- a/app/lib/widgets/shelves/move_to_shelf_sheet.dart +++ b/app/lib/widgets/shelves/move_to_shelf_sheet.dart @@ -15,6 +15,8 @@ import 'package:papyrus/widgets/shared/bottom_sheet_handle.dart'; import 'package:papyrus/widgets/shared/empty_state.dart'; import 'package:papyrus/widgets/shelves/add_shelf_sheet.dart'; import 'package:provider/provider.dart'; +import 'package:papyrus/themes/app_motion.dart'; +import 'package:papyrus/widgets/shared/app_motion_control.dart'; /// Bottom sheet for moving a book (or multiple books) to one or more shelves. class MoveToShelfSheet extends StatefulWidget { @@ -38,6 +40,7 @@ class MoveToShelfSheet extends StatefulWidget { FutureOr Function(List shelfIds)? onSave, }) { return showModalBottomSheet( + sheetAnimationStyle: AppMotion.animationStyle(context), context: context, useRootNavigator: true, isScrollControlled: true, @@ -53,6 +56,7 @@ class MoveToShelfSheet extends StatefulWidget { FutureOr Function(List shelfIds)? onSave, }) { return showModalBottomSheet( + sheetAnimationStyle: AppMotion.animationStyle(context), context: context, isScrollControlled: true, shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xl))), @@ -283,7 +287,15 @@ class _MoveToShelfSheetState extends State with PersistentSave ), ), // Checkbox - Checkbox(value: isSelected, onChanged: (_) => _toggleShelf(shelf.id), activeColor: shelfColor), + AppMotionControl( + value: isSelected, + builder: (focusNode) => Checkbox( + focusNode: focusNode, + value: isSelected, + onChanged: (_) => _toggleShelf(shelf.id), + activeColor: shelfColor, + ), + ), ], ), ), diff --git a/app/lib/widgets/shelves/shelf_card.dart b/app/lib/widgets/shelves/shelf_card.dart index 21bdc8b..6784df5 100644 --- a/app/lib/widgets/shelves/shelf_card.dart +++ b/app/lib/widgets/shelves/shelf_card.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:papyrus/models/shelf.dart' show CoverPreview, ShelfData; import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/widgets/book/private_book_cover.dart'; +import 'package:papyrus/themes/app_motion.dart'; /// Card widget for displaying a shelf in grid or list view. /// @@ -79,8 +80,9 @@ class _ShelfCardState extends State { top: Spacing.xs, right: Spacing.xs, child: AnimatedOpacity( + key: ValueKey(AppMotion.disabled(context)), opacity: _isHovered ? 1.0 : 0.0, - duration: const Duration(milliseconds: 150), + duration: AppMotion.duration(context, const Duration(milliseconds: 150)), child: _buildMoreButton(context), ), ), @@ -333,8 +335,9 @@ class _ShelfCardState extends State { // More button (desktop hover only) if (_isDesktop && widget.onMoreTap != null) AnimatedOpacity( + key: ValueKey(AppMotion.disabled(context)), opacity: _isHovered ? 1.0 : 0.0, - duration: const Duration(milliseconds: 150), + duration: AppMotion.duration(context, const Duration(milliseconds: 150)), child: IconButton( icon: const Icon(Icons.more_vert), iconSize: IconSizes.action, diff --git a/app/lib/widgets/shelves/shelves_filter_chips.dart b/app/lib/widgets/shelves/shelves_filter_chips.dart index bfa0875..c037049 100644 --- a/app/lib/widgets/shelves/shelves_filter_chips.dart +++ b/app/lib/widgets/shelves/shelves_filter_chips.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:papyrus/providers/shelves_provider.dart'; import 'package:papyrus/themes/design_tokens.dart'; import 'package:provider/provider.dart'; +import 'package:papyrus/themes/app_motion.dart'; +import 'package:papyrus/widgets/shared/app_motion_control.dart'; typedef _ShelfSortSelection = ({ShelfSortOption option, bool ascending}); @@ -58,27 +60,34 @@ class _DropdownFilterChip extends StatelessWidget { selected: isSelected, label: '$semanticLabel: $label', child: ConstrainedBox( - constraints: BoxConstraints(minHeight: isEink ? TouchTargets.einkMin : 0), - child: ActionChip( - tooltip: tooltip, - avatar: Icon(icon, size: 18, color: foregroundColor), - label: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text(label), - const SizedBox(width: 2), - Icon(Icons.keyboard_arrow_down_rounded, size: 20, color: foregroundColor), - ], + constraints: const BoxConstraints(minHeight: 0), + child: AppMotionControl( + value: null, + builder: (focusNode) => ActionChip( + focusNode: focusNode, + chipAnimationStyle: appChipAnimationStyle(context), + tooltip: tooltip, + avatar: Icon(icon, size: 18, color: foregroundColor), + label: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(label), + const SizedBox(width: 2), + Icon(Icons.keyboard_arrow_down_rounded, size: 20, color: foregroundColor), + ], + ), + labelStyle: TextStyle(color: foregroundColor, fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal), + backgroundColor: isSelected ? colorScheme.secondaryContainer : colorScheme.surfaceContainerLow, + side: BorderSide( + color: isSelected ? Colors.transparent : colorScheme.outlineVariant, + width: isEink ? BorderWidths.einkDefault : BorderWidths.thin, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(isEink ? AppRadius.none : AppRadius.full), + ), + visualDensity: VisualDensity.compact, + onPressed: onPressed, ), - labelStyle: TextStyle(color: foregroundColor, fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal), - backgroundColor: isSelected ? colorScheme.secondaryContainer : colorScheme.surfaceContainerLow, - side: BorderSide( - color: isSelected ? Colors.transparent : colorScheme.outlineVariant, - width: isEink ? BorderWidths.einkDefault : BorderWidths.thin, - ), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(isEink ? AppRadius.none : AppRadius.full)), - visualDensity: isEink ? VisualDensity.standard : VisualDensity.compact, - onPressed: onPressed, ), ), ); @@ -139,6 +148,7 @@ Future _showSingleSelectionSheet( required T selectedValue, }) { return showModalBottomSheet( + sheetAnimationStyle: AppMotion.animationStyle(context), context: context, useSafeArea: true, useRootNavigator: true, @@ -279,9 +289,10 @@ class ShelvesFilterChips extends StatelessWidget { final orderKey = [...chips.map((chip) => chip.id), if (provider.hasActiveShelfControls) 'clear-all'].join('-'); return SizedBox( - height: isEink ? TouchTargets.einkMin : TouchTargets.mobileRecommended, + height: TouchTargets.mobileRecommended, child: AnimatedSwitcher( - duration: isEink ? AnimationDurations.eink : AnimationDurations.standard, + key: ValueKey(AppMotion.disabled(context)), + duration: AppMotion.duration(context, AnimationDurations.standard), switchInCurve: Curves.easeOutCubic, switchOutCurve: Curves.easeInCubic, transitionBuilder: (child, animation) { @@ -313,9 +324,9 @@ class ShelvesFilterChips extends StatelessWidget { onPressed: provider.clearShelfControls, style: TextButton.styleFrom( padding: const EdgeInsets.symmetric(horizontal: 12), - minimumSize: Size(0, isEink ? TouchTargets.einkMin : TouchTargets.mobileMin), + minimumSize: const Size(0, TouchTargets.mobileMin), tapTargetSize: MaterialTapTargetSize.shrinkWrap, - visualDensity: isEink ? VisualDensity.standard : VisualDensity.compact, + visualDensity: VisualDensity.compact, shape: isEink ? const RoundedRectangleBorder(borderRadius: BorderRadius.zero) : null, ), child: const Text('Clear all'), diff --git a/app/lib/widgets/statistics/reading_charts.dart b/app/lib/widgets/statistics/reading_charts.dart index fb9be03..8b93af3 100644 --- a/app/lib/widgets/statistics/reading_charts.dart +++ b/app/lib/widgets/statistics/reading_charts.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:papyrus/models/daily_activity.dart'; import 'package:papyrus/providers/statistics_provider.dart'; import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/themes/app_motion.dart'; // ============================================================================= // DATA AGGREGATION @@ -226,6 +227,7 @@ class ReadingTimeBarChart extends StatelessWidget { final barWidth = (availableWidth / activities.length * 0.6).clamp(4.0, isDesktop ? 20.0 : 16.0); return BarChart( + key: ValueKey(AppMotion.disabled(context)), BarChartData( alignment: BarChartAlignment.spaceAround, maxY: yAxis.maxY, @@ -288,7 +290,7 @@ class ReadingTimeBarChart extends StatelessWidget { ); }).toList(), ), - duration: AnimationDurations.standard, + duration: AppMotion.duration(context, AnimationDurations.standard), ); }, ), @@ -310,6 +312,7 @@ class ReadingTimeBarChart extends StatelessWidget { final barWidth = (availableWidth / buckets.length * 0.6).clamp(6.0, isDesktop ? 24.0 : 18.0); return BarChart( + key: ValueKey(AppMotion.disabled(context)), BarChartData( alignment: BarChartAlignment.spaceAround, maxY: yAxis.maxY, @@ -374,7 +377,7 @@ class ReadingTimeBarChart extends StatelessWidget { ); }).toList(), ), - duration: AnimationDurations.standard, + duration: AppMotion.duration(context, AnimationDurations.standard), ); }, ), @@ -435,6 +438,7 @@ class PagesReadLineChart extends StatelessWidget { return SizedBox( height: chartHeight, child: LineChart( + key: ValueKey(AppMotion.disabled(context)), LineChartData( minY: 0, maxY: yAxis.maxY, @@ -506,7 +510,7 @@ class PagesReadLineChart extends StatelessWidget { ), ], ), - duration: AnimationDurations.standard, + duration: AppMotion.duration(context, AnimationDurations.standard), ), ); } @@ -527,6 +531,7 @@ class PagesReadLineChart extends StatelessWidget { return SizedBox( height: chartHeight, child: LineChart( + key: ValueKey(AppMotion.disabled(context)), LineChartData( minY: 0, maxY: yAxis.maxY, @@ -598,7 +603,7 @@ class PagesReadLineChart extends StatelessWidget { ), ], ), - duration: AnimationDurations.standard, + duration: AppMotion.duration(context, AnimationDurations.standard), ), ); } @@ -649,6 +654,7 @@ class BooksPerMonthChart extends StatelessWidget { final barWidth = (availableWidth / displayStats.length * 0.5).clamp(8.0, isDesktop ? 28.0 : 22.0); return BarChart( + key: ValueKey(AppMotion.disabled(context)), BarChartData( alignment: BarChartAlignment.spaceAround, maxY: maxBooks > 0 ? maxBooks * 1.2 : 5, @@ -737,7 +743,7 @@ class BooksPerMonthChart extends StatelessWidget { ); }).toList(), ), - duration: AnimationDurations.standard, + duration: AppMotion.duration(context, AnimationDurations.standard), ); }, ), diff --git a/app/lib/widgets/topics/add_topic_sheet.dart b/app/lib/widgets/topics/add_topic_sheet.dart index 6b051be..8c8c4a9 100644 --- a/app/lib/widgets/topics/add_topic_sheet.dart +++ b/app/lib/widgets/topics/add_topic_sheet.dart @@ -6,6 +6,7 @@ import 'package:papyrus/models/tag.dart'; import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/utils/color_utils.dart'; import 'package:papyrus/widgets/shared/bottom_sheet_handle.dart'; +import 'package:papyrus/themes/app_motion.dart'; /// Bottom sheet for creating or editing a topic. class AddTopicSheet extends StatefulWidget { @@ -24,6 +25,7 @@ class AddTopicSheet extends StatefulWidget { FutureOr Function(String name, String? description, String colorHex)? onSave, }) { return showModalBottomSheet( + sheetAnimationStyle: AppMotion.animationStyle(context), context: context, isScrollControlled: true, shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xl))), diff --git a/app/lib/widgets/topics/manage_topics_sheet.dart b/app/lib/widgets/topics/manage_topics_sheet.dart index 29eb0a9..2dea978 100644 --- a/app/lib/widgets/topics/manage_topics_sheet.dart +++ b/app/lib/widgets/topics/manage_topics_sheet.dart @@ -15,6 +15,8 @@ import 'package:papyrus/widgets/shared/bottom_sheet_handle.dart'; import 'package:papyrus/widgets/shared/empty_state.dart'; import 'package:papyrus/widgets/topics/add_topic_sheet.dart'; import 'package:provider/provider.dart'; +import 'package:papyrus/themes/app_motion.dart'; +import 'package:papyrus/widgets/shared/app_motion_control.dart'; /// Bottom sheet for managing topic assignments for a book or multiple books. class ManageTopicsSheet extends StatefulWidget { @@ -38,6 +40,7 @@ class ManageTopicsSheet extends StatefulWidget { FutureOr Function(List tagIds)? onSave, }) { return showModalBottomSheet( + sheetAnimationStyle: AppMotion.animationStyle(context), context: context, useRootNavigator: true, isScrollControlled: true, @@ -53,6 +56,7 @@ class ManageTopicsSheet extends StatefulWidget { FutureOr Function(List tagIds)? onSave, }) { return showModalBottomSheet( + sheetAnimationStyle: AppMotion.animationStyle(context), context: context, isScrollControlled: true, shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xl))), @@ -284,7 +288,15 @@ class _ManageTopicsSheetState extends State with PersistentSa ), ), // Checkbox - Checkbox(value: isSelected, onChanged: (_) => _toggleTag(tag.id), activeColor: tagColor), + AppMotionControl( + value: isSelected, + builder: (focusNode) => Checkbox( + focusNode: focusNode, + value: isSelected, + onChanged: (_) => _toggleTag(tag.id), + activeColor: tagColor, + ), + ), ], ), ), diff --git a/app/lib/widgets/topics/topic_detail_sheet.dart b/app/lib/widgets/topics/topic_detail_sheet.dart index 3d5850f..e73abd6 100644 --- a/app/lib/widgets/topics/topic_detail_sheet.dart +++ b/app/lib/widgets/topics/topic_detail_sheet.dart @@ -5,6 +5,7 @@ import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/widgets/shared/bottom_sheet_handle.dart'; import 'package:papyrus/widgets/topics/add_topic_sheet.dart'; import 'package:provider/provider.dart'; +import 'package:papyrus/themes/app_motion.dart'; /// Bottom sheet showing topic details with edit and delete actions. /// @@ -18,6 +19,7 @@ class TopicDetailSheet extends StatelessWidget { /// Shows the topic detail sheet. static Future show(BuildContext context, {required Tag tag}) { return showModalBottomSheet( + sheetAnimationStyle: AppMotion.animationStyle(context), context: context, shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xl))), builder: (context) => TopicDetailSheet(tag: tag), @@ -133,6 +135,7 @@ class TopicDetailSheet extends StatelessWidget { final colorScheme = Theme.of(context).colorScheme; showDialog( + animationStyle: AppMotion.animationStyle(context), context: context, builder: (dialogContext) => AlertDialog( title: const Text('Delete topic?'), @@ -151,9 +154,10 @@ class TopicDetailSheet extends StatelessWidget { if (dialogContext.mounted) Navigator.pop(dialogContext); } catch (_) { if (context.mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('Could not delete topic. Please try again.'))); + ScaffoldMessenger.of(context).showSnackBar( + snackBarAnimationStyle: AppMotion.animationStyle(context), + const SnackBar(content: Text('Could not delete topic. Please try again.')), + ); } } }, diff --git a/app/test/themes/eink_theme_test.dart b/app/test/themes/eink_theme_test.dart new file mode 100644 index 0000000..2f72c03 --- /dev/null +++ b/app/test/themes/eink_theme_test.dart @@ -0,0 +1,109 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/themes/app_motion.dart'; +import 'package:papyrus/themes/app_theme.dart'; +import 'package:papyrus/widgets/shared/app_progress_indicator.dart'; + +void main() { + test('e-ink keeps typography sizes and exposes strong selected contrast', () { + final ink = AppTheme.eink; + final normal = AppTheme.light; + expect(ink.textTheme.bodyMedium?.fontSize, normal.textTheme.bodyMedium?.fontSize); + expect(ink.textTheme.titleMedium?.fontSize, normal.textTheme.titleMedium?.fontSize); + expect(ink.colorScheme.primaryContainer, Colors.black); + expect(ink.colorScheme.onPrimaryContainer, Colors.white); + expect(ink.colorScheme.outline, Colors.black); + expect(ink.splashFactory, NoSplash.splashFactory); + expect(ink.filledButtonTheme.style?.animationDuration, Duration.zero); + expect( + ink.pageTransitionsTheme.builders.values.every((builder) => builder.transitionDuration == Duration.zero), + isTrue, + ); + }); + + testWidgets('theme motion scope preserves sizing and honors system reduced motion', (tester) async { + final disabled = ValueNotifier(false); + addTearDown(disabled.dispose); + final readings = <(bool, Size, TextScaler)>[]; + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light, + home: ValueListenableBuilder( + valueListenable: disabled, + builder: (_, value, _) => MediaQuery( + data: const MediaQueryData( + size: Size(1200, 800), + textScaler: TextScaler.linear(1.3), + disableAnimations: true, + ), + child: AppMotionScope( + reduceAnimations: value, + child: Builder( + builder: (context) { + readings.add(( + AppMotion.disabled(context), + MediaQuery.sizeOf(context), + MediaQuery.textScalerOf(context), + )); + return const SizedBox(); + }, + ), + ), + ), + ), + ), + ); + expect(readings.last, (true, const Size(1200, 800), const TextScaler.linear(1.3))); + disabled.value = true; + await tester.pump(); + expect(readings.last.$1, isTrue); + }); + + testWidgets('e-ink loading indicators stay static and still report actual progress', (tester) async { + final progress = ValueNotifier(null); + addTearDown(progress.dispose); + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.eink, + home: Scaffold( + body: ValueListenableBuilder( + valueListenable: progress, + builder: (_, value, _) => Column( + children: [ + AppCircularProgressIndicator(value: value), + AppLinearProgressIndicator(value: value), + ], + ), + ), + ), + ), + ); + await tester.pump(); + final circle = tester.widget(find.byType(CircularProgressIndicator)); + final line = tester.widget(find.byType(LinearProgressIndicator)); + expect(circle.value, isNull); + expect(line.value, isNull); + expect(circle.controller?.value, 0.5); + expect(line.controller?.value, 0.5); + await tester.pump(const Duration(seconds: 2)); + expect(circle.controller?.value, 0.5); + expect(tester.binding.hasScheduledFrame, isFalse); + progress.value = 0.75; + await tester.pump(); + expect(tester.widget(find.byType(LinearProgressIndicator)).value, 0.75); + expect(tester.widget(find.byType(CircularProgressIndicator)).value, 0.75); + await tester.pumpWidget(const SizedBox()); + }); + + testWidgets('normal-theme indeterminate loaders continue animating', (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light, + home: const Scaffold(body: AppCircularProgressIndicator()), + ), + ); + await tester.pump(const Duration(milliseconds: 50)); + expect(tester.binding.hasScheduledFrame, isTrue); + await tester.pumpWidget(const SizedBox()); + }); +} diff --git a/app/test/widgets/app_motion_control_test.dart b/app/test/widgets/app_motion_control_test.dart new file mode 100644 index 0000000..0151da5 --- /dev/null +++ b/app/test/widgets/app_motion_control_test.dart @@ -0,0 +1,266 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/themes/app_theme.dart'; +import 'package:papyrus/widgets/settings/settings_row.dart'; +import 'package:papyrus/widgets/shared/app_motion_control.dart'; + +void main() { + final themes = {'light': AppTheme.light, 'dark': AppTheme.dark, 'eink': AppTheme.eink}; + + ToggleableStateMixin toggleState(WidgetTester tester) => tester.allStates.whereType().single; + + testWidgets('chip motion refreshes across theme switches and retains keyboard focus', (tester) async { + final theme = ValueNotifier(AppTheme.light); + final selected = ValueNotifier(false); + final focus = FocusNode(); + addTearDown(theme.dispose); + addTearDown(selected.dispose); + addTearDown(focus.dispose); + await tester.pumpWidget( + ValueListenableBuilder( + valueListenable: theme, + builder: (_, value, _) => MaterialApp( + theme: value, + themeAnimationDuration: Duration.zero, + home: Scaffold( + body: Center( + child: ValueListenableBuilder( + valueListenable: selected, + builder: (context, value, _) => AppMotionControl( + value: null, + focusNode: focus, + builder: (focusNode) => FilterChip( + focusNode: focusNode, + label: const Text('Option'), + selected: value, + onSelected: (value) => selected.value = value, + chipAnimationStyle: appChipAnimationStyle(context), + ), + ), + ), + ), + ), + ), + ), + ); + focus.requestFocus(); + await tester.pumpAndSettle(); + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 20)); + theme.value = AppTheme.eink; + await tester.pump(); + await tester.pump(); + expect(focus.hasFocus, isTrue); + expect(selected.value, isTrue); + final selectedWidth = tester.getSize(find.byType(RawChip)).width; + await tester.pumpAndSettle(); + expect(tester.getSize(find.byType(RawChip)).width, selectedWidth); + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pump(); + await tester.pump(); + expect(selected.value, isFalse); + expect(focus.hasFocus, isTrue); + final deselectedWidth = tester.getSize(find.byType(RawChip)).width; + await tester.pumpAndSettle(); + expect(tester.getSize(find.byType(RawChip)).width, deselectedWidth); + expect(deselectedWidth, lessThan(selectedWidth)); + theme.value = AppTheme.dark; + await tester.pumpAndSettle(); + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 20)); + final animatedWidth = tester.getSize(find.byType(RawChip)).width; + await tester.pumpAndSettle(); + expect(tester.getSize(find.byType(RawChip)).width, greaterThan(animatedWidth)); + }); + + for (final theme in themes.entries) { + for (final kind in ['checkbox', 'switch', 'checkbox tile', 'switch tile', 'settings switch']) { + testWidgets('${theme.key} $kind paints changed values with the expected motion', (tester) async { + final value = ValueNotifier(false); + addTearDown(value.dispose); + await tester.pumpWidget( + MaterialApp( + theme: theme.value, + home: Scaffold( + body: Center( + child: ValueListenableBuilder( + valueListenable: value, + builder: (context, selected, _) { + if (kind == 'settings switch') { + return SettingsToggleRow( + label: 'Setting', + value: selected, + onChanged: (changed) => value.value = changed, + ); + } + return AppMotionControl( + value: selected, + builder: (focusNode) { + void changed(bool? changed) => value.value = changed!; + return switch (kind) { + 'checkbox' => Checkbox(value: selected, onChanged: changed, focusNode: focusNode), + 'switch' => Switch(value: selected, onChanged: changed, focusNode: focusNode), + 'checkbox tile' => CheckboxListTile( + value: selected, + onChanged: changed, + focusNode: focusNode, + title: const Text('Setting'), + ), + _ => SwitchListTile( + value: selected, + onChanged: changed, + focusNode: focusNode, + title: const Text('Setting'), + ), + }; + }, + ); + }, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(toggleState(tester).position.value, 0); + final target = kind.contains('checkbox') ? find.byType(Checkbox) : find.byType(Switch); + await tester.tap(target); + await tester.pump(); + await tester.pump(); + expect(value.value, isTrue); + if (theme.key == 'eink') { + expect(toggleState(tester).position.value, 1); + expect(toggleState(tester).positionController.isAnimating, isFalse); + } else { + await tester.pump(const Duration(milliseconds: 40)); + expect(toggleState(tester).position.value, inExclusiveRange(0, 1)); + } + await tester.pumpAndSettle(); + await tester.tap(target); + await tester.pump(); + await tester.pump(); + expect(value.value, isFalse); + if (theme.key == 'eink') expect(toggleState(tester).position.value, 0); + await tester.pumpAndSettle(); + }); + } + } + + for (final tile in [false, true]) { + testWidgets('eink ${tile ? 'tile' : 'checkbox'} preserves focus for repeated keyboard changes', (tester) async { + final value = ValueNotifier(false); + final focus = FocusNode(); + addTearDown(value.dispose); + addTearDown(focus.dispose); + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.eink, + home: Scaffold( + body: ValueListenableBuilder( + valueListenable: value, + builder: (context, selected, _) => AppMotionControl( + value: selected, + focusNode: focus, + builder: (focusNode) => tile + ? CheckboxListTile( + value: selected, + onChanged: (changed) => value.value = changed!, + focusNode: focusNode, + title: const Text('Setting'), + ) + : Checkbox(value: selected, onChanged: (changed) => value.value = changed!, focusNode: focusNode), + ), + ), + ), + ), + ); + focus.requestFocus(); + await tester.pumpAndSettle(); + for (final expected in [true, false, true]) { + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pump(); + await tester.pump(); + expect(value.value, expected); + expect(toggleState(tester).position.value, expected ? 1 : 0); + expect(focus.hasFocus, isTrue); + } + }); + } + + for (final theme in themes.entries) { + for (final kind in ['choice', 'filter', 'action']) { + testWidgets('${theme.key} $kind chip selection uses the expected animation style', (tester) async { + final value = ValueNotifier(false); + addTearDown(value.dispose); + await tester.pumpWidget( + MaterialApp( + theme: theme.value, + home: Scaffold( + body: Center( + child: ValueListenableBuilder( + valueListenable: value, + builder: (context, selected, _) { + final style = appChipAnimationStyle(context); + return AppMotionControl( + value: null, + builder: (focusNode) => switch (kind) { + 'choice' => ChoiceChip( + focusNode: focusNode, + label: const Text('Option'), + selected: selected, + onSelected: (changed) => value.value = changed, + chipAnimationStyle: style, + ), + 'filter' => FilterChip( + focusNode: focusNode, + label: const Text('Option'), + selected: selected, + onSelected: (changed) => value.value = changed, + chipAnimationStyle: style, + ), + _ => ActionChip( + focusNode: focusNode, + label: const Text('Option'), + avatar: selected ? const Icon(Icons.check) : null, + onPressed: () => value.value = !selected, + chipAnimationStyle: style, + ), + }, + ); + }, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Option')); + await tester.pump(); + await tester.pump(); + expect(value.value, isTrue); + final chip = tester.widget(find.byType(RawChip)); + if (theme.key == 'eink') { + final style = chip.chipAnimationStyle!; + for (final animation in [ + style.enableAnimation, + style.selectAnimation, + style.avatarDrawerAnimation, + style.deleteDrawerAnimation, + ]) { + expect(animation!.duration, Duration.zero); + expect(animation.reverseDuration, Duration.zero); + } + final immediateWidth = tester.getSize(find.byType(RawChip)).width; + await tester.pumpAndSettle(); + expect(tester.getSize(find.byType(RawChip)).width, immediateWidth); + } else { + expect(chip.chipAnimationStyle, isNull); + await tester.pumpAndSettle(); + } + }); + } + } +} diff --git a/app/test/widgets/app_motion_integration_test.dart b/app/test/widgets/app_motion_integration_test.dart new file mode 100644 index 0000000..8129663 --- /dev/null +++ b/app/test/widgets/app_motion_integration_test.dart @@ -0,0 +1,194 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/models/shelf.dart'; +import 'package:papyrus/models/bookmark.dart'; +import 'package:papyrus/themes/app_theme.dart'; +import 'package:papyrus/widgets/acquisition/guarded_bottom_sheet_route.dart'; +import 'package:papyrus/widgets/book/book_bookmarks.dart'; +import 'package:papyrus/widgets/library/acquisition_confirmation_dialog.dart'; +import 'package:papyrus/widgets/shared/app_date_picker.dart'; +import 'package:papyrus/widgets/shelves/add_shelf_sheet.dart'; +import 'package:papyrus/widgets/shelves/shelf_card.dart'; + +class _Routes extends NavigatorObserver { + Route? latest; + + @override + void didPush(Route route, Route? previousRoute) => latest = route; +} + +void main() { + final shelf = Shelf(id: 'shelf', name: 'Books', createdAt: DateTime.utc(2026), updatedAt: DateTime.utc(2026)); + final themes = {'light': AppTheme.light, 'dark': AppTheme.dark, 'eink': AppTheme.eink}; + + Future desktop(WidgetTester tester) async { + tester.view.physicalSize = const Size(1200, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + } + + Widget card() => Center( + child: SizedBox( + width: 200, + height: 240, + child: ShelfCard(shelf: shelf, onMoreTap: () {}), + ), + ); + + double opacity(WidgetTester tester) => + tester.renderObject(find.byType(AnimatedOpacity)).opacity.value; + + for (final entry in themes.entries) { + testWidgets('${entry.key} shelf hover ${entry.key == 'eink' ? 'updates instantly' : 'keeps its fade'}', ( + tester, + ) async { + await desktop(tester); + await tester.pumpWidget( + MaterialApp( + theme: entry.value, + home: Scaffold(body: card()), + ), + ); + await tester.pumpAndSettle(); + expect(opacity(tester), 0); + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + await mouse.addPointer(location: Offset.zero); + await mouse.moveTo(tester.getCenter(find.byType(ShelfCard))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + if (entry.key == 'eink') { + expect(opacity(tester), 1); + } else { + expect(opacity(tester), greaterThan(0)); + expect(opacity(tester), lessThan(1)); + } + await mouse.removePointer(); + await tester.pumpAndSettle(); + }); + } + + testWidgets('switching to eink finishes an active implicit transition', (tester) async { + await desktop(tester); + final theme = ValueNotifier(AppTheme.light); + addTearDown(theme.dispose); + await tester.pumpWidget( + ValueListenableBuilder( + valueListenable: theme, + builder: (_, value, _) => MaterialApp( + theme: value, + themeAnimationDuration: Duration.zero, + home: Scaffold(body: card()), + ), + ), + ); + await tester.pumpAndSettle(); + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + await mouse.addPointer(location: Offset.zero); + await mouse.moveTo(tester.getCenter(find.byType(ShelfCard))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + expect(opacity(tester), inExclusiveRange(0, 1)); + theme.value = AppTheme.eink; + await tester.pump(); + expect(opacity(tester), 1); + await mouse.removePointer(); + await tester.pumpAndSettle(); + }); + + for (final entry in themes.entries) { + for (final overlay in ['dialog', 'sheet', 'guarded sheet', 'date', 'date range', 'popup']) { + testWidgets('${entry.key} $overlay uses the motion policy on open and close', (tester) async { + final routes = _Routes(); + final navigator = GlobalKey(); + final busy = ValueNotifier(false); + addTearDown(busy.dispose); + await tester.pumpWidget( + MaterialApp( + theme: entry.value, + navigatorKey: navigator, + navigatorObservers: [routes], + home: Scaffold( + body: Builder( + builder: (context) { + if (overlay == 'popup') { + return BookBookmarks( + bookmarks: [ + Bookmark(id: 'bookmark', bookId: 'book', position: 0.5, createdAt: DateTime.utc(2026)), + ], + bookTitle: 'Book', + ); + } + return TextButton( + onPressed: () { + switch (overlay) { + case 'dialog': + showAcquisitionConfirmationDialog( + context: context, + title: 'Confirm', + message: 'Message', + actionLabel: 'Continue', + ); + case 'sheet': + AddShelfSheet.show(context); + case 'guarded sheet': + showGuardedModalBottomSheet( + context: context, + busy: busy, + shape: const RoundedRectangleBorder(), + builder: (_) => const SizedBox(height: 100, child: Text('Guarded')), + ); + case 'date': + showAppDatePicker( + context: context, + initialDate: DateTime(2026, 9, 5), + firstDate: DateTime(2026), + lastDate: DateTime(2027), + ); + case 'date range': + showAppDateRangePicker( + context: context, + initialDateRange: DateTimeRange(start: DateTime(2026, 9, 5), end: DateTime(2026, 9, 10)), + firstDate: DateTime(2026), + lastDate: DateTime(2027), + ); + } + }, + child: const Text('Open'), + ); + }, + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(overlay == 'popup' ? find.byIcon(Icons.sort) : find.text('Open')); + await tester.pump(); + await tester.pump(); + final route = routes.latest! as TransitionRoute; + if (entry.key == 'eink') { + expect(route.transitionDuration, Duration.zero); + expect(route.animation!.value, 1); + } else { + expect(route.transitionDuration, greaterThan(Duration.zero)); + await tester.pump(const Duration(milliseconds: 50)); + expect(route.animation!.value, inExclusiveRange(0, 1)); + await tester.pumpAndSettle(); + } + navigator.currentState!.pop(); + await tester.pump(); + await tester.pump(); + if (entry.key == 'eink') { + expect(route.isActive, isFalse); + expect(navigator.currentState!.canPop(), isFalse); + } else { + expect(route.animation!.status, AnimationStatus.reverse); + await tester.pumpAndSettle(); + expect(navigator.currentState!.canPop(), isFalse); + } + }); + } + } +} diff --git a/app/test/widgets/shell/eink_layout_test.dart b/app/test/widgets/shell/eink_layout_test.dart new file mode 100644 index 0000000..f57cd9c --- /dev/null +++ b/app/test/widgets/shell/eink_layout_test.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/providers/preferences_provider.dart'; +import 'package:papyrus/providers/sidebar_provider.dart'; +import 'package:papyrus/themes/app_theme.dart'; +import 'package:papyrus/widgets/shell/adaptive_app_shell.dart'; +import 'package:papyrus/widgets/shell/desktop_sidebar.dart'; +import 'package:papyrus/widgets/shell/mobile_bottom_nav.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + for (final width in [390.0, 850.0, 1440.0]) { + testWidgets('e-ink preserves navigation layout at width $width', (tester) async { + tester.view.physicalSize = Size(width, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + SharedPreferences.setMockInitialValues({'theme_mode': 'light'}); + final prefs = PreferencesProvider(await SharedPreferences.getInstance()); + final router = GoRouter( + initialLocation: '/library/books', + routes: [ + ShellRoute( + builder: (_, _, child) => AdaptiveAppShell(child: child), + routes: [GoRoute(path: '/library/books', builder: (_, _) => const Text('Library content'))], + ), + ], + ); + addTearDown(router.dispose); + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: prefs), + ChangeNotifierProvider(create: (_) => DataStore()), + ChangeNotifierProvider(create: (_) => SidebarProvider()), + ], + child: Consumer( + builder: (_, preferences, _) => MaterialApp.router( + theme: preferences.isEinkMode ? AppTheme.eink : AppTheme.light, + routerConfig: router, + ), + ), + ), + ); + await tester.pumpAndSettle(); + final desktop = find.byType(DesktopSidebar).evaluate().isNotEmpty; + final mobile = find.byType(MobileBottomNav).evaluate().isNotEmpty; + expect(desktop, width >= 840); + final contentBounds = tester.getRect(find.text('Library content')); + prefs.themeModePref = 'eink'; + await tester.pumpAndSettle(); + expect(find.byType(DesktopSidebar), desktop ? findsOneWidget : findsNothing); + expect(find.byType(MobileBottomNav), mobile ? findsOneWidget : findsNothing); + expect(tester.getRect(find.text('Library content')).left, contentBounds.left); + expect(router.routeInformationProvider.value.uri.path, '/library/books'); + prefs.themeModePref = 'light'; + await tester.pumpAndSettle(); + expect(find.byType(DesktopSidebar), desktop ? findsOneWidget : findsNothing); + expect(find.byType(MobileBottomNav), mobile ? findsOneWidget : findsNothing); + }); + } +} diff --git a/app/test/widgets/static_date_picker_test.dart b/app/test/widgets/static_date_picker_test.dart new file mode 100644 index 0000000..facc5f4 --- /dev/null +++ b/app/test/widgets/static_date_picker_test.dart @@ -0,0 +1,162 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/themes/app_theme.dart'; +import 'package:papyrus/widgets/shared/app_date_picker.dart'; +import 'package:papyrus/widgets/shared/app_drawer.dart'; + +void main() { + testWidgets('e-ink calendar changes months and years instantly and saves a date', (tester) async { + DateTime? result; + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.eink, + home: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () async { + result = await showAppDatePicker( + context: context, + initialDate: DateTime(2026, 9, 5), + firstDate: DateTime(2026), + lastDate: DateTime(2028, 12, 31), + ); + }, + child: const Text('Open'), + ), + ), + ), + ), + ); + await tester.tap(find.text('Open')); + await tester.pump(); + await tester.pump(); + await tester.tap(find.byTooltip('Next month')); + await tester.pump(); + expect(find.text('October 2026'), findsOneWidget); + expect(find.byType(PageView), findsNothing); + await tester.tap(find.text('October 2026')); + await tester.pump(); + expect(find.byType(YearPicker), findsOneWidget); + await tester.tap(find.text('2027')); + await tester.pump(); + expect(find.byType(YearPicker), findsNothing); + expect(find.text('October 2027'), findsOneWidget); + await tester.tap(find.text('15')); + await tester.pump(); + await tester.tap(find.text('OK')); + await tester.pump(); + expect(result, DateTime(2027, 10, 15)); + }); + + testWidgets('e-ink range selection spans months and cancel preserves the original', (tester) async { + DateTimeRange? result; + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.eink, + home: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () async { + result = await showAppDateRangePicker( + context: context, + initialDateRange: DateTimeRange(start: DateTime(2026, 9, 5), end: DateTime(2026, 9, 10)), + firstDate: DateTime(2026), + lastDate: DateTime(2027), + ); + }, + child: const Text('Open'), + ), + ), + ), + ), + ); + await tester.tap(find.text('Open')); + await tester.pump(); + await tester.pump(); + await tester.tap(find.text('28')); + await tester.pump(); + await tester.tap(find.byTooltip('Next month')); + await tester.pump(); + await tester.tap(find.text('3')); + await tester.pump(); + await tester.tap(find.text('OK')); + await tester.pump(); + expect(result, DateTimeRange(start: DateTime(2026, 9, 28), end: DateTime(2026, 10, 3))); + await tester.tap(find.text('Open')); + await tester.pump(); + await tester.pump(); + await tester.tap(find.text('20')); + await tester.pump(); + await tester.tap(find.text('Cancel')); + await tester.pump(); + expect(result, isNull); + }); + + testWidgets('e-ink drawer opens and dismisses instantly at its regular width', (tester) async { + final scaffold = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.eink, + home: Scaffold( + key: scaffold, + drawer: const Drawer(child: Text('Sections')), + body: Builder( + builder: (context) => + TextButton(onPressed: () => openAppDrawer(context, scaffold.currentState), child: const Text('Open')), + ), + ), + ), + ); + await tester.tap(find.text('Open')); + await tester.pump(); + await tester.pump(); + final drawer = find.byType(Drawer); + expect(tester.getTopLeft(drawer).dx, 0); + expect(tester.getSize(drawer).width, 304); + await tester.tapAt(const Offset(700, 300)); + await tester.pump(); + await tester.pump(); + expect(find.text('Sections'), findsNothing); + }); + + testWidgets('e-ink typed dates validate and survive switching back to calendar', (tester) async { + DateTime? result; + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.eink, + home: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () async { + result = await showAppDatePicker( + context: context, + initialDate: DateTime(2026, 9, 5), + firstDate: DateTime(2026), + lastDate: DateTime(2027), + ); + }, + child: const Text('Open'), + ), + ), + ), + ), + ); + await tester.tap(find.text('Open')); + await tester.pump(); + await tester.pump(); + await tester.tap(find.byIcon(Icons.edit_outlined)); + await tester.pump(); + await tester.enterText(find.byType(TextField), 'invalid'); + await tester.tap(find.text('OK')); + await tester.pump(); + expect(find.byType(InputDatePickerFormField), findsOneWidget); + expect(result, isNull); + await tester.enterText(find.byType(TextField), '10/20/2026'); + await tester.tap(find.byIcon(Icons.calendar_today)); + await tester.pump(); + expect(find.text('October 2026'), findsOneWidget); + await tester.tap(find.text('OK')); + await tester.pump(); + expect(result, DateTime(2026, 10, 20)); + }); +} From 965052678c2c7f2a83aa4eaf0c646a3cd33746e8 Mon Sep 17 00:00:00 2001 From: Eoic Date: Sun, 6 Sep 2026 02:49:24 +0300 Subject: [PATCH 2/3] Remove e-ink input focus interpolation and floating-label motion --- app/docs/eink-theme.md | 9 +- app/lib/themes/app_theme.dart | 15 +- .../themes/instant_outline_input_border.dart | 22 +++ app/test/themes/eink_input_focus_test.dart | 168 ++++++++++++++++++ 4 files changed, 208 insertions(+), 6 deletions(-) create mode 100644 app/lib/themes/instant_outline_input_border.dart create mode 100644 app/test/themes/eink_input_focus_test.dart diff --git a/app/docs/eink-theme.md b/app/docs/eink-theme.md index 17e3ef7..82bb100 100644 --- a/app/docs/eink-theme.md +++ b/app/docs/eink-theme.md @@ -6,6 +6,13 @@ breakpoints. Typography sizes, control dimensions, card margins, and spacing match the regular themes. Black/white selection states, stronger borders, and opaque surfaces keep controls legible without shadows or color alone. +Input labels stay above their fields in e-ink, and focus borders snap to their +target thickness instead of interpolating. Hints appear without fading. These +changes use the input theme and preserve the native field's text, selection, +keyboard focus, and validation. Light and dark retain floating-label and border +animations. Pixel comparisons in `test/themes/eink_input_focus_test.dart` cover +focus and blur, explicit form borders, and switching themes while editing. + `AppMotion` carries the motion policy independently of layout. E-ink enables it through its theme extension; the existing reduced-animation preference and the system reduced-motion setting are also respected by app-controlled transitions. @@ -33,6 +40,6 @@ Regression coverage lives in `test/widgets/shell/eink_layout_test.dart`, `test/widgets/static_date_picker_test.dart`. Validated on `feature/eink-theme`, based on this repository's default `master` -branch: `flutter test --no-pub` passed 1,140 tests (10 skipped), +branch: `flutter test --no-pub` passed 1,145 tests (10 skipped), `flutter analyze --no-pub` reported no issues, and `flutter build web --release --no-pub` succeeded. diff --git a/app/lib/themes/app_theme.dart b/app/lib/themes/app_theme.dart index 237d40e..1915f72 100644 --- a/app/lib/themes/app_theme.dart +++ b/app/lib/themes/app_theme.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'app_motion.dart'; +import 'instant_outline_input_border.dart'; import 'color_schemes.g.dart'; import 'design_tokens.dart'; @@ -362,25 +363,29 @@ class AppTheme { static InputDecorationTheme _einkInputDecorationTheme() { return const InputDecorationTheme( + floatingLabelBehavior: FloatingLabelBehavior.always, + floatingLabelStyle: TextStyle(color: EinkColors.black, fontSize: 16, fontWeight: FontWeight.w700), + hintFadeDuration: Duration.zero, + hoverColor: Colors.transparent, filled: true, fillColor: EinkColors.container, - border: OutlineInputBorder( + border: InstantOutlineInputBorder( borderRadius: BorderRadius.zero, borderSide: BorderSide(color: EinkColors.black, width: BorderWidths.einkDefault), ), - enabledBorder: OutlineInputBorder( + enabledBorder: InstantOutlineInputBorder( borderRadius: BorderRadius.zero, borderSide: BorderSide(color: EinkColors.black, width: BorderWidths.einkDefault), ), - focusedBorder: OutlineInputBorder( + focusedBorder: InstantOutlineInputBorder( borderRadius: BorderRadius.zero, borderSide: BorderSide(color: EinkColors.black, width: BorderWidths.einkFocused), ), - errorBorder: OutlineInputBorder( + errorBorder: InstantOutlineInputBorder( borderRadius: BorderRadius.zero, borderSide: BorderSide(color: EinkColors.black, width: BorderWidths.einkError), ), - focusedErrorBorder: OutlineInputBorder( + focusedErrorBorder: InstantOutlineInputBorder( borderRadius: BorderRadius.zero, borderSide: BorderSide(color: EinkColors.black, width: BorderWidths.einkError), ), diff --git a/app/lib/themes/instant_outline_input_border.dart b/app/lib/themes/instant_outline_input_border.dart new file mode 100644 index 0000000..a10faf1 --- /dev/null +++ b/app/lib/themes/instant_outline_input_border.dart @@ -0,0 +1,22 @@ +import 'package:flutter/material.dart'; + +/// Keeps Flutter's outline geometry without interpolating between border states. +/// InputDecorator's private border controller otherwise animates focus changes +/// even when MediaQuery.disableAnimations is true. +class InstantOutlineInputBorder extends OutlineInputBorder { + const InstantOutlineInputBorder({super.borderSide, super.borderRadius, super.gapPadding}); + + @override + InstantOutlineInputBorder copyWith({BorderSide? borderSide, BorderRadius? borderRadius, double? gapPadding}) => + InstantOutlineInputBorder( + borderSide: borderSide ?? this.borderSide, + borderRadius: borderRadius ?? this.borderRadius, + gapPadding: gapPadding ?? this.gapPadding, + ); + + @override + ShapeBorder? lerpFrom(ShapeBorder? a, double t) => this; + + @override + ShapeBorder? lerpTo(ShapeBorder? b, double t) => b ?? this; +} diff --git a/app/test/themes/eink_input_focus_test.dart b/app/test/themes/eink_input_focus_test.dart new file mode 100644 index 0000000..18073db --- /dev/null +++ b/app/test/themes/eink_input_focus_test.dart @@ -0,0 +1,168 @@ +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/themes/app_theme.dart'; + +Future _pixels(WidgetTester tester, GlobalKey key) async { + final boundary = key.currentContext!.findRenderObject()! as RenderRepaintBoundary; + return (await tester.runAsync(() async { + final image = await boundary.toImage(); + final bytes = await image.toByteData(format: ui.ImageByteFormat.rawRgba); + image.dispose(); + return bytes!.buffer.asUint8List(); + }))!; +} + +void main() { + for (final inlineBorder in [false, true]) { + testWidgets('e-ink input focus paints its final state immediately (inline border: $inlineBorder)', (tester) async { + final key = GlobalKey(); + final focus = FocusNode(); + final controller = TextEditingController(); + addTearDown(focus.dispose); + addTearDown(controller.dispose); + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.eink, + home: Scaffold( + body: Center( + child: RepaintBoundary( + key: key, + child: SizedBox( + width: 320, + child: TextField( + controller: controller, + focusNode: focus, + showCursor: false, + decoration: InputDecoration( + labelText: 'Title', + hintText: 'Enter title', + border: inlineBorder ? const OutlineInputBorder() : null, + ), + ), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final initialSize = tester.getSize(find.byType(TextField)); + final unfocused = await _pixels(tester, key); + focus.requestFocus(); + await tester.pump(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 16)); + final focused = await _pixels(tester, key); + expect(listEquals(unfocused, focused), isFalse, reason: 'Keyboard focus must remain visibly distinct'); + await tester.pump(const Duration(milliseconds: 80)); + expect( + listEquals(focused, await _pixels(tester, key)), + isTrue, + reason: 'Focus must not interpolate the border or label', + ); + await tester.pump(const Duration(milliseconds: 200)); + expect(listEquals(focused, await _pixels(tester, key)), isTrue); + expect(tester.getSize(find.byType(TextField)), initialSize); + await tester.enterText(find.byType(TextField), 'Papyrus'); + controller.selection = const TextSelection(baseOffset: 1, extentOffset: 4); + await tester.pump(); + focus.unfocus(); + await tester.pump(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 16)); + final blurred = await _pixels(tester, key); + await tester.pump(const Duration(milliseconds: 250)); + expect(listEquals(blurred, await _pixels(tester, key)), isTrue, reason: 'Losing focus must also be instant'); + expect(controller.text, 'Papyrus'); + expect(controller.selection, const TextSelection(baseOffset: 1, extentOffset: 4)); + }); + } + + for (final dark in [false, true]) { + testWidgets('normal input focus keeps its animation (dark: $dark)', (tester) async { + final key = GlobalKey(); + final focus = FocusNode(); + addTearDown(focus.dispose); + await tester.pumpWidget( + MaterialApp( + theme: dark ? AppTheme.dark : AppTheme.light, + home: Scaffold( + body: Center( + child: RepaintBoundary( + key: key, + child: SizedBox( + width: 320, + child: TextField( + focusNode: focus, + showCursor: false, + decoration: const InputDecoration(labelText: 'Title'), + ), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + focus.requestFocus(); + await tester.pump(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 16)); + final initial = await _pixels(tester, key); + await tester.pump(const Duration(milliseconds: 80)); + expect(listEquals(initial, await _pixels(tester, key)), isFalse); + await tester.pumpAndSettle(); + }); + } + + testWidgets('theme changes preserve a focused form field and its validation', (tester) async { + final eink = ValueNotifier(false); + final focus = FocusNode(); + final controller = TextEditingController(text: 'Papyrus'); + final form = GlobalKey(); + addTearDown(eink.dispose); + addTearDown(focus.dispose); + addTearDown(controller.dispose); + await tester.pumpWidget( + ValueListenableBuilder( + valueListenable: eink, + builder: (_, ink, _) => MaterialApp( + theme: ink ? AppTheme.eink : AppTheme.light, + themeAnimationDuration: Duration.zero, + home: Scaffold( + body: Form( + key: form, + child: TextFormField( + controller: controller, + focusNode: focus, + decoration: const InputDecoration(labelText: 'Title'), + validator: (text) => text!.isEmpty ? 'Required' : null, + ), + ), + ), + ), + ), + ); + focus.requestFocus(); + await tester.pump(); + controller.selection = const TextSelection(baseOffset: 1, extentOffset: 4); + eink.value = true; + await tester.pump(); + expect(focus.hasFocus, isTrue); + expect(controller.text, 'Papyrus'); + expect(controller.selection, const TextSelection(baseOffset: 1, extentOffset: 4)); + expect(form.currentState!.validate(), isTrue); + controller.clear(); + expect(form.currentState!.validate(), isFalse); + await tester.pumpAndSettle(); + expect(find.text('Required'), findsOneWidget); + eink.value = false; + await tester.pump(); + expect(focus.hasFocus, isTrue); + expect(find.text('Required'), findsOneWidget); + }); +} From 7dae702e1174fcc2ec3f3995d6c40add93c6dd70 Mon Sep 17 00:00:00 2001 From: Eoic Date: Sun, 6 Sep 2026 03:07:49 +0300 Subject: [PATCH 3/3] Remove outdated Books Acquisition UX Design document and update package dependencies in pubspec.lock --- app/docs/eink-theme.md | 45 - ...07-25-books-acquisition-ux-continuation.md | 92 -- .../plans/2026-07-25-books-acquisition-ux.md | 1313 ----------------- .../2026-07-25-books-acquisition-ux-design.md | 226 --- app/pubspec.lock | 18 +- 5 files changed, 9 insertions(+), 1685 deletions(-) delete mode 100644 app/docs/eink-theme.md delete mode 100644 app/docs/superpowers/plans/2026-07-25-books-acquisition-ux-continuation.md delete mode 100644 app/docs/superpowers/plans/2026-07-25-books-acquisition-ux.md delete mode 100644 app/docs/superpowers/specs/2026-07-25-books-acquisition-ux-design.md diff --git a/app/docs/eink-theme.md b/app/docs/eink-theme.md deleted file mode 100644 index 82bb100..0000000 --- a/app/docs/eink-theme.md +++ /dev/null @@ -1,45 +0,0 @@ -# E-ink theme - -E-ink is a theme choice alongside light and dark. Screen width selects the app -shell and page layout; selecting e-ink keeps the current route and responsive -breakpoints. Typography sizes, control dimensions, card margins, and spacing -match the regular themes. Black/white selection states, stronger borders, and -opaque surfaces keep controls legible without shadows or color alone. - -Input labels stay above their fields in e-ink, and focus borders snap to their -target thickness instead of interpolating. Hints appear without fading. These -changes use the input theme and preserve the native field's text, selection, -keyboard focus, and validation. Light and dark retain floating-label and border -animations. Pixel comparisons in `test/themes/eink_input_focus_test.dart` cover -focus and blur, explicit form borders, and switching themes while editing. - -`AppMotion` carries the motion policy independently of layout. E-ink enables it -through its theme extension; the existing reduced-animation preference and the -system reduced-motion setting are also respected by app-controlled transitions. - -When adding UI: - -- Use `AppMotion.duration` and `AppMotion.animationStyle` for animations and - overlays. Stateful animations must also finish or reset when the policy changes. -- Use the shared progress indicators. Indeterminate indicators retain their - loading semantics and a visible static phase; determinate values still update. -- Use `AppMotionControl` for framework controls with private animation - controllers. Its local ticker scope and retained focus let controls update - immediately without freezing an entire page. -- Use the shared date-picker and drawer entry points. Flutter's stock calendar - has internal transitions independent of its route, so reduced-motion calendar - controls change months and years directly. Cancel never saves a selection. -- Keep dimensions and navigation decisions independent of the theme. - -Light and dark keep their normal animation timings. User-driven scrolling and -actual data/progress updates remain available in e-ink. - -Regression coverage lives in `test/widgets/shell/eink_layout_test.dart`, -`test/themes/eink_theme_test.dart`, `test/widgets/app_motion_integration_test.dart`, -`test/widgets/app_motion_control_test.dart`, and -`test/widgets/static_date_picker_test.dart`. - -Validated on `feature/eink-theme`, based on this repository's default `master` -branch: `flutter test --no-pub` passed 1,145 tests (10 skipped), -`flutter analyze --no-pub` reported no issues, and -`flutter build web --release --no-pub` succeeded. diff --git a/app/docs/superpowers/plans/2026-07-25-books-acquisition-ux-continuation.md b/app/docs/superpowers/plans/2026-07-25-books-acquisition-ux-continuation.md deleted file mode 100644 index 8edbe3a..0000000 --- a/app/docs/superpowers/plans/2026-07-25-books-acquisition-ux-continuation.md +++ /dev/null @@ -1,92 +0,0 @@ -# Books acquisition UX continuation checkpoint - -Saved: 2026-07-25 - -## Repository state - -- Repository: `/home/karolis/Documents/Projects/Papyrus/client/app` -- Branch: `feature/torrent-acquisition` -- Current HEAD: `878241c fix: guard acquisition async operations` -- The active implementation agent was interrupted at the user's request. -- There are no uncommitted acquisition-task edits after the interruption. -- Task 8 has not started. - -The existing implementation plan and approved design remain the source of truth: - -- `docs/superpowers/plans/2026-07-25-books-acquisition-ux.md` -- `docs/superpowers/specs/2026-07-25-books-acquisition-ux-design.md` - -## Completed work - -Tasks 1 through 6 passed their spec and quality reviews. - -Task 7 is implemented through these commits: - -- `878f444 feat: integrate downloads into books page` -- `5588790 test: complete books acquisition acceptance coverage` -- `4c79e79 fix: harden acquisition provider state transitions` -- `51a5242 fix: harden books acquisition interactions` -- `9b192d4 fix: close acquisition lifecycle gaps` -- `878241c fix: guard acquisition async operations` - -At `878241c`, Task 7 passed its final spec review. The latest focused verification passed 298 tests, scoped Flutter analysis, formatting, and diff checks. - -## One remaining Task 7 quality blocker - -An in-flight `refreshJobs()` can apply a stale server snapshot after a newer successful local job write. - -Example: - -1. Visible polling starts `listJobs()` and receives a delayed response containing a failed job. -2. The user successfully removes that job. -3. The delayed list response completes and replaces `_jobs` with its older snapshot. -4. The removed job is resurrected. - -The same race can revert Cancel or Retry, or erase a newly submitted placeholder. - -### Required fix - -Add job-state revision or equivalent request ownership in `AcquisitionDownloadsProvider`: - -- Capture the job-state revision when `refreshJobs()` starts. -- Increment the revision on every authoritative local `_jobs` write, including successful remove, cancel, retry, file selection, and submitted-job insertion. -- Discard a list response when the gateway generation or job-state revision no longer matches. -- Ensure loading/finalization state and polling scheduling do not get stuck when a response is discarded. -- A later fresh refresh must still converge to the server state. - -Add delayed-`listJobs` regression tests for at least: - -- Remove -- Cancel -- Retry -- Submission -- A later fresh refresh after the stale response is discarded - -Then rerun provider, LibraryPage, details-sheet, grid, and list suites, scoped analysis, formatting, and diff checks. - -After committing the fix, repeat the Task 7 spec review followed by the Task 7 quality review on the exact new head. Approval requires no Critical or Important issues. - -## Task 8 after Task 7 approval - -Run the full regression and analysis commands from the implementation plan, then complete the manual live smoke workflow for: - -- Explicit online search from Books -- Multiple-release selection and submission -- qBittorrent progress appearing in the normal grid/list -- Downloading filter -- Details, cancel, retry, remove, and file-selection flows -- Responsive desktop/mobile and e-ink presentation - -Use the verification-before-completion and final branch-finishing workflows before claiming completion. - -## User-owned dirty files to preserve - -These pre-existing changes are unrelated to the remaining Task 7 fix and must not be reset, overwritten, staged, or committed accidentally: - -- `lib/main.dart` -- `lib/pages/acquisition_page.dart` -- `lib/providers/acquisition_availability_provider.dart` -- `lib/widgets/acquisition/acquisition_endpoint_editor.dart` -- `test/acquisition/acquisition_availability_provider_test.dart` -- `test/pages/acquisition_page_test.dart` -- `test/widgets/acquisition/acquisition_endpoint_editor_test.dart` diff --git a/app/docs/superpowers/plans/2026-07-25-books-acquisition-ux.md b/app/docs/superpowers/plans/2026-07-25-books-acquisition-ux.md deleted file mode 100644 index 4fa1a23..0000000 --- a/app/docs/superpowers/plans/2026-07-25-books-acquisition-ux.md +++ /dev/null @@ -1,1313 +0,0 @@ -# Books Acquisition UX Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Integrate online book discovery and managed downloads into the existing Books page so local browsing stays unchanged, online results replace the grid only when explicitly requested, and accepted downloads appear as selectable progress placeholders in the ordinary grid/list. - -**Architecture:** Keep the Books page in one of two page-local presentation modes: local library or online results. Keep remote query/results, release selection, job selection, polling, and submission outcomes in `AcquisitionDownloadsProvider`. Render server acquisition jobs as placeholders until their synchronized `Book` records arrive, then attach progress to the real book and finally let the normal synchronized book UI take over. Reuse the existing `SelectionHeader`, `EmptyState`, `QuickFilterChips`, `BottomSheetHandle`, add-book choice cards, and shelf-style destructive `AlertDialog`; do not introduce a download-management page or change the server contract. - -**Tech Stack:** Flutter, Dart, Provider, existing Papyrus design tokens and shared widgets, `flutter_test` - ---- - -## Constraints and invariants - -- The approved design is `docs/superpowers/specs/2026-07-25-books-acquisition-ux-design.md`. -- Local search never sends a remote request. -- Remote search runs only after `Search online for “…”`, `Find books online`, or an explicit submit in online mode. -- Entering and leaving online mode preserves the local query, filters, sort, view mode, and ordinary book selection. -- Release selection, acquisition-job selection, and ordinary book selection are three independent states. -- Server jobs are the source of truth for progress and survive refresh/restart through the existing polling path. -- A completed book is never fabricated from a job response. The synchronized `Book` record replaces its placeholder. -- No server or database changes are expected. Stop and document a concrete contract defect before changing the server. -- Preserve unrelated changes already present in the working tree. Stage only the files listed by each task. - -## Task 1: Make acquisition state support explicit search and partial retry - -**Files:** - -- Create: `lib/acquisition/acquisition_user_messages.dart` -- Create: `test/acquisition/acquisition_user_messages_test.dart` -- Modify: `lib/providers/acquisition_downloads_provider.dart` -- Modify: `test/acquisition/acquisition_downloads_provider_test.dart` - -- [ ] **Step 1: Add failing provider tests for search and submission state** - -Add tests proving: - -```dart -test('search state is separate from job refresh errors', () async { - final gateway = _FakeGateway(searchError: const AuthApiException( - statusCode: 502, - message: 'upstream request failed', - )); - final provider = AcquisitionDownloadsProvider( - gateway: gateway, - pollingInterval: Duration.zero, - ); - - await provider.searchRemote('Dune'); - - expect(provider.remoteQuery, 'Dune'); - expect(provider.remoteResults, isEmpty); - expect(provider.searchError, 'Could not search connected sources. Check the enabled indexers and try again.'); - expect(provider.error, isNull); -}); - -test('partial submission keeps only failed releases selected', () async { - final gateway = _FakeGateway( - batchResponse: BatchSubmissionResponse(items: [ - BatchSubmissionItem(index: 0, job: _job(id: 'job-1'), error: null), - const BatchSubmissionItem(index: 1, job: null, error: 'download client rejected release'), - ]), - ); - final provider = AcquisitionDownloadsProvider( - gateway: gateway, - pollingInterval: Duration.zero, - ); - provider.setRemoteResults('Dune', [_release('one'), _release('two')]); - provider.selectAllReleases(); - - final outcome = await provider.submitSelectedReleases('client-1'); - - expect(outcome.successfulCount, 1); - expect(outcome.failedCount, 1); - expect(provider.selectedReleaseTokens, {'two'}); - expect(provider.submissionErrorsByReleaseToken.keys, {'two'}); - expect(provider.jobs.map((job) => job.id), contains('job-1')); -}); -``` - -Also cover complete success, complete failure, an omitted/malformed batch item, and clearing stale row errors on a new search. - -- [ ] **Step 2: Run the focused tests and confirm they fail for missing behavior** - -Run: - -```bash -flutter test test/acquisition/acquisition_downloads_provider_test.dart test/acquisition/acquisition_user_messages_test.dart -``` - -Expected: failures for the missing `searchError`, `submissionErrorsByReleaseToken`, and `AcquisitionSubmissionOutcome` APIs. - -- [ ] **Step 3: Add task-focused error mapping** - -Implement a small pure mapper. It must never expose URLs, endpoint IDs, stack strings, or exception class names. - -```dart -import 'package:papyrus/auth/auth_api_client.dart'; - -String acquisitionSearchMessage(Object error) { - if (error is AuthApiException) { - return switch (error.statusCode) { - 401 || 403 => 'Your session expired. Sign in and try again.', - 502 || 503 || 504 => 'Could not search connected sources. Check the enabled indexers and try again.', - _ => 'Could not search connected sources. Try again.', - }; - } - - return 'Could not search connected sources. Try again.'; -} - -String acquisitionSubmissionMessage(String? detail) { - final normalized = detail?.toLowerCase() ?? ''; - - if (normalized.contains('auth')) { - return 'The download client rejected its saved sign-in details.'; - } - if (normalized.contains('timeout') || normalized.contains('timed out')) { - return 'The download client did not respond in time.'; - } - if (normalized.contains('reject')) { - return 'The download client rejected this release.'; - } - - return 'This release could not be sent to the download client.'; -} -``` - -Keep mapping deliberately small and test every branch. Do not map by presenting raw backend strings. - -- [ ] **Step 4: Return a structured batch outcome and preserve failed selection** - -Add: - -```dart -class AcquisitionSubmissionOutcome { - final int successfulCount; - final Map failuresByReleaseToken; - - const AcquisitionSubmissionOutcome({ - required this.successfulCount, - required this.failuresByReleaseToken, - }); - - int get failedCount => failuresByReleaseToken.length; - bool get allSucceeded => failedCount == 0 && successfulCount > 0; -} -``` - -Replace the provider's global submission-error list with: - -```dart -Map _submissionErrorsByReleaseToken = const {}; -String? _searchError; - -Map get submissionErrorsByReleaseToken => - Map.unmodifiable(_submissionErrorsByReleaseToken); -String? get searchError => _searchError; - -List get submissionErrors => - List.unmodifiable(_submissionErrorsByReleaseToken.values); -``` - -During submission, preserve response-index correlation: - -```dart -final selectedReleases = _remoteResults - .where((release) => _selectedReleaseTokens.contains(release.releaseToken)) - .toList(); -final successfulTokens = {}; -final failures = {}; -final handledIndexes = {}; - -for (final item in response.items) { - if (item.index < 0 || item.index >= selectedReleases.length) { - continue; - } - - handledIndexes.add(item.index); - final release = selectedReleases[item.index]; - - if (item.job case final job?) { - _jobs[job.id] = job; - successfulTokens.add(release.releaseToken); - } else { - failures[release.releaseToken] = - acquisitionSubmissionMessage(item.error); - } -} - -for (var index = 0; index < selectedReleases.length; index++) { - if (!handledIndexes.contains(index)) { - failures[selectedReleases[index].releaseToken] = - 'The download client did not return a result for this release.'; - } -} - -_selectedReleaseTokens - ..removeAll(successfulTokens) - ..addAll(failures.keys); -_submissionErrorsByReleaseToken = Map.unmodifiable(failures); -``` - -Set `remoteQuery` before starting the request so loading/error/empty all belong to the submitted query. Clear `searchError`, row errors, and release selection when a new query is submitted. Keep the existing generic `error` for polling/configuration failures only. - -- [ ] **Step 5: Run provider and mapper tests** - -Run: - -```bash -dart format lib/acquisition/acquisition_user_messages.dart lib/providers/acquisition_downloads_provider.dart test/acquisition/acquisition_user_messages_test.dart test/acquisition/acquisition_downloads_provider_test.dart -flutter test test/acquisition/acquisition_downloads_provider_test.dart test/acquisition/acquisition_user_messages_test.dart -flutter analyze lib/acquisition/acquisition_user_messages.dart lib/providers/acquisition_downloads_provider.dart -``` - -Expected: all focused tests pass and analysis is clean. - -- [ ] **Step 6: Commit Task 1** - -```bash -git add lib/acquisition/acquisition_user_messages.dart lib/providers/acquisition_downloads_provider.dart test/acquisition/acquisition_user_messages_test.dart test/acquisition/acquisition_downloads_provider_test.dart -git commit -m "fix: preserve failed acquisition submissions" -``` - -## Task 2: Add the approved online-search entry points - -**Files:** - -- Modify: `lib/widgets/add_book/add_book_choice_sheet.dart` -- Modify: `test/widgets/add_book/add_book_sheets_test.dart` -- Create: `lib/widgets/library/online_books_header.dart` -- Create: `test/widgets/library/online_books_header_test.dart` - -- [ ] **Step 1: Add failing add-book choice tests** - -Add tests proving that `Find books online`: - -- is absent when no callback is supplied; -- uses the existing `_ChoiceOption` appearance; -- closes the add-book sheet and invokes the page callback exactly once. - -Use the intended API: - -```dart -await AddBookChoiceSheet.show( - context, - onFindOnline: () => findOnlineCalls++, -); -``` - -- [ ] **Step 2: Run the add-book tests and confirm failure** - -Run: - -```bash -flutter test test/widgets/add_book/add_book_sheets_test.dart -``` - -Expected: failure because `onFindOnline` and the third option do not exist. - -- [ ] **Step 3: Extend the existing choice sheet without duplicating its styling** - -Change the constructor and `show` method: - -```dart -const AddBookChoiceSheet({ - required this.callerContext, - this.onFindOnline, - super.key, -}); - -final VoidCallback? onFindOnline; - -static Future show( - BuildContext context, { - VoidCallback? onFindOnline, -}) { - return showModalBottomSheet( - context: context, - useRootNavigator: true, - useSafeArea: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical( - top: Radius.circular(AppRadius.xl), - ), - ), - builder: (_) => Padding( - padding: const EdgeInsets.only( - left: Spacing.lg, - right: Spacing.lg, - top: Spacing.md, - bottom: Spacing.lg, - ), - child: AddBookChoiceSheet( - callerContext: context, - onFindOnline: onFindOnline, - ), - ), - ); -} -``` - -Append the third `_ChoiceOption` only when `onFindOnline != null`: - -```dart -if (onFindOnline != null) ...[ - const SizedBox(height: Spacing.sm), - _ChoiceOption( - icon: Icons.travel_explore_outlined, - title: 'Find books online', - subtitle: 'Search connected book sources', - onTap: () { - Navigator.of(context).pop(); - onFindOnline!(); - }, - ), -], -``` - -- [ ] **Step 4: Add failing tests for the contextual online header** - -Create widget tests for: - -- Back, `Online results`, and an explicit search field; -- `autofocus: true` when entered from Add book; -- no search callback on ordinary typing; -- search callback on keyboard submit and the search icon; -- disabled submit while searching or when trimmed input is empty; -- compact mobile and desktop layouts under `AppTheme.darkTheme`. - -Use an API shaped like: - -```dart -OnlineBooksHeader( - controller: controller, - autofocus: true, - isSearching: false, - onBack: onBack, - onSearch: (query) => submitted.add(query), -) -``` - -- [ ] **Step 5: Implement the header using existing tokens** - -Create a stateless responsive widget. Use `LayoutBuilder`, `Spacing`, existing header typography, `TextField`, and `IconButton`; do not invent a separate surface/card. - -```dart -void _submit() { - final query = controller.text.trim(); - if (query.isNotEmpty && !isSearching) { - onSearch(query); - } -} -``` - -Wrap the submit control in `ValueListenableBuilder` listening to the supplied controller so its enabled state updates as the user types without making the header stateful. - -Desktop uses one row: Back, title, expanded search. Mobile uses the existing compact header space with title/back above the full-width search field. Give Back and Search explicit tooltips. - -- [ ] **Step 6: Run and commit Task 2** - -Run: - -```bash -dart format lib/widgets/add_book/add_book_choice_sheet.dart lib/widgets/library/online_books_header.dart test/widgets/add_book/add_book_sheets_test.dart test/widgets/library/online_books_header_test.dart -flutter test test/widgets/add_book/add_book_sheets_test.dart test/widgets/library/online_books_header_test.dart -flutter analyze lib/widgets/add_book/add_book_choice_sheet.dart lib/widgets/library/online_books_header.dart -``` - -Then: - -```bash -git add lib/widgets/add_book/add_book_choice_sheet.dart lib/widgets/library/online_books_header.dart test/widgets/add_book/add_book_sheets_test.dart test/widgets/library/online_books_header_test.dart -git commit -m "feat: add online book search entry points" -``` - -## Task 3: Build online result states and retryable row errors - -**Files:** - -- Modify: `lib/widgets/library/remote_release_list.dart` -- Create: `lib/widgets/library/online_results_view.dart` -- Create: `test/widgets/library/remote_release_list_test.dart` -- Create: `test/widgets/library/online_results_view_test.dart` - -- [ ] **Step 1: Write failing result-list tests** - -Cover: - -- full-width rows with checkbox, title, source, format, size, and seeders; -- selected-row treatment using the current color scheme; -- a concise inline error under only the failed release; -- row tap toggles selection; -- semantics expose the title and selected state. - -Extend the constructor: - -```dart -RemoteReleaseList( - releases: releases, - selectedReleaseTokens: const {'token-1'}, - errorsByReleaseToken: const { - 'token-1': 'The download client rejected this release.', - }, - onToggleSelection: toggled.add, -) -``` - -- [ ] **Step 2: Write failing online-content tests** - -Create tests for five mutually exclusive content states: - -```dart -OnlineResultsView( - hasSearched: false, - isSearching: false, - query: '', - error: null, - releases: const [], - selectedReleaseTokens: const {}, - errorsByReleaseToken: const {}, - onRetry: () {}, - onToggleSelection: (_) {}, -) -``` - -Expected states: - -- initial: `Search connected sources`; -- loading: centered progress and query-aware label; -- error: task-focused message and `Try again`; -- empty: `No releases found` with query-aware guidance; -- results: `RemoteReleaseList`. - -- [ ] **Step 3: Run tests and confirm they fail** - -Run: - -```bash -flutter test test/widgets/library/remote_release_list_test.dart test/widgets/library/online_results_view_test.dart -``` - -- [ ] **Step 4: Implement the result widgets** - -Add `errorsByReleaseToken` to `RemoteReleaseList` and place the error below metadata using `colorScheme.error` and `bodySmall`. Preserve existing list padding and separators. - -Implement `OnlineResultsView` as a pure state renderer using `EmptyState` for initial, error, and empty states. It must not initiate requests from `build`. - -The error action is: - -```dart -FilledButton.icon( - onPressed: onRetry, - icon: const Icon(Icons.refresh), - label: const Text('Try again'), -) -``` - -- [ ] **Step 5: Run and commit Task 3** - -Run: - -```bash -dart format lib/widgets/library/remote_release_list.dart lib/widgets/library/online_results_view.dart test/widgets/library/remote_release_list_test.dart test/widgets/library/online_results_view_test.dart -flutter test test/widgets/library/remote_release_list_test.dart test/widgets/library/online_results_view_test.dart -flutter analyze lib/widgets/library/remote_release_list.dart lib/widgets/library/online_results_view.dart -``` - -Then: - -```bash -git add lib/widgets/library/remote_release_list.dart lib/widgets/library/online_results_view.dart test/widgets/library/remote_release_list_test.dart test/widgets/library/online_results_view_test.dart -git commit -m "feat: add contextual online release results" -``` - -## Task 4: Render acquisition jobs as ordinary book placeholders - -**Files:** - -- Create: `lib/widgets/library/acquisition_status_text.dart` -- Create: `lib/widgets/library/acquisition_placeholder_card.dart` -- Create: `lib/widgets/library/acquisition_placeholder_list_item.dart` -- Create: `test/widgets/library/acquisition_placeholder_card_test.dart` -- Create: `test/widgets/library/acquisition_placeholder_list_item_test.dart` -- Modify: `lib/widgets/library/book_grid.dart` -- Modify: `lib/widgets/library/book_card.dart` -- Modify: `lib/widgets/library/book_list_item.dart` -- Modify: `test/widgets/library/book_card_test.dart` -- Create: `test/widgets/library/book_grid_test.dart` - -- [ ] **Step 1: Add failing status and placeholder tests** - -Test every status: - -```dart -AcquisitionJobStatus.queued -AcquisitionJobStatus.submitted -AcquisitionJobStatus.downloading -AcquisitionJobStatus.needsFileSelection -AcquisitionJobStatus.importing -AcquisitionJobStatus.completed -AcquisitionJobStatus.failed -AcquisitionJobStatus.cancelled -AcquisitionJobStatus.unknown -``` - -For a downloading job with metrics, assert: - -- release title; -- `42%`; -- formatted speed such as `1.5 MB/s`; -- formatted ETA such as `3 min remaining`; -- determinate `LinearProgressIndicator`; -- the same card dimensions and radius as `BookCard`. - -For unknown progress, assert an indeterminate or status-only treatment without fake `0%`. - -- [ ] **Step 2: Add failing grid reconciliation tests** - -Use this intended `BookGrid` extension: - -```dart -BookGrid( - books: books, - acquisitionJobsByBookId: {'book-1': linkedJob}, - placeholderJobs: [orphanJob], - selectedAcquisitionJobIds: const {'job-orphan'}, - onAcquisitionTap: tappedJobs.add, - onAcquisitionLongPress: selectedJobs.add, - // existing book callbacks remain unchanged -) -``` - -Give every new constructor argument a backward-compatible default in Task 4 so the existing `LibraryPage` continues to compile until Task 7 wires the new behavior: - -```dart -final List placeholderJobs; -final Set selectedAcquisitionJobIds; -final ValueChanged? onAcquisitionTap; -final ValueChanged? onAcquisitionLongPress; - -// Add these optional named parameters after the existing constructor fields: - this.placeholderJobs = const [], - this.selectedAcquisitionJobIds = const {}, - this.onAcquisitionTap, - this.onAcquisitionLongPress, -``` - -Prove: - -- a job without a synchronized book renders once as an orphan placeholder; -- a job whose `bookId` exists renders through the linked `BookCard`, not twice; -- tapping/long-pressing a linked or orphan job uses acquisition callbacks; -- ordinary books continue using ordinary book callbacks and selection; -- list and grid view can render the same job set. - -- [ ] **Step 3: Run tests and confirm failure** - -Run: - -```bash -flutter test test/widgets/library/acquisition_placeholder_card_test.dart test/widgets/library/acquisition_placeholder_list_item_test.dart test/widgets/library/book_grid_test.dart test/widgets/library/book_card_test.dart -``` - -- [ ] **Step 4: Centralize status and metric formatting** - -Create pure functions: - -```dart -String acquisitionStatusLabel(AcquisitionJob job) => switch (job.status) { - AcquisitionJobStatus.queued || - AcquisitionJobStatus.submitted => 'Queued', - AcquisitionJobStatus.downloading => job.progress == null - ? 'Downloading' - : 'Downloading ${(job.progress! * 100).round()}%', - AcquisitionJobStatus.needsFileSelection => 'Needs attention', - AcquisitionJobStatus.importing => 'Adding to library', - AcquisitionJobStatus.completed => 'Finishing import', - AcquisitionJobStatus.failed => 'Download failed', - AcquisitionJobStatus.cancelled => 'Cancelled', - AcquisitionJobStatus.unknown => 'Needs attention', -}; -``` - -Also add tested `formatBytes(int? bytes)`, `formatSpeed(int bytesPerSecond)`, and `formatEta(int seconds)` helpers. `formatBytes(null)` returns `—`. Reuse them from cards, list items, and the details sheet; remove duplicate private formatters. - -- [ ] **Step 5: Implement the placeholder card and list item** - -Use the ordinary card/list dimensions, radius, typography, selection outline, and title placement. Use a neutral cover: - -```dart -Container( - color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.menu_book_outlined, - size: IconSizes.display, - color: colorScheme.onSurfaceVariant.withValues(alpha: 0.55), - ), -) -``` - -Overlay status and progress within the existing card structure. Do not use remote release artwork or add a separate dashboard-style panel. - -- [ ] **Step 6: Reconcile jobs in `BookGrid`** - -Keep book and job callbacks separate: - -```dart -final acquisitionJob = acquisitionJobsByBookId[book.id]; - -return BookCard( - book: book, - acquisitionJob: acquisitionJob, - isSelected: acquisitionJob == null - ? selectedBookIds.contains(book.id) - : selectedAcquisitionJobIds.contains(acquisitionJob.id), - onTap: acquisitionJob == null - ? () => onBookTap(book) - : () => onAcquisitionTap(acquisitionJob), - onLongPress: acquisitionJob == null - ? () => onBookLongPress(book) - : () => onAcquisitionLongPress(acquisitionJob), -); -``` - -Append `placeholderJobs` after synchronized books. The caller, not `BookGrid`, is responsible for passing only jobs whose `bookId` is not present in the current synchronized book set. - -- [ ] **Step 7: Run and commit Task 4** - -Run: - -```bash -dart format lib/widgets/library/acquisition_status_text.dart lib/widgets/library/acquisition_placeholder_card.dart lib/widgets/library/acquisition_placeholder_list_item.dart lib/widgets/library/book_grid.dart lib/widgets/library/book_card.dart lib/widgets/library/book_list_item.dart test/widgets/library/acquisition_placeholder_card_test.dart test/widgets/library/acquisition_placeholder_list_item_test.dart test/widgets/library/book_grid_test.dart test/widgets/library/book_card_test.dart -flutter test test/widgets/library/acquisition_placeholder_card_test.dart test/widgets/library/acquisition_placeholder_list_item_test.dart test/widgets/library/book_grid_test.dart test/widgets/library/book_card_test.dart -flutter analyze lib/widgets/library/acquisition_status_text.dart lib/widgets/library/acquisition_placeholder_card.dart lib/widgets/library/acquisition_placeholder_list_item.dart lib/widgets/library/book_grid.dart lib/widgets/library/book_card.dart lib/widgets/library/book_list_item.dart -``` - -Then: - -```bash -git add lib/widgets/library/acquisition_status_text.dart lib/widgets/library/acquisition_placeholder_card.dart lib/widgets/library/acquisition_placeholder_list_item.dart lib/widgets/library/book_grid.dart lib/widgets/library/book_card.dart lib/widgets/library/book_list_item.dart test/widgets/library/acquisition_placeholder_card_test.dart test/widgets/library/acquisition_placeholder_list_item_test.dart test/widgets/library/book_grid_test.dart test/widgets/library/book_card_test.dart -git commit -m "feat: show downloads as book placeholders" -``` - -## Task 5: Add the temporary Downloading filter without changing library-domain filters - -**Files:** - -- Modify: `lib/widgets/library/library_filter_chips.dart` -- Create: `test/widgets/library/library_filter_chips_test.dart` -- Create: `lib/widgets/library/acquisition_job_visibility.dart` -- Create: `test/widgets/library/acquisition_job_visibility_test.dart` - -- [ ] **Step 1: Write failing visibility-policy tests** - -Create pure helpers that receive synchronized book IDs and jobs. Prove: - -- active, queued, failed, and attention-required jobs appear in the Downloading filter; -- completed jobs remain as an All-view placeholder only until their `Book` arrives; -- a linked active job filters in its synchronized `Book`; -- an orphan active job filters in its placeholder; -- no duplicate placeholder appears when `bookId` is synchronized; -- the Downloading chip disappears when no filterable job remains. - -Use a result type: - -```dart -class AcquisitionLibraryItems { - final Map linkedJobsByBookId; - final List orphanJobs; - final Set downloadingBookIds; - final List downloadingOrphanJobs; - - bool get hasDownloadingItems => - downloadingBookIds.isNotEmpty || downloadingOrphanJobs.isNotEmpty; -} -``` - -- [ ] **Step 2: Write failing filter-chip tests** - -Extend `LibraryFilterChips` without adding `downloading` to `LibraryFilterType`: - -```dart -LibraryFilterChips( - showDownloading: true, - isDownloadingSelected: true, - onDownloadingTapped: onDownloading, - onLibraryFilterTapped: onLibraryFilter, -) -``` - -Prove: - -- the chip is absent by default; -- it appears after the existing chips when requested; -- selecting it invokes only `onDownloadingTapped`; -- selecting an ordinary chip invokes `onLibraryFilterTapped` so the page can clear downloads-only mode; -- existing filter behavior remains unchanged when callbacks are omitted. - -- [ ] **Step 3: Run tests and confirm failure** - -Run: - -```bash -flutter test test/widgets/library/acquisition_job_visibility_test.dart test/widgets/library/library_filter_chips_test.dart -``` - -- [ ] **Step 4: Implement the pure reconciliation policy** - -Use status policy explicitly: - -```dart -bool isDownloadingFilterStatus(AcquisitionJobStatus status) => switch (status) { - AcquisitionJobStatus.queued || - AcquisitionJobStatus.submitted || - AcquisitionJobStatus.downloading || - AcquisitionJobStatus.needsFileSelection || - AcquisitionJobStatus.importing || - AcquisitionJobStatus.failed || - AcquisitionJobStatus.unknown => true, - AcquisitionJobStatus.completed || - AcquisitionJobStatus.cancelled => false, -}; -``` - -Cancelled jobs remain visible in All until the user removes them. Completed orphan jobs remain visible in All as `Finishing import` until synchronization supplies the book. Neither keeps the Downloading chip visible by itself. - -- [ ] **Step 5: Extend the existing filter wrapper** - -Build the optional dynamic entry with the existing `QuickFilterChipData`; do not duplicate `FilterChip` styling: - -```dart -final filters = _filters - .map( - (filter) => QuickFilterChipData( - label: filter.label, - icon: filter.icon, - isSelected: libraryProvider.isFilterActive(filter.type), - ), - ) - .toList(); - -if (showDownloading) { - filters.add( - QuickFilterChipData( - label: 'Downloading', - icon: Icons.downloading_outlined, - isSelected: isDownloadingSelected, - ), - ); -} -``` - -Dispatch the final index to `onDownloadingTapped`; dispatch ordinary indices through existing provider behavior and then call `onLibraryFilterTapped`. Make all new constructor fields optional with defaults (`false` or `null`) so existing callers compile before Task 7. - -- [ ] **Step 6: Run and commit Task 5** - -Run: - -```bash -dart format lib/widgets/library/library_filter_chips.dart lib/widgets/library/acquisition_job_visibility.dart test/widgets/library/library_filter_chips_test.dart test/widgets/library/acquisition_job_visibility_test.dart -flutter test test/widgets/library/library_filter_chips_test.dart test/widgets/library/acquisition_job_visibility_test.dart -flutter analyze lib/widgets/library/library_filter_chips.dart lib/widgets/library/acquisition_job_visibility.dart -``` - -Then: - -```bash -git add lib/widgets/library/library_filter_chips.dart lib/widgets/library/acquisition_job_visibility.dart test/widgets/library/library_filter_chips_test.dart test/widgets/library/acquisition_job_visibility_test.dart -git commit -m "feat: add downloading books filter" -``` - -## Task 6: Rebuild download details and destructive actions with existing Papyrus surfaces - -**Files:** - -- Modify: `lib/widgets/library/acquisition_job_sheets.dart` -- Create: `test/widgets/library/acquisition_job_sheets_test.dart` -- Create: `lib/widgets/library/acquisition_confirmation_dialog.dart` -- Create: `test/widgets/library/acquisition_confirmation_dialog_test.dart` - -- [ ] **Step 1: Add failing details-sheet tests** - -Test that opening a job uses: - -- `showModalBottomSheet`; -- `BottomSheetHandle`; -- the same `AppRadius.xl`, `Spacing.md/lg`, and headline/body styles as existing Papyrus sheets; -- content-height sizing rather than viewport-filling constraints; -- title, status, progress, downloaded/total bytes, speed, ETA, and selected file when available; -- `Cancel`, `Retry import`, or file choices only when valid for the job. - -Add the new general entry point: - -```dart -showAcquisitionJobDetailsSheet( - context: context, - provider: provider, - job: job, -) -``` - -Keep `showAcquisitionJobAttentionSheet` as a compatibility wrapper that delegates to the new function until `LibraryPage` is switched in Task 7: - -```dart -Future showAcquisitionJobAttentionSheet({ - required BuildContext context, - required AcquisitionDownloadsProvider provider, - required AcquisitionJob job, -}) { - return showAcquisitionJobDetailsSheet( - context: context, - provider: provider, - job: job, - ); -} -``` - -- [ ] **Step 2: Add failing confirmation-dialog tests** - -Test exact surface and destructive treatment: - -```dart -final confirmed = await showAcquisitionConfirmationDialog( - context: context, - title: 'Cancel downloads', - message: 'Cancel 2 selected downloads?', - actionLabel: 'Cancel downloads', -); -``` - -Assert: - -- one `AlertDialog`; -- no `BottomSheet`; -- `TextButton('Cancel')`; -- a `FilledButton` whose background is `colorScheme.error`; -- cancel returns `false`, destructive action returns `true`. - -- [ ] **Step 3: Run tests and confirm failure** - -Run: - -```bash -flutter test test/widgets/library/acquisition_job_sheets_test.dart test/widgets/library/acquisition_confirmation_dialog_test.dart -``` - -- [ ] **Step 4: Implement the standard details sheet** - -Use: - -```dart -await showModalBottomSheet( - context: context, - useRootNavigator: true, - useSafeArea: true, - isScrollControlled: true, - showDragHandle: false, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical( - top: Radius.circular(AppRadius.xl), - ), - ), - builder: (sheetContext) => Padding( - padding: const EdgeInsets.fromLTRB( - Spacing.lg, - Spacing.md, - Spacing.lg, - Spacing.lg, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const BottomSheetHandle(), - const SizedBox(height: Spacing.md), - Text(job.title, style: Theme.of(sheetContext).textTheme.headlineSmall), - const SizedBox(height: Spacing.sm), - Text(acquisitionStatusLabel(job)), - if (job.progress case final progress?) ...[ - const SizedBox(height: Spacing.md), - LinearProgressIndicator(value: progress), - ], - if (job.downloadedBytes != null || job.totalBytes != null) - Text( - '${formatBytes(job.downloadedBytes)} of ' - '${formatBytes(job.totalBytes)}', - ), - if (job.downloadSpeedBytesPerSecond case final speed?) - Text(formatSpeed(speed)), - if (job.etaSeconds case final eta?) Text(formatEta(eta)), - if (job.selectedFilePath case final path?) Text(path), - ], - ), - ), -); -``` - -Append the tested contextual action section after these metrics: supported file rows for `needsFileSelection`, Cancel for `job.canCancel`, and Retry import for `job.canRetryImport`. File selection remains inside this same details flow. Use the shared formatting functions from Task 4. Do not display endpoint IDs, torrent hashes, raw client states, or backend exception text. - -- [ ] **Step 5: Implement the shelf-style confirmation helper** - -Match `ShelvesPage._confirmDeleteShelf`: - -```dart -return showDialog( - context: context, - builder: (dialogContext) => AlertDialog( - title: Text(title), - content: Text(message), - actions: [ - TextButton( - onPressed: () => Navigator.of(dialogContext).pop(false), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () => Navigator.of(dialogContext).pop(true), - style: FilledButton.styleFrom( - backgroundColor: Theme.of(dialogContext).colorScheme.error, - ), - child: Text(actionLabel), - ), - ], - ), -) ?? false; -``` - -- [ ] **Step 6: Run and commit Task 6** - -Run: - -```bash -dart format lib/widgets/library/acquisition_job_sheets.dart lib/widgets/library/acquisition_confirmation_dialog.dart test/widgets/library/acquisition_job_sheets_test.dart test/widgets/library/acquisition_confirmation_dialog_test.dart -flutter test test/widgets/library/acquisition_job_sheets_test.dart test/widgets/library/acquisition_confirmation_dialog_test.dart -flutter analyze lib/widgets/library/acquisition_job_sheets.dart lib/widgets/library/acquisition_confirmation_dialog.dart -``` - -Then: - -```bash -git add lib/widgets/library/acquisition_job_sheets.dart lib/widgets/library/acquisition_confirmation_dialog.dart test/widgets/library/acquisition_job_sheets_test.dart test/widgets/library/acquisition_confirmation_dialog_test.dart -git commit -m "feat: add consistent download actions" -``` - -## Task 7: Replace the bolted-on LibraryPage acquisition UI - -**Files:** - -- Modify: `lib/pages/library_page.dart` -- Modify: `test/pages/library_page_test.dart` -- Delete: `lib/widgets/library/acquisition_job_list.dart` - -- [ ] **Step 1: Replace old page tests with failing approved-flow tests** - -Remove assertions tied to `_buildAcquisitionActions`, a separate downloads screen, and the old `AcquisitionJobList`. - -Add test groups for: - -1. **Local mode** - - ordinary local search makes zero gateway search calls; - - unmatched non-empty query shows `Search online for “Dune”` only when acquisition is ready; - - Add book shows `Find books online` only when ready; - - no acquisition toolbar or Downloads button appears. -2. **Online mode** - - empty-state action carries the current query and searches once; - - Add book enters with empty focused field and does not search; - - Back restores the exact local query/filter/view state; - - loading, empty, error, retry, and results render in place of the grid. -3. **Release selection/submission** - - selection uses `SelectionHeader`; - - select all/deselect all work; - - one client submits immediately; - - multiple clients use a standard content-height choice bottom sheet; - - all-success returns local; - - partial/complete failure stays online with failed rows selected and annotated. -4. **Local placeholders** - - orphan and linked jobs render without duplication; - - completed orphan remains until synchronized book appears; - - Downloading filter isolates linked books and orphan jobs; - - job selection does not mutate ordinary book selection; - - only valid bulk actions appear. -5. **Responsive regression** - - mobile and desktop pump under the real application theme; - - no infinite-width or overflow exception occurs. - -- [ ] **Step 2: Run the page tests and confirm failure** - -Run: - -```bash -flutter test test/pages/library_page_test.dart -``` - -Expected: failures for the approved mode transitions and placeholder integration. - -- [ ] **Step 3: Introduce page-local presentation state** - -Replace `_showDownloads` with: - -```dart -enum _BooksPresentationMode { local, online } - -_BooksPresentationMode _presentationMode = _BooksPresentationMode.local; -bool _showDownloadingOnly = false; -late final TextEditingController _onlineSearchController; -``` - -Initialize/dispose the controller. Add: - -```dart -void _enterOnlineMode( - AcquisitionDownloadsProvider provider, { - String initialQuery = '', - bool submitImmediately = false, -}) { - _onlineSearchController.text = initialQuery; - setState(() => _presentationMode = _BooksPresentationMode.online); - - if (submitImmediately && initialQuery.trim().isNotEmpty) { - unawaited(provider.searchRemote(initialQuery)); - } -} - -void _leaveOnlineMode(AcquisitionDownloadsProvider provider) { - provider.clearRemoteResults(); - setState(() => _presentationMode = _BooksPresentationMode.local); -} -``` - -Do not modify `LibraryProvider.searchQuery`, filters, sort, or selection in either method. - -- [ ] **Step 4: Replace normal header actions** - -Delete `_buildAcquisitionActions` and every caller. - -In local mode, preserve the current desktop/mobile header and filter chips. Pass `onFindOnline` to both mobile and desktop `AddBookChoiceSheet.show` calls only when `downloadsProvider.isManagedAcquisitionReady`. - -In online mode, replace the normal header region with `OnlineBooksHeader`. If release selection is non-empty, replace it with the existing `SelectionHeader`: - -```dart -SelectionHeader( - selectedCount: provider.selectedReleaseTokens.length, - totalCount: provider.remoteResults.length, - onClose: provider.clearReleaseSelection, - onSelectAll: provider.selectAllReleases, - onDeselectAll: provider.clearReleaseSelection, - actions: FilledButton.icon( - onPressed: provider.isSubmitting ? null : () => _submitSelected(provider), - icon: const Icon(Icons.download_outlined), - label: const Text('Download'), - ), -) -``` - -On mobile, use the existing mobile selection action treatment if the button cannot fit safely; do not squeeze controls into an overflowing row. - -- [ ] **Step 5: Replace the content switch** - -Local content: - -```dart -final items = buildAcquisitionLibraryItems( - books: dataStore.books, - jobs: downloadsProvider?.jobs ?? const [], -); -final visibleBooks = _showDownloadingOnly - ? books.where((book) => items.downloadingBookIds.contains(book.id)).toList() - : books; -final visiblePlaceholderJobs = _showDownloadingOnly - ? items.downloadingOrphanJobs - : items.orphanJobs; -``` - -Pass linked jobs and orphan jobs to grid/list rendering. Search orphan placeholders by `job.title` using the current local query before rendering them. Show `Downloading` through `LibraryFilterChips`; selecting it sets `_showDownloadingOnly = true`, while selecting any library filter clears it. - -When local books and matching placeholders are empty for a non-empty local query, configure the existing `EmptyState`: - -```dart -EmptyState( - icon: Icons.search_off, - title: 'No books found', - subtitle: 'No books in your library match “$query”.', - action: downloadsProvider?.isManagedAcquisitionReady == true - ? FilledButton( - onPressed: () => _enterOnlineMode( - downloadsProvider!, - initialQuery: query, - submitImmediately: true, - ), - child: Text('Search online for “$query”'), - ) - : null, -) -``` - -Online content always uses `OnlineResultsView`, replacing the grid in place. - -- [ ] **Step 6: Implement submission transitions and client choice** - -Use the enabled clients from `provider.downloadClients`. - -```dart -Future _submitSelected( - AcquisitionDownloadsProvider provider, -) async { - final client = provider.downloadClients.length == 1 - ? provider.downloadClients.single - : await _chooseDownloadClient(provider.downloadClients); - - if (client == null || !mounted) { - return; - } - - final outcome = await provider.submitSelectedReleases(client.id); - if (!mounted) { - return; - } - - if (outcome.allSucceeded) { - _leaveOnlineMode(provider); - } -} -``` - -The client chooser must use `BottomSheetHandle`, `AppRadius.xl`, existing sheet padding, and content-height `Column(mainAxisSize: MainAxisSize.min)`. Show user-defined client names only. - -Partial/complete failure remains online because `allSucceeded` is false. Row errors and failed selection come directly from provider state. Successful jobs are already available to local placeholders even before leaving online mode. - -- [ ] **Step 7: Implement acquisition selection and actions** - -When a linked or orphan acquisition item is tapped, show `showAcquisitionJobDetailsSheet`. Long press toggles `provider.toggleJobSelection(job.id)`. - -When job selection is non-empty, use `SelectionHeader` with: - -```dart -final selectedJobs = provider.jobs - .where((job) => provider.selectedJobIds.contains(job.id)) - .toList(); -final canCancel = selectedJobs.isNotEmpty && - selectedJobs.every((job) => job.canCancel); -final canRetry = selectedJobs.isNotEmpty && - selectedJobs.every((job) => job.canRetryImport); -final canRemove = selectedJobs.isNotEmpty && - selectedJobs.every( - (job) => job.status == AcquisitionJobStatus.cancelled || - job.status == AcquisitionJobStatus.failed, - ); -``` - -Show only actions valid for the entire selection. Cancel and Remove must first call `showAcquisitionConfirmationDialog`; Retry does not need destructive confirmation. - -- [ ] **Step 8: Delete the obsolete separate job list** - -Remove: - -- import of `acquisition_job_list.dart`; -- `_showDownloads`; -- `_buildAcquisitionActions`; -- the old Downloads content branch; -- `lib/widgets/library/acquisition_job_list.dart`. - -Retain `_visibleDownloadsProvider`, `didChangeDependencies`, `dispose`, and their `setLibraryVisible` calls because they drive polling lifecycle independently of the obsolete screen. Do not remove provider polling or job action methods. - -- [ ] **Step 9: Run page and focused regression tests** - -Run: - -```bash -dart format lib/pages/library_page.dart test/pages/library_page_test.dart -flutter test test/pages/library_page_test.dart -flutter test test/widgets/add_book/add_book_sheets_test.dart test/widgets/library/online_books_header_test.dart test/widgets/library/online_results_view_test.dart test/widgets/library/book_grid_test.dart test/widgets/library/library_filter_chips_test.dart test/widgets/library/acquisition_job_sheets_test.dart test/widgets/library/acquisition_confirmation_dialog_test.dart -flutter analyze lib/pages/library_page.dart -``` - -Expected: all approved flows pass under the real theme with no layout exceptions. - -- [ ] **Step 10: Commit Task 7** - -```bash -git add lib/pages/library_page.dart test/pages/library_page_test.dart -git add -u lib/widgets/library/acquisition_job_list.dart -git commit -m "feat: integrate downloads into books page" -``` - -## Task 8: Verify restoration, replacement, accessibility, and the full client - -**Files:** - -- Modify if required by failures: files changed in Tasks 1–7 only -- Modify: `test/pages/library_page_test.dart` -- Modify: `test/acquisition/acquisition_downloads_provider_test.dart` - -- [ ] **Step 1: Add any missing cross-boundary regression tests** - -Before final verification, ensure tests explicitly prove: - -```dart -// Restored active job after provider refresh: -await provider.refreshJobs(); -expect(provider.jobs.single.status, AcquisitionJobStatus.downloading); - -// Orphan job before synchronization: -expect(find.text('Dune release'), findsOneWidget); - -// Same job after synchronized Book arrives: -dataStore.replaceBooksFromSync([bookWithMatchingId]); -await tester.pump(); -expect(find.byType(AcquisitionPlaceholderCard), findsNothing); -expect(find.text(bookWithMatchingId.title), findsOneWidget); -``` - -Also verify: - -- search and action controls have tooltips/semantic labels; -- keyboard submit works; -- no action depends on hover; -- cancelled/failed cards remain removable in All; -- completed orphan placeholders do not keep the Downloading chip visible; -- gateway search call count remains zero during local typing. - -- [ ] **Step 2: Run formatting checks** - -Run: - -```bash -dart format --output=none --set-exit-if-changed lib test -``` - -Expected: exit 0. - -- [ ] **Step 3: Run the full test suite** - -Run: - -```bash -flutter test -``` - -Expected: all tests pass. - -- [ ] **Step 4: Run full static analysis** - -Run: - -```bash -flutter analyze -``` - -Expected: no issues. - -- [ ] **Step 5: Perform a local manual smoke test before any push** - -With the existing local server and client: - -1. Open Books and type a local query with matches; confirm no remote request and no UI change. -2. Type an unmatched query; confirm the explicit online action appears. -3. Enter online mode, go Back, and confirm local search/filter/view state is unchanged. -4. Search a real Prowlarr source and select multiple releases. -5. Submit to qBittorrent and confirm immediate placeholders in the ordinary Books grid. -6. Confirm progress, speed, and ETA update without opening another page. -7. Reload the app and confirm active placeholders restore. -8. Open a placeholder details sheet and exercise valid non-destructive actions. -9. Cancel one job and verify the shelf-style confirmation dialog and removable cancelled placeholder. -10. Let one download import and confirm the synchronized Book replaces its placeholder without duplication or manual refresh. -11. Repeat the layout check at mobile and desktop widths. - -Record any environment-only limitation rather than weakening tests. - -- [ ] **Step 6: Review the diff against the approved spec** - -Run: - -```bash -git diff --check -git diff --stat -git status --short -rg -n "check your indexers|Search releases|Torrent acquisition" lib test -``` - -Expected: - -- no whitespace errors; -- no stale old acquisition copy; -- no unfinished implementation markers; -- only scoped client files are staged later. - -- [ ] **Step 7: Commit final regression adjustments** - -If Task 8 required code/test adjustments: - -```bash -git add test/pages/library_page_test.dart test/acquisition/acquisition_downloads_provider_test.dart -git commit -m "test: cover books acquisition workflow" -``` - -If no adjustment was needed, do not create an empty commit. - -## Completion gate - -Do not claim completion until all of the following are true: - -- `flutter test` passes. -- `flutter analyze` passes. -- `dart format --output=none --set-exit-if-changed lib test` passes. -- Local typing never calls the acquisition gateway. -- Online results replace the grid only in explicit online mode. -- Full success returns local; partial/complete failure stays online with failed rows selected. -- Job-only and linked placeholders are not duplicated. -- Job selection remains independent of ordinary book selection. -- Cancel and Remove use an `AlertDialog` matching shelf deletion, not a bottom sheet. -- Details and client choice use existing Papyrus bottom-sheet components and content-height sizing. -- The obsolete acquisition toolbar and separate job-list screen are gone. -- Manual smoke testing with real Prowlarr and qBittorrent succeeds before pushing. diff --git a/app/docs/superpowers/specs/2026-07-25-books-acquisition-ux-design.md b/app/docs/superpowers/specs/2026-07-25-books-acquisition-ux-design.md deleted file mode 100644 index 7d5912e..0000000 --- a/app/docs/superpowers/specs/2026-07-25-books-acquisition-ux-design.md +++ /dev/null @@ -1,226 +0,0 @@ -# Books Acquisition UX Design - -Date: 2026-07-25 - -## Objective - -Integrate online book discovery and managed downloads into the existing Books page without introducing a parallel visual system or disrupting ordinary library use. - -The normal Books experience remains local-first. Online discovery is an explicit secondary action, and active downloads appear as temporary books in the same grid until they are imported. - -## Design Principles - -- Preserve the existing Books page for ordinary library browsing. -- Reuse Papyrus typography, spacing, filters, selection headers, dialogs, bottom sheets, and card dimensions. -- Show task language rather than infrastructure language. -- Keep local and online search modes visually and behaviorally distinct. -- Put loading, empty, and error feedback in the content area. -- Make download progress visible without requiring a separate management page. - -## Entry and Local Search - -The normal Books page retains its current header, local search, filter chips, sort control, view toggle, and Add book action. It does not show an acquisition toolbar, download button, or additional row below the search field. - -Typing in the Books search field continues to filter only the local library. - -When a non-empty local search has no matches and online acquisition is available, the existing empty state shows: - -- Title: `No books found` -- Query-aware supporting text -- Primary action: `Search online for “”` - -When acquisition is unavailable, both online entry actions are omitted. The empty state does not expose integration setup, endpoint names, or backend failures. - -The existing Add book bottom sheet gains a third option: - -- Title: `Find books online` -- Subtitle: `Search connected book sources` - -This option uses the same choice-card component, spacing, icon treatment, and navigation behavior as Import digital books and Add physical book. - -Choosing the empty-state action enters online mode with the current query. Choosing Find books online enters online mode with an empty query and focuses the online search field. - -## Online Results Mode - -Online discovery is an in-place mode of the Books page. The application shell and Books destination remain unchanged, while the Books header and content become contextual. - -The online header contains: - -- A Back action that returns to the previous local Books state -- The title `Online results` -- A search field containing the current online query - -Library filters, local sort, grid/list view toggle, and Add book are hidden while online mode is active because they do not apply to release results. - -Remote search runs only when the user explicitly submits the query. Editing the field does not issue requests on every keystroke. - -### Results - -Results use a full-width list because release title, format, size, seeders, and source must be compared directly. They do not imitate library book cards. - -Each row uses existing Papyrus list conventions: - -- Checkbox at the leading edge -- Release title using the existing title style -- One secondary metadata line -- Existing divider and selected-row treatment -- Whole-row selection target - -The metadata line contains, when available: - -- Format -- File size -- Seeders -- Source - -Raw release tokens, endpoint IDs, client hashes, and implementation terminology are never displayed. - -### Selection - -Selecting a result replaces the online header with the existing contextual selection-header pattern: - -- Close selection -- Selected count -- Select all or Deselect all -- Download action - -Closing selection clears selection but remains in online results. Back returns to the local Books state. - -## Submission - -If exactly one enabled download client is available, Download submits the selected releases immediately. - -If multiple clients are available, Papyrus opens a standard choice bottom sheet using the same handle, shape, padding, typography, and choice rows as existing Papyrus sheets. - -The client choice is described by its user-defined name. Internal endpoint details are not shown unless they are necessary to distinguish identically named clients. - -When every selected release is submitted successfully: - -- Successful items become placeholder books in the local grid. -- The page returns to the local Books view. -- Result selection is cleared. - -When submission is partially or completely unsuccessful: - -- Successful items still become placeholder books. -- The page remains in online mode. -- Failed items remain visible with a concise reason and selected for retry. -- Successfully submitted items are removed from the result selection. - -## Download Placeholders - -Each accepted download creates a placeholder card in the ordinary Books grid immediately. - -Placeholder cards use the same dimensions, shape, spacing, and title placement as existing book cards. Their cover area uses a neutral Papyrus treatment rather than release artwork or a visually unrelated status panel. - -The card presents: - -- Book or release title -- Current download state -- Progress bar when progress is known -- Download speed and ETA when available -- `Needs attention` when user action is required - -Placeholder cards participate in the All view and local text search. - -When one or more managed download jobs exist, a temporary `Downloading` filter appears alongside the existing Books filters. It shows active, queued, failed, and attention-required placeholders. It disappears when no managed jobs remain. - -When import completes, the placeholder is replaced by the imported book through the normal synchronized data flow. No manual refresh is required. - -## Download Details and Actions - -Opening a placeholder displays a standard Papyrus bottom sheet with: - -- Title and current status -- Progress, downloaded size, total size, speed, and ETA when available -- Selected file when known -- Contextual actions such as Cancel, Retry import, or Select file - -Jobs requiring file selection present the file choices within this sheet flow. - -Download selection is separate from ordinary book selection so the action set is never ambiguous. Selecting placeholder cards uses the existing selection-header appearance with only actions valid for the selected jobs. - -Available bulk actions: - -- Cancel for active or queued jobs -- Retry for retryable failed imports -- Remove for cancelled or failed jobs - -Cancel and Remove use the same confirmation-dialog appearance and destructive-action treatment as deleting a shelf. Confirmation is not presented as a bottom sheet. - -## Language and Errors - -User-facing language describes tasks and outcomes: - -- `Search online` -- `Online results` -- `Downloading` -- `Needs attention` -- `Download failed` -- `Try again` - -Names such as Prowlarr and qBittorrent appear only in integration settings or when a user-defined client name is needed for client selection. - -Error placement follows the active task: - -- Search errors replace the result content with a concise error state, Retry, and Back. -- Empty searches show an instructional empty state. -- No results show `No online results` and invite a different title, author, or ISBN. -- Submission errors stay attached to the failed result rows. -- Download and import errors appear on the placeholder and in its details sheet. - -Raw backend exception text is logged for diagnostics but mapped to stable user-facing messages. - -## State and Data Flow - -The Books page has two mutually exclusive presentation modes: - -1. Local library -2. Online results - -Online-result selection and placeholder-download selection are contextual substates of those modes. They reuse the established selection-header appearance but keep independent selections and actions. - -Online query and result selection are acquisition state. Local query, filters, sort, view mode, and book selection remain library state. Entering or leaving online mode does not discard the local state. - -Server acquisition jobs remain the source of truth for download status. The client refreshes jobs while the Books page is visible and when the application resumes. Active jobs restore after reload, restart, or another device update. - -Imported books continue to come from the existing synchronized book repository. The UI does not fabricate a completed book from the acquisition job response. - -## Responsive Behavior - -Mobile and desktop use the same state model and language. - -On desktop, the online contextual header occupies the existing Books header region and results use the standard page width and padding. - -On mobile, Back, title, and search wrap into the existing compact header structure. Selection actions use the existing mobile bottom action treatment when they cannot fit safely in the header. - -No control depends on hover, and every row and card action has an accessible label and keyboard focus behavior. - -## Testing - -Widget tests cover: - -- Local search remains local-only. -- The online action appears only for a non-empty, unmatched query when acquisition is ready. -- Add book exposes Find books online only when acquisition is available. -- Local state survives entering and leaving online mode. -- Remote requests run only on explicit submission. -- Loading, empty, success, and error result states. -- Result selection, select all, client choice, successful submission, complete failure, and partial failure. -- Placeholder rendering for each job state. -- Downloading filter visibility and filtering. -- Placeholder selection and valid bulk actions. -- Cancel and Remove confirmation dialogs. -- Completed import replacement. -- Mobile and desktop layouts under the real application theme. - -Provider and gateway tests cover state transitions, polling lifecycle, error mapping, retry behavior, and restoration from server jobs. - -## Non-Goals - -- Automatic remote search on every local keystroke -- Mixing local books and online releases in one result collection -- A separate download-management destination -- Redesigning integration settings -- OPDS, OPFS, or other future source types -- Changing the server acquisition contract unless implementation reveals a blocking contract defect diff --git a/app/pubspec.lock b/app/pubspec.lock index c81bc30..c623638 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -491,10 +491,10 @@ packages: dependency: transitive description: name: matcher - sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.20" + version: "0.12.19" material_color_utilities: dependency: transitive description: @@ -507,10 +507,10 @@ packages: dependency: transitive description: name: meta - sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.19.0" + version: "1.18.0" mobile_scanner: dependency: "direct main" description: @@ -977,10 +977,10 @@ packages: dependency: transitive description: name: test_api - sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.12" + version: "0.7.11" typed_data: dependency: transitive description: @@ -1105,10 +1105,10 @@ packages: dependency: transitive description: name: vector_math - sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47 + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.2.0" vm_service: dependency: transitive description: @@ -1182,5 +1182,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.11.0-0 <4.0.0" + dart: ">=3.10.3 <4.0.0" flutter: ">=3.41.0"