diff --git a/app/fonts/OpenDyslexic/OFL.txt b/app/fonts/OpenDyslexic/OFL.txt new file mode 100644 index 00000000..0a1d034b --- /dev/null +++ b/app/fonts/OpenDyslexic/OFL.txt @@ -0,0 +1,94 @@ +Copyright (c) 2019-07-29, Abbie Gonzalez (https://abbiecod.es|support@abbiecod.es), +with Reserved Font Name OpenDyslexic. +Copyright (c) 12/2012 - 2019 +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/app/fonts/OpenDyslexic/OpenDyslexic-Bold.otf b/app/fonts/OpenDyslexic/OpenDyslexic-Bold.otf new file mode 100644 index 00000000..a3bd88c4 Binary files /dev/null and b/app/fonts/OpenDyslexic/OpenDyslexic-Bold.otf differ diff --git a/app/fonts/OpenDyslexic/OpenDyslexic-BoldItalic.otf b/app/fonts/OpenDyslexic/OpenDyslexic-BoldItalic.otf new file mode 100644 index 00000000..070a0cf5 Binary files /dev/null and b/app/fonts/OpenDyslexic/OpenDyslexic-BoldItalic.otf differ diff --git a/app/fonts/OpenDyslexic/OpenDyslexic-Italic.otf b/app/fonts/OpenDyslexic/OpenDyslexic-Italic.otf new file mode 100644 index 00000000..fe66b97b Binary files /dev/null and b/app/fonts/OpenDyslexic/OpenDyslexic-Italic.otf differ diff --git a/app/fonts/OpenDyslexic/OpenDyslexic-Regular.otf b/app/fonts/OpenDyslexic/OpenDyslexic-Regular.otf new file mode 100644 index 00000000..f0f51d67 Binary files /dev/null and b/app/fonts/OpenDyslexic/OpenDyslexic-Regular.otf differ diff --git a/app/fonts/OpenDyslexic/README.md b/app/fonts/OpenDyslexic/README.md new file mode 100644 index 00000000..f9d5ed5a --- /dev/null +++ b/app/fonts/OpenDyslexic/README.md @@ -0,0 +1,10 @@ +# OpenDyslexic + +Unmodified OpenDyslexic font files from the author's repository: +https://github.com/antijingoist/opendyslexic/tree/1824da5c0e41dc3e13ffc7f3a636dcaf695d61b7/compiled + +Upstream project: https://opendyslexic.org/ +Current source repository: https://forge.hackers.town/antijingoist/opendyslexic + +Bundled under the SIL Open Font License 1.1 in `OFL.txt`. +The app registers this license in Settings > About > Licenses. diff --git a/app/integration_test/support/tracking_app.dart b/app/integration_test/support/tracking_app.dart index d3455a39..d7a45a06 100644 --- a/app/integration_test/support/tracking_app.dart +++ b/app/integration_test/support/tracking_app.dart @@ -168,7 +168,7 @@ class TrackingValidationApp { store.dispose(); preferences.dispose(); fixtureAuth.dispose(); - await database.clearGuestLibrary(); + await database.clearGuestLibrary(deleteFiles: (_) async {}); await database.close(); } } diff --git a/app/lib/auth/auth_api_client.dart b/app/lib/auth/auth_api_client.dart index 308b4d31..de09691a 100644 --- a/app/lib/auth/auth_api_client.dart +++ b/app/lib/auth/auth_api_client.dart @@ -141,6 +141,35 @@ class AuthApiClient { return DateTime.parse(json['deletion_due_at'] as String); } + Future uploadProfilePhoto(String accessToken, Uint8List bytes, {required String displayName}) { + return _withUploadCooldown('/users/me/avatar', accessToken, () async { + final request = http.MultipartRequest('POST', config.endpoint('/users/me/avatar')) + ..headers.addAll(_authHeaders(accessToken)) + ..fields['display_name'] = displayName + ..files.add( + http.MultipartFile.fromBytes('file', bytes, filename: 'avatar.jpg', contentType: MediaType('image', 'jpeg')), + ); + + final response = await http.Response.fromStream(await _httpClient.send(request)); + return PapyrusUser.fromJson(_decodeResponse(response)); + }); + } + + Future downloadProfilePhoto(String accessToken, String avatarUrl) async { + if (!RegExp(r'^/users/me/avatar/[0-9a-fA-F-]{36}$').hasMatch(avatarUrl)) { + throw const AuthApiException(statusCode: 0, message: 'Invalid profile photo'); + } + + final response = await _httpClient.get(config.endpoint(avatarUrl), headers: _authHeaders(accessToken)); + + if (response.statusCode >= 200 && response.statusCode < 300) { + return response.bodyBytes; + } + + _decodeResponse(response); + throw const AuthApiException(statusCode: 0, message: 'Photo download failed'); + } + Future recoverAccount( AccountDeletionAuthorization authorization, { required String clientType, diff --git a/app/lib/auth/auth_repository.dart b/app/lib/auth/auth_repository.dart index 679dff23..9533888b 100644 --- a/app/lib/auth/auth_repository.dart +++ b/app/lib/auth/auth_repository.dart @@ -197,6 +197,16 @@ class AuthRepository { return apiClient.updateCurrentUser(accessToken: accessToken, displayName: displayName, avatarUrl: avatarUrl); } + Future uploadProfilePhoto(Uint8List bytes, {required String displayName}) { + return _withFreshAccessToken((accessToken) { + return apiClient.uploadProfilePhoto(accessToken, bytes, displayName: displayName); + }); + } + + Future downloadProfilePhoto(String avatarUrl) { + return _withFreshAccessToken((accessToken) => apiClient.downloadProfilePhoto(accessToken, avatarUrl)); + } + Future forgotPassword(String email) { return apiClient.forgotPassword(email); } diff --git a/app/lib/config/app_router.dart b/app/lib/config/app_router.dart index b53377e4..822aa235 100644 --- a/app/lib/config/app_router.dart +++ b/app/lib/config/app_router.dart @@ -14,7 +14,6 @@ import 'package:papyrus/pages/catalogs_page.dart'; import 'package:papyrus/pages/catalog_book_page.dart'; import 'package:papyrus/pages/forgot_password_page.dart'; import 'package:papyrus/pages/login_page.dart'; -import 'package:papyrus/pages/edit_profile_page.dart'; import 'package:papyrus/pages/profile_page.dart'; import 'package:papyrus/pages/licenses_page.dart'; import 'package:papyrus/pages/support_page.dart'; @@ -282,11 +281,6 @@ class AppRouter { child: SupportPage(client: SupportApiClient(config: syncSettingsProvider.activeApiConfig)), ), ), - GoRoute( - name: 'EDIT_PROFILE', - path: 'edit', - pageBuilder: (context, state) => NoTransitionPage(key: state.pageKey, child: const EditProfilePage()), - ), ], ), // Developer Options (debug only) diff --git a/app/lib/main.dart b/app/lib/main.dart index fb50a215..de10a436 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -4,7 +4,9 @@ import 'dart:convert'; import 'package:http/http.dart' as http; import 'package:flutter/material.dart'; -import 'package:papyrus/themes/app_motion.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:papyrus/themes/app_accessibility.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'; @@ -24,6 +26,7 @@ import 'package:papyrus/opds/opds_http_client.dart'; import 'package:papyrus/opds/opds_library.dart'; import 'package:papyrus/opds/opds_resource_cache.dart'; import 'package:papyrus/services/book_import_session.dart'; +import 'package:papyrus/services/guest_account_deletion_service.dart'; import 'package:papyrus/platform/book_import_drop_registration.dart'; import 'package:papyrus/powersync/powersync_service.dart'; import 'package:papyrus/powersync/papyrus_powersync_connector.dart'; @@ -52,6 +55,11 @@ Future main() async { ensureBookImportDropPluginRegistered(); usePathUrlStrategy(); + LicenseRegistry.addLicense(() async* { + final license = await rootBundle.loadString('fonts/OpenDyslexic/OFL.txt'); + yield LicenseEntryWithLineBreaks(['OpenDyslexic'], license); + }); + try { await GoalCalendar.deviceTimezone(); } catch (_) { @@ -540,6 +548,15 @@ class _PapyrusState extends State { ChangeNotifierProvider.value(value: _bookStorageStatusController), Provider.value(value: _powerSyncService), Provider.value(value: _bookImportService), + Provider( + create: (_) => GuestAccountDeletionService( + auth: _authProvider, + library: _powerSyncService, + files: _bookImportService, + catalogs: _opdsCatalogs, + downloads: _opdsDownloads, + ), + ), ChangeNotifierProvider.value(value: _opdsCatalogs), ChangeNotifierProvider.value(value: _opdsDownloads), Provider.value(value: _opdsHttpClient), @@ -559,18 +576,34 @@ class _PapyrusState extends State { child: Consumer( builder: (context, preferencesProvider, child) { final isEink = preferencesProvider.isEinkMode; + final lightTheme = preferencesProvider.increaseContrast ? AppTheme.highContrastLight : AppTheme.light; + final darkTheme = preferencesProvider.increaseContrast ? AppTheme.highContrastDark : AppTheme.dark; return MaterialApp.router( title: 'Papyrus', debugShowCheckedModeBanner: false, - theme: isEink ? AppTheme.eink : AppTheme.light, - darkTheme: isEink ? AppTheme.eink : AppTheme.dark, + theme: AppTheme.withTypography( + isEink ? AppTheme.eink : lightTheme, + dyslexiaFont: preferencesProvider.dyslexiaFont, + ), + darkTheme: AppTheme.withTypography( + isEink ? AppTheme.eink : darkTheme, + dyslexiaFont: preferencesProvider.dyslexiaFont, + ), + highContrastTheme: AppTheme.withTypography( + isEink ? AppTheme.eink : AppTheme.highContrastLight, + dyslexiaFont: preferencesProvider.dyslexiaFont, + ), + highContrastDarkTheme: AppTheme.withTypography( + isEink ? AppTheme.eink : AppTheme.highContrastDark, + dyslexiaFont: preferencesProvider.dyslexiaFont, + ), themeMode: isEink ? ThemeMode.light : preferencesProvider.themeMode, - themeAnimationDuration: isEink || preferencesProvider.reduceAnimations + themeAnimationDuration: + isEink || preferencesProvider.reduceAnimations || MediaQuery.disableAnimationsOf(context) ? Duration.zero : kThemeAnimationDuration, - builder: (context, child) => - AppMotionScope(reduceAnimations: isEink || preferencesProvider.reduceAnimations, child: child!), + builder: (context, child) => AppAccessibilityScope(preferences: preferencesProvider, child: child!), routerConfig: _appRouter.router, ); }, diff --git a/app/lib/opds/opds_catalog_store.dart b/app/lib/opds/opds_catalog_store.dart index 8d2b8be4..28ce6004 100644 --- a/app/lib/opds/opds_catalog_store.dart +++ b/app/lib/opds/opds_catalog_store.dart @@ -75,6 +75,16 @@ class OpdsCatalogStore { return result; } + Future clearScope(String scope) => _serialize(() async { + for (final catalog in load(scope)) { + await _secrets.delete(_secretKey(scope, catalog.id)); + } + + if (!await _prefs.remove(_key(scope))) { + throw const OpdsException('Could not remove saved catalogs.'); + } + }); + /// Omitted credentials preserve the existing secret unless the origin changes. Future save(String scope, OpdsCatalog catalog, {OpdsCredentials? credentials, bool clearCredentials = false}) => _serialize(() async { diff --git a/app/lib/opds/opds_catalogs.dart b/app/lib/opds/opds_catalogs.dart index 08490776..678da6a8 100644 --- a/app/lib/opds/opds_catalogs.dart +++ b/app/lib/opds/opds_catalogs.dart @@ -118,6 +118,16 @@ class OpdsCatalogs extends ChangeNotifier { } } + Future clearScope(String scope) async { + await cache?.clearScope(scope); + await library?.clearScope(scope); + await store.clearScope(scope); + + if (_scope == scope && !_disposed) { + reload(); + } + } + @override void dispose() { _disposed = true; diff --git a/app/lib/opds/opds_downloads.dart b/app/lib/opds/opds_downloads.dart index ad608629..d36f6ff5 100644 --- a/app/lib/opds/opds_downloads.dart +++ b/app/lib/opds/opds_downloads.dart @@ -234,6 +234,12 @@ class OpdsDownloads extends ChangeNotifier { _notify(); } + Future cancelAndWait() async { + final operations = _operations.values.toList(); + reset(); + await Future.wait(operations); + } + void _notify() { if (!_disposed) { notifyListeners(); diff --git a/app/lib/opds/opds_library.dart b/app/lib/opds/opds_library.dart index 62140145..82bc3cba 100644 --- a/app/lib/opds/opds_library.dart +++ b/app/lib/opds/opds_library.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:papyrus/data/data_store.dart'; import 'package:papyrus/opds/opds_models.dart'; +import 'package:papyrus/opds/opds_http_client.dart'; import 'package:shared_preferences/shared_preferences.dart'; /// Captured before import, so its result cannot be attributed to another account. @@ -157,6 +158,22 @@ class OpdsLibrary extends ChangeNotifier { return _pending; } + Future clearScope(String scope) async { + if (_scope == scope) { + _generation++; + _imports = {}; + } + + _scopedImports.remove(scope); + await _pending; + + if (!await _prefs.remove(_key(scope))) { + throw const OpdsException('Could not remove catalog import history.'); + } + + _changed(); + } + void _changed() { if (!_disposed) { notifyListeners(); diff --git a/app/lib/opds/opds_resource_cache.dart b/app/lib/opds/opds_resource_cache.dart index 8d14409f..2178d096 100644 --- a/app/lib/opds/opds_resource_cache.dart +++ b/app/lib/opds/opds_resource_cache.dart @@ -165,6 +165,16 @@ class OpdsResourceCache extends ChangeNotifier { String _serialize() => jsonEncode({'version': 1, 'entries': _entries.values.toList()}); + Future clearScope(String scope) { + if (_scope == scope) { + _epoch++; + } + + _entries.removeWhere((key, _) => (jsonDecode(key) as List).first == scope); + notifyListeners(); + return _persist(); + } + void _trim() { while (_entries.isNotEmpty && (_entries.length > maxEntries || utf8.encode(_serialize()).length > maxBytes)) { _entries.remove(_entries.keys.first); diff --git a/app/lib/pages/book_edit_page.dart b/app/lib/pages/book_edit_page.dart index 244f85a9..0c187b8a 100644 --- a/app/lib/pages/book_edit_page.dart +++ b/app/lib/pages/book_edit_page.dart @@ -40,7 +40,7 @@ class _BookEditPageState extends State { static const double _desktopContentMaxWidth = 1120; static const double _desktopCoverPaneWidth = ComponentSizes.bookCoverWidthDesktop; static const double _minimumDesktopFormPaneWidth = 420; - // Section cards already inset their contents by Spacing.md. + // Form sections already inset their contents by Spacing.md. static const double _desktopPaneGap = Spacing.xl - Spacing.md; static const double _desktopPaneBreakpoint = @@ -227,15 +227,18 @@ class _BookEditPageState extends State { } Widget _buildDesktopScaffold(BuildContext context, BookEditProvider provider) { - return Form( - key: _formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildDesktopPageHeader(context, provider), - const Divider(height: 1), - Expanded(child: _buildDesktopLayout(context, provider)), - ], + return Material( + color: Theme.of(context).colorScheme.surface, + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildDesktopPageHeader(context, provider), + const Divider(height: 1), + Expanded(child: _buildDesktopLayout(context, provider)), + ], + ), ), ); } @@ -271,12 +274,9 @@ class _BookEditPageState extends State { return ListView( padding: const EdgeInsets.all(Spacing.md), children: [ - Card( - margin: const EdgeInsets.only(bottom: Spacing.xs), - child: Padding( - padding: const EdgeInsets.all(Spacing.md), - child: _buildCoverSection(context, provider, isDesktop: false), - ), + Padding( + padding: const EdgeInsets.fromLTRB(Spacing.md, Spacing.md, Spacing.md, Spacing.md + Spacing.xs), + child: _buildCoverSection(context, provider, isDesktop: false), ), ..._buildFormSections(context, provider), ], @@ -326,7 +326,7 @@ class _BookEditPageState extends State { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _buildSectionCard( + _buildFormSection( title: 'Fetch metadata', padding: const EdgeInsets.fromLTRB(Spacing.md, 0, Spacing.md, Spacing.md), children: [_buildMetadataSection(context, provider)], @@ -342,20 +342,17 @@ class _BookEditPageState extends State { _buildPublicationSection(), _buildIdentifiersSection(), _buildSeriesSection(), - Card( - margin: EdgeInsets.zero, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: Spacing.md, vertical: Spacing.xs), - child: _buildPhysicalBookSection(context, provider), - ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: Spacing.md, vertical: Spacing.xs), + child: _buildPhysicalBookSection(context, provider), ), if (!skipMetadata) - _buildSectionCard(title: 'Fetch metadata', children: [_buildMetadataSection(context, provider)]), + _buildFormSection(title: 'Fetch metadata', children: [_buildMetadataSection(context, provider)]), ]; } Widget _buildBasicInfoSection(BuildContext context, BookEditProvider provider) { - return _buildSectionCard( + return _buildFormSection( title: 'Basic information', children: [ BookTextField(controller: _titleController, label: 'Title', required: true, onChanged: _provider.updateTitle), @@ -395,7 +392,7 @@ class _BookEditPageState extends State { } Widget _buildPublicationSection() { - return _buildSectionCard( + return _buildFormSection( title: 'Publication details', children: [ ResponsiveFormRow( @@ -431,7 +428,7 @@ class _BookEditPageState extends State { } Widget _buildIdentifiersSection() { - return _buildSectionCard( + return _buildFormSection( title: 'Identifiers', children: [ ResponsiveFormRow( @@ -446,7 +443,7 @@ class _BookEditPageState extends State { } Widget _buildSeriesSection() { - return _buildSectionCard( + return _buildFormSection( title: 'Series', children: [ ResponsiveFormRow( @@ -472,23 +469,20 @@ class _BookEditPageState extends State { ); } - Widget _buildSectionCard({ + Widget _buildFormSection({ required String title, required List children, EdgeInsetsGeometry padding = const EdgeInsets.all(Spacing.md), }) { - return Card( - margin: const EdgeInsets.only(bottom: Spacing.xs), - child: Padding( - padding: padding, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(title, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600)), - const SizedBox(height: Spacing.md), - ...children, - ], - ), + return Padding( + padding: padding.add(const EdgeInsets.only(bottom: Spacing.xs)), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600)), + const SizedBox(height: Spacing.md), + ...children, + ], ), ); } @@ -497,28 +491,31 @@ class _BookEditPageState extends State { final rating = provider.editedBook?.rating ?? 0; final colorScheme = Theme.of(context).colorScheme; - return Row( + return Wrap( + spacing: Spacing.sm, + crossAxisAlignment: WrapCrossAlignment.center, children: [ Text('Rating', style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant)), - const SizedBox(width: Spacing.sm), - ...List.generate(5, (index) { - final starValue = index + 1; - final isSelected = starValue <= rating; - - return GestureDetector( - onTap: () { - _provider.updateRating(starValue == rating ? null : starValue); - }, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 2), - child: Icon( - isSelected ? Icons.star_rounded : Icons.star_outline_rounded, - color: isSelected ? colorScheme.primary : colorScheme.onSurfaceVariant, - size: 28, + Wrap( + children: List.generate(5, (index) { + final starValue = index + 1; + final isSelected = starValue <= rating; + + return Semantics( + selected: starValue == rating, + child: IconButton( + tooltip: starValue == rating ? 'Clear $starValue star rating' : 'Rate $starValue out of 5 stars', + onPressed: () => _provider.updateRating(starValue == rating ? null : starValue), + constraints: const BoxConstraints(minWidth: 48, minHeight: 48), + icon: Icon( + isSelected ? Icons.star_rounded : Icons.star_outline_rounded, + color: isSelected ? colorScheme.primary : colorScheme.onSurfaceVariant, + size: 28, + ), ), - ), - ); - }), + ); + }), + ), ], ); } @@ -532,6 +529,7 @@ class _BookEditPageState extends State { AppMotionControl( value: isPhysical, builder: (focusNode) => SwitchListTile( + hoverColor: Colors.transparent, focusNode: focusNode, title: const Text('Physical book'), value: isPhysical, @@ -590,7 +588,8 @@ class _BookEditPageState extends State { onSelectionChanged: (selection) { provider.setMetadataSource(selection.first); }, - style: const ButtonStyle(visualDensity: VisualDensity.compact, tapTargetSize: MaterialTapTargetSize.shrinkWrap), + direction: MediaQuery.textScalerOf(context).scale(14) > 20 ? Axis.vertical : Axis.horizontal, + style: const ButtonStyle(minimumSize: WidgetStatePropertyAll(Size(48, 48))), ); final searchField = TextFormField( @@ -601,6 +600,7 @@ class _BookEditPageState extends State { border: OutlineInputBorder(borderRadius: BorderRadius.circular(AppRadius.md)), prefixIcon: const Icon(Icons.search), suffixIcon: IconButton( + tooltip: 'Search book metadata', icon: provider.isFetching ? const SizedBox(width: 20, height: 20, child: AppCircularProgressIndicator(strokeWidth: 2)) : const Icon(Icons.arrow_forward), diff --git a/app/lib/pages/edit_profile_page.dart b/app/lib/pages/edit_profile_page.dart deleted file mode 100644 index 173f9da7..00000000 --- a/app/lib/pages/edit_profile_page.dart +++ /dev/null @@ -1,421 +0,0 @@ -import 'package:papyrus/widgets/shared/app_bottom_sheet.dart'; -import 'dart:typed_data'; - -import 'package:file_picker/file_picker.dart'; -import 'package:flutter/material.dart'; -import 'package:papyrus/widgets/shared/app_page_bar.dart'; -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). -/// -/// Reads current values from Papyrus auth and writes back on save. -/// Mobile: full-screen form with AppBar save action. -/// Desktop: top-centered card within the adaptive shell. -class EditProfilePage extends StatefulWidget { - const EditProfilePage({super.key}); - - @override - State createState() => _EditProfilePageState(); -} - -class _EditProfilePageState extends State { - final _formKey = GlobalKey(); - late final TextEditingController _nameController; - bool _isSaving = false; - String? _errorMessage; - - // Photo state — picked image bytes for preview, null means unchanged. - Uint8List? _pickedImageBytes; - bool _photoRemoved = false; - - @override - void initState() { - super.initState(); - final user = context.read().user; - final displayName = user?.displayName ?? ''; - _nameController = TextEditingController(text: displayName); - } - - @override - void dispose() { - _nameController.dispose(); - super.dispose(); - } - - bool get _hasChanges { - final user = context.read().user; - final currentName = user?.displayName ?? ''; - final nameChanged = _nameController.text.trim() != currentName; - return nameChanged || _pickedImageBytes != null || _photoRemoved; - } - - @override - Widget build(BuildContext context) { - final screenWidth = MediaQuery.of(context).size.width; - final isDesktop = screenWidth >= Breakpoints.desktopSmall; - - return Scaffold( - appBar: AppPageBar( - context: context, - title: const Text('Edit profile'), - leading: IconButton(icon: const Icon(Icons.close), onPressed: () => _handleBack(context)), - actions: [ - Padding( - padding: const EdgeInsets.only(right: Spacing.sm), - child: FilledButton( - onPressed: _isSaving ? null : () => _handleSave(context), - child: _isSaving - ? const SizedBox( - width: 20, - height: 20, - child: AppCircularProgressIndicator(strokeWidth: 2, color: Colors.white), - ) - : const Text('Save'), - ), - ), - ], - ), - body: SafeArea(child: isDesktop ? _buildDesktopBody(context) : _buildMobileBody(context)), - ); - } - - Widget _buildMobileBody(BuildContext context) { - return SingleChildScrollView(padding: const EdgeInsets.all(Spacing.md), child: _buildForm(context)); - } - - Widget _buildDesktopBody(BuildContext context) { - return Align( - alignment: Alignment.topCenter, - child: SingleChildScrollView( - padding: const EdgeInsets.all(Spacing.xl), - child: ConstrainedBox(constraints: const BoxConstraints(maxWidth: 480), child: _buildForm(context)), - ), - ); - } - - Widget _buildForm(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final textTheme = Theme.of(context).textTheme; - - return Form( - key: _formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Avatar - Center(child: _buildAvatarEditor(context)), - const SizedBox(height: Spacing.xl), - - // Error banner - if (_errorMessage != null) ...[_buildErrorBanner(context), const SizedBox(height: Spacing.md)], - - // Display name - Text('Display name', style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)), - const SizedBox(height: Spacing.sm), - TextFormField( - controller: _nameController, - textCapitalization: TextCapitalization.words, - textInputAction: TextInputAction.done, - decoration: const InputDecoration(hintText: 'Enter your name'), - validator: (value) { - if (value == null || value.trim().isEmpty) { - return 'Name cannot be empty'; - } - - if (value.trim().length > 100) { - return 'Name is too long'; - } - - return null; - }, - autovalidateMode: AutovalidateMode.onUserInteraction, - onChanged: (_) => setState(() {}), - ), - const SizedBox(height: Spacing.lg), - - // Email (read-only) - Text('Email', style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)), - const SizedBox(height: Spacing.sm), - TextFormField(initialValue: _getEmail(), enabled: false, decoration: const InputDecoration()), - ], - ), - ); - } - - Widget _buildErrorBanner(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final textTheme = Theme.of(context).textTheme; - - return Container( - width: double.infinity, - padding: const EdgeInsets.all(Spacing.md), - decoration: BoxDecoration(color: colorScheme.errorContainer, borderRadius: BorderRadius.circular(AppRadius.md)), - child: Row( - children: [ - Icon(Icons.error_outline, color: colorScheme.onErrorContainer, size: IconSizes.medium), - const SizedBox(width: Spacing.sm), - Expanded( - child: Text(_errorMessage!, style: textTheme.bodyMedium?.copyWith(color: colorScheme.onErrorContainer)), - ), - ], - ), - ); - } - - Widget _buildAvatarEditor(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - const size = 120.0; - - return GestureDetector( - onTap: () => _showPhotoOptions(context), - child: Stack( - children: [ - Container( - width: size, - height: size, - decoration: BoxDecoration(shape: BoxShape.circle, color: colorScheme.primaryContainer), - clipBehavior: Clip.antiAlias, - child: _buildAvatarImage(context, size), - ), - Positioned( - bottom: 0, - right: 0, - child: Container( - width: 36, - height: 36, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: colorScheme.primary, - border: Border.all(color: colorScheme.surface, width: 2), - ), - child: Icon(Icons.camera_alt, size: IconSizes.small, color: colorScheme.onPrimary), - ), - ), - ], - ), - ); - } - - Widget _buildAvatarImage(BuildContext context, double size) { - final colorScheme = Theme.of(context).colorScheme; - - final initialsWidget = Center( - child: Text( - _initials, - style: Theme.of( - context, - ).textTheme.headlineMedium?.copyWith(color: colorScheme.onPrimaryContainer, fontSize: size * 0.35), - ), - ); - - // Show picked image if available. - if (_pickedImageBytes != null) { - return Image.memory( - _pickedImageBytes!, - fit: BoxFit.cover, - width: size, - height: size, - errorBuilder: (_, _, _) => initialsWidget, - ); - } - - // Show existing network photo if not removed. - if (!_photoRemoved) { - final photoUrl = context.read().user?.avatarUrl; - - if (photoUrl != null && photoUrl.isNotEmpty) { - return Image.network( - photoUrl, - fit: BoxFit.cover, - width: size, - height: size, - errorBuilder: (_, _, _) => initialsWidget, - ); - } - } - - return initialsWidget; - } - - bool get _hasExistingPhoto { - if (_photoRemoved) { - return false; - } - - if (_pickedImageBytes != null) { - return true; - } - - final photoUrl = context.read().user?.avatarUrl; - return photoUrl != null && photoUrl.isNotEmpty; - } - - void _showPhotoOptions(BuildContext context) { - showModalBottomSheet( - sheetAnimationStyle: AppMotion.animationStyle(context), - context: context, - isScrollControlled: true, - useSafeArea: true, - builder: (sheetContext) => AppBottomSheet( - header: Text( - 'Profile photo', - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - body: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - ListTile( - leading: const Icon(Icons.photo_library_outlined), - title: const Text('Choose from gallery'), - onTap: () { - Navigator.pop(sheetContext); - _pickImage(); - }, - ), - if (_hasExistingPhoto) - ListTile( - leading: Icon(Icons.delete_outline, color: Theme.of(context).colorScheme.error), - title: Text('Remove photo', style: TextStyle(color: Theme.of(context).colorScheme.error)), - onTap: () { - Navigator.pop(sheetContext); - - setState(() { - _pickedImageBytes = null; - _photoRemoved = true; - }); - }, - ), - ], - ), - ), - ); - } - - Future _pickImage() async { - try { - final result = await FilePicker.platform.pickFiles(type: FileType.image, withData: true); - - if (result != null && result.files.single.bytes != null) { - setState(() { - _pickedImageBytes = result.files.single.bytes; - _photoRemoved = false; - }); - } - } catch (error) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - snackBarAnimationStyle: AppMotion.animationStyle(context), - const SnackBar(content: Text('Could not open image picker')), - ); - } - } - } - - Future _handleSave(BuildContext context) async { - if (!_formKey.currentState!.validate()) { - return; - } - - setState(() { - _isSaving = true; - _errorMessage = null; - }); - - try { - final authProvider = context.read(); - final user = authProvider.user; - - if (user == null) { - setState(() { - _errorMessage = 'Not signed in'; - _isSaving = false; - }); - - return; - } - - final newName = _nameController.text.trim(); - final currentName = user.displayName; - - if (newName != currentName) { - final success = await authProvider.updateProfile(displayName: newName); - - if (!success) { - setState(() { - _errorMessage = authProvider.error ?? 'Failed to update profile'; - _isSaving = false; - }); - - return; - } - } - - if (context.mounted) { - context.pop(); - } - } catch (error) { - setState(() { - _errorMessage = 'Failed to update profile'; - _isSaving = false; - }); - } - } - - void _handleBack(BuildContext context) { - if (_hasChanges) { - showDialog( - animationStyle: AppMotion.animationStyle(context), - context: context, - builder: (dialogContext) => AlertDialog( - title: const Text('Discard changes?'), - content: const Text('You have unsaved changes. Are you sure you want to discard them?'), - actions: [ - TextButton(onPressed: () => Navigator.pop(dialogContext), child: const Text('Keep editing')), - FilledButton( - onPressed: () { - Navigator.pop(dialogContext); - context.pop(); - }, - child: const Text('Discard'), - ), - ], - ), - ); - } else { - context.pop(); - } - } - - String _getEmail() { - final email = context.read().user?.email; - - if (email == null || email.trim().isEmpty) { - return 'No email provided'; - } - - return email; - } - - String get _initials { - final name = _nameController.text.trim(); - - if (name.isEmpty) { - return '?'; - } - - final parts = name.split(' '); - - if (parts.length >= 2) { - return '${parts[0][0]}${parts[1][0]}'.toUpperCase(); - } - - return name[0].toUpperCase(); - } -} diff --git a/app/lib/pages/forgot_password_page.dart b/app/lib/pages/forgot_password_page.dart index cee4d889..8cae0dd5 100644 --- a/app/lib/pages/forgot_password_page.dart +++ b/app/lib/pages/forgot_password_page.dart @@ -171,7 +171,7 @@ class _ForgotPasswordPageState extends State { snackBarAnimationStyle: AppMotion.animationStyle(context), SnackBar( duration: const Duration(seconds: 5), - content: Text(message), + content: Text(message, style: TextStyle(color: Theme.of(context).colorScheme.onError)), backgroundColor: Theme.of(context).colorScheme.error, ), ); @@ -276,22 +276,24 @@ class _ForgotPasswordForm extends StatelessWidget { @override Widget build(BuildContext context) { - return Form( - key: formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, - children: [ - EmailInput( - labelText: 'Email address', - controller: emailController, - focusNode: emailFocusNode, - textInputAction: TextInputAction.done, - onEditingComplete: onSubmit, - ), - const SizedBox(height: Spacing.lg), - AuthContinueButton(isLoading: isLoading, onPressed: onSubmit, isDesktop: isDesktop), - ], + return AutofillGroup( + child: Form( + key: formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + EmailInput( + labelText: 'Email address', + controller: emailController, + focusNode: emailFocusNode, + textInputAction: TextInputAction.done, + onEditingComplete: onSubmit, + ), + const SizedBox(height: Spacing.lg), + AuthContinueButton(isLoading: isLoading, onPressed: onSubmit, isDesktop: isDesktop), + ], + ), ), ); } @@ -362,33 +364,37 @@ class _SetNewPasswordForm extends StatelessWidget { @override Widget build(BuildContext context) { - return Form( - key: formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, - children: [ - PasswordInput( - labelText: 'New password', - controller: passwordController, - focusNode: passwordFocusNode, - textInputAction: TextInputAction.next, - extraValidator: validatePasswordStrength, - onEditingComplete: () => confirmPasswordFocusNode.requestFocus(), - ), - const SizedBox(height: Spacing.md), - PasswordInput( - labelText: 'Confirm new password', - controller: confirmPasswordController, - focusNode: confirmPasswordFocusNode, - textInputAction: TextInputAction.done, - extraValidator: validateConfirmPassword, - validationDependency: passwordController, - onFieldSubmitted: (_) => onSubmit(), - ), - const SizedBox(height: Spacing.lg), - AuthContinueButton(isLoading: isLoading, onPressed: onSubmit, isDesktop: isDesktop), - ], + return AutofillGroup( + child: Form( + key: formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + PasswordInput( + autofillHints: const [AutofillHints.newPassword], + labelText: 'New password', + controller: passwordController, + focusNode: passwordFocusNode, + textInputAction: TextInputAction.next, + extraValidator: validatePasswordStrength, + onEditingComplete: () => confirmPasswordFocusNode.requestFocus(), + ), + const SizedBox(height: Spacing.md), + PasswordInput( + autofillHints: const [AutofillHints.newPassword], + labelText: 'Confirm new password', + controller: confirmPasswordController, + focusNode: confirmPasswordFocusNode, + textInputAction: TextInputAction.done, + extraValidator: validateConfirmPassword, + validationDependency: passwordController, + onFieldSubmitted: (_) => onSubmit(), + ), + const SizedBox(height: Spacing.lg), + AuthContinueButton(isLoading: isLoading, onPressed: onSubmit, isDesktop: isDesktop), + ], + ), ), ); } diff --git a/app/lib/pages/login_page.dart b/app/lib/pages/login_page.dart index 5afbb43f..3fb133aa 100644 --- a/app/lib/pages/login_page.dart +++ b/app/lib/pages/login_page.dart @@ -97,7 +97,7 @@ class _LoginPageState extends State { snackBarAnimationStyle: AppMotion.animationStyle(context), SnackBar( duration: const Duration(seconds: 5), - content: Text(message), + content: Text(message, style: TextStyle(color: Theme.of(context).colorScheme.onError)), backgroundColor: Theme.of(context).colorScheme.error, ), ); @@ -188,41 +188,43 @@ class _LoginForm extends StatelessWidget { @override Widget build(BuildContext context) { - return Form( - key: formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, - children: [ - EmailInput( - labelText: 'Email address', - controller: emailController, - focusNode: emailFocusNode, - textInputAction: TextInputAction.next, - onEditingComplete: () => passwordFocusNode.requestFocus(), - ), - const SizedBox(height: Spacing.md), - PasswordInput( - labelText: 'Password', - controller: passwordController, - focusNode: passwordFocusNode, - textInputAction: TextInputAction.done, - onFieldSubmitted: (_) => onLogin(), - ), - const SizedBox(height: Spacing.sm), - Align( - alignment: Alignment.centerRight, - child: TextButton( - onPressed: () { - context.push('/forgot-password'); - }, - style: TextButton.styleFrom(foregroundColor: Theme.of(context).colorScheme.primary), - child: const Text('Forgot password?', style: TextStyle(fontWeight: FontWeight.w500)), + return AutofillGroup( + child: Form( + key: formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + EmailInput( + labelText: 'Email address', + controller: emailController, + focusNode: emailFocusNode, + textInputAction: TextInputAction.next, + onEditingComplete: () => passwordFocusNode.requestFocus(), ), - ), - const SizedBox(height: Spacing.sm), - AuthContinueButton(isLoading: isLoading, onPressed: onLogin, isDesktop: isDesktop), - ], + const SizedBox(height: Spacing.md), + PasswordInput( + labelText: 'Password', + controller: passwordController, + focusNode: passwordFocusNode, + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) => onLogin(), + ), + const SizedBox(height: Spacing.sm), + Align( + alignment: Alignment.centerRight, + child: TextButton( + onPressed: () { + context.push('/forgot-password'); + }, + style: TextButton.styleFrom(foregroundColor: Theme.of(context).colorScheme.primary), + child: const Text('Forgot password?', style: TextStyle(fontWeight: FontWeight.w500)), + ), + ), + const SizedBox(height: Spacing.sm), + AuthContinueButton(isLoading: isLoading, onPressed: onLogin, isDesktop: isDesktop), + ], + ), ), ); } diff --git a/app/lib/pages/profile_page.dart b/app/lib/pages/profile_page.dart index 00a7c905..793561cd 100644 --- a/app/lib/pages/profile_page.dart +++ b/app/lib/pages/profile_page.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:papyrus/widgets/shared/app_page_bar.dart'; import 'package:go_router/go_router.dart'; @@ -11,27 +10,18 @@ import 'package:papyrus/widgets/profile/about_settings_section.dart'; import 'package:papyrus/widgets/profile/accessibility_settings_section.dart'; import 'package:papyrus/widgets/profile/account_settings_section.dart'; import 'package:papyrus/widgets/profile/appearance_settings_section.dart'; -import 'package:papyrus/widgets/profile/developer_settings_section.dart'; -import 'package:papyrus/widgets/profile/library_settings_section.dart'; import 'package:papyrus/widgets/profile/notifications_settings_section.dart'; import 'package:papyrus/widgets/profile/privacy_settings_section.dart'; -import 'package:papyrus/widgets/profile/profile_header.dart'; import 'package:papyrus/widgets/profile/profile_menu_item.dart'; -import 'package:papyrus/widgets/profile/reading_settings_section.dart'; import 'package:papyrus/widgets/profile/storage_sync_settings_section.dart'; import 'package:provider/provider.dart'; enum _ProfileSection { account, appearance, - reading, - library, - notifications, storageSync, - privacyData, accessibility, about, - developerOptions, } /// User profile page with account settings, preferences, and app configuration. @@ -62,23 +52,18 @@ class _ProfilePageState extends State { Widget _buildMobileLayout(BuildContext context) { return Scaffold( - appBar: AppPageBar(context: context, title: const Text('Profile')), + appBar: AppPageBar(context: context, title: const Text('Settings')), body: SafeArea( child: ListView( padding: const EdgeInsets.all(Spacing.md), children: [ - _buildMobileHeader(context), - const SizedBox(height: Spacing.md), const AccountSettingsSection(isDesktop: false), const AppearanceSettingsSection(isDesktop: false), - const ReadingSettingsSection(isDesktop: false), - const LibrarySettingsSection(isDesktop: false), const NotificationsSettingsSection(isDesktop: false), const StorageSyncSettingsSection(isDesktop: false), StorageSyncSettingsSection.buildMobileAcquisitionSection(context), const PrivacySettingsSection(isDesktop: false), const AccessibilitySettingsSection(isDesktop: false), - if (kDebugMode) const DeveloperSettingsSection(isDesktop: false), const AboutSettingsSection(isDesktop: false), const Divider(height: 1), ProfileMenuItem( @@ -95,29 +80,15 @@ class _ProfilePageState extends State { ); } - Widget _buildMobileHeader(BuildContext context) { - final user = context.watch().user; - final displayName = user?.displayName ?? 'Anonymous User'; - final userEmail = user?.email; - final email = (userEmail != null && userEmail.trim().isNotEmpty) ? userEmail : 'No email provided'; - - return ProfileHeader( - displayName: displayName, - email: email, - avatarUrl: user?.avatarUrl, - onEditProfile: () => context.go('/profile/edit'), - isDesktopLayout: false, - ); - } - Widget _buildDesktopLayout(BuildContext context) { return Scaffold( body: SafeArea( child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _buildDesktopNav(context), + FocusTraversalGroup(child: _buildDesktopNav(context)), const VerticalDivider(width: 1), - Expanded(child: _buildDesktopContent(context)), + Expanded(child: FocusTraversalGroup(child: _buildDesktopContent(context))), ], ), ), @@ -149,23 +120,11 @@ class _ProfilePageState extends State { label: 'Appearance', section: _ProfileSection.appearance, ), - _buildNavItem( - context, - icon: Icons.auto_stories_outlined, - label: 'Reading', - section: _ProfileSection.reading, - ), - _buildNavItem( - context, - icon: Icons.library_books_outlined, - label: 'Library', - section: _ProfileSection.library, - ), _buildNavItem( context, icon: Icons.notifications_outlined, label: 'Notifications', - section: _ProfileSection.notifications, + enabled: false, ), _buildNavItem( context, @@ -177,7 +136,7 @@ class _ProfilePageState extends State { context, icon: Icons.shield_outlined, label: 'Privacy', - section: _ProfileSection.privacyData, + enabled: false, ), _buildNavItem( context, @@ -185,13 +144,6 @@ class _ProfilePageState extends State { label: 'Accessibility', section: _ProfileSection.accessibility, ), - if (kDebugMode) - _buildNavItem( - context, - icon: Icons.code, - label: 'Developer options', - section: _ProfileSection.developerOptions, - ), _buildNavItem(context, icon: Icons.info_outline, label: 'About', section: _ProfileSection.about), const SizedBox(height: Spacing.sm), Divider(height: 1, color: colorScheme.outlineVariant), @@ -218,6 +170,7 @@ class _ProfilePageState extends State { required IconData icon, required String label, _ProfileSection? section, + bool enabled = true, bool isDestructive = false, VoidCallback? onTap, }) { @@ -241,34 +194,33 @@ class _ProfilePageState extends State { return Padding( padding: const EdgeInsets.symmetric(vertical: 2), - child: Material( - color: bgColor, - borderRadius: BorderRadius.circular(AppRadius.md), - child: InkWell( - onTap: - onTap ?? - () { - if (section != null) { - setState(() => _selectedSection = section); - } - }, + child: Semantics( + enabled: enabled, + button: true, + child: Material( + color: bgColor, borderRadius: BorderRadius.circular(AppRadius.md), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: Spacing.md, vertical: Spacing.sm + 2), - child: Row( - children: [ - Icon(icon, color: iconColor, size: IconSizes.medium), - const SizedBox(width: Spacing.md), - Expanded( - child: Text( - label, - style: textTheme.bodyMedium?.copyWith( - color: textColor, - fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, + child: InkWell( + canRequestFocus: enabled, + onTap: enabled ? onTap ?? () => setState(() => _selectedSection = section!) : null, + borderRadius: BorderRadius.circular(AppRadius.md), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: Spacing.md, vertical: Spacing.sm + 2), + child: Row( + children: [ + Icon(icon, color: enabled ? iconColor : Theme.of(context).disabledColor, size: IconSizes.medium), + const SizedBox(width: Spacing.md), + Expanded( + child: Text( + label, + style: textTheme.bodyMedium?.copyWith( + color: enabled ? textColor : Theme.of(context).disabledColor, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, + ), ), ), - ), - ], + ], + ), ), ), ), @@ -279,10 +231,10 @@ class _ProfilePageState extends State { Widget _buildDesktopContent(BuildContext context) { final textTheme = Theme.of(context).textTheme; - return Align( - alignment: Alignment.topLeft, - child: SingleChildScrollView( - padding: const EdgeInsets.all(Spacing.xl), + return SingleChildScrollView( + padding: const EdgeInsets.all(Spacing.xl), + child: Align( + alignment: Alignment.topLeft, child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 600), child: Column( @@ -304,20 +256,10 @@ class _ProfilePageState extends State { return 'Account'; case _ProfileSection.appearance: return 'Appearance'; - case _ProfileSection.reading: - return 'Reading'; - case _ProfileSection.library: - return 'Library'; - case _ProfileSection.notifications: - return 'Notifications'; case _ProfileSection.storageSync: return 'Storage'; - case _ProfileSection.privacyData: - return 'Privacy & data'; case _ProfileSection.accessibility: return 'Accessibility'; - case _ProfileSection.developerOptions: - return 'Developer options'; case _ProfileSection.about: return 'About'; } @@ -329,22 +271,12 @@ class _ProfilePageState extends State { return const AccountSettingsSection(isDesktop: true); case _ProfileSection.appearance: return const AppearanceSettingsSection(isDesktop: true); - case _ProfileSection.reading: - return const ReadingSettingsSection(isDesktop: true); - case _ProfileSection.library: - return const LibrarySettingsSection(isDesktop: true); - case _ProfileSection.notifications: - return const NotificationsSettingsSection(isDesktop: true); case _ProfileSection.storageSync: return const StorageSyncSettingsSection(isDesktop: true); - case _ProfileSection.privacyData: - return const PrivacySettingsSection(isDesktop: true); case _ProfileSection.accessibility: return const AccessibilitySettingsSection(isDesktop: true); case _ProfileSection.about: return const AboutSettingsSection(isDesktop: true); - case _ProfileSection.developerOptions: - return const DeveloperSettingsSection(isDesktop: true); } } diff --git a/app/lib/pages/reader_page.dart b/app/lib/pages/reader_page.dart index b5521923..058ad24b 100644 --- a/app/lib/pages/reader_page.dart +++ b/app/lib/pages/reader_page.dart @@ -1,6 +1,7 @@ import 'package:papyrus/services/reading_device_identity.dart'; import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:papyrus/themes/app_accessibility.dart'; import 'package:papyrus/widgets/shared/app_page_bar.dart'; import 'package:papyrus/reader/reading_activity_tracker.dart'; import 'package:papyrus/providers/enums/library_reading_status.dart'; @@ -233,20 +234,22 @@ class _ReaderPageState extends State { ); } - return PapyrusReader( - document: document, - initialLocator: _initialLocator, - initialPreferences: preferences, - builders: ReaderUiBuilders( - loading: (context, state) => const _ReaderLoading(), - compactPanelRoute: (_, panel) => buildReaderPanelSheet(context, panel), + return ReaderSystemTextScope( + child: PapyrusReader( + document: document, + initialLocator: _initialLocator, + initialPreferences: preferences, + builders: ReaderUiBuilders( + loading: (context, state) => const _ReaderLoading(), + compactPanelRoute: (_, panel) => buildReaderPanelSheet(context, panel), + ), + onLocatorChanged: _session!.updateLocator, + onActivity: _onActivity, + onPreferencesChanged: (updated) { + ReaderBookAdapter.persistPreferences(context.read(), updated); + }, + onBack: _close, ), - onLocatorChanged: _session!.updateLocator, - onActivity: _onActivity, - onPreferencesChanged: (updated) { - ReaderBookAdapter.persistPreferences(context.read(), updated); - }, - onBack: _close, ); } diff --git a/app/lib/pages/register_page.dart b/app/lib/pages/register_page.dart index 565339a9..6024cd14 100644 --- a/app/lib/pages/register_page.dart +++ b/app/lib/pages/register_page.dart @@ -97,7 +97,7 @@ class _RegisterPageState extends State { snackBarAnimationStyle: AppMotion.animationStyle(context), SnackBar( duration: const Duration(seconds: 5), - content: Text(message), + content: Text(message, style: TextStyle(color: Theme.of(context).colorScheme.onError)), backgroundColor: Theme.of(context).colorScheme.error, ), ); @@ -228,49 +228,53 @@ class _RegisterForm extends StatelessWidget { @override Widget build(BuildContext context) { - return Form( - key: formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, - children: [ - NameInput( - labelText: 'Display name', - controller: displayNameController, - focusNode: displayNameFocusNode, - textInputAction: TextInputAction.next, - onEditingComplete: () => emailFocusNode.requestFocus(), - ), - const SizedBox(height: Spacing.md), - EmailInput( - labelText: 'Email address', - controller: emailController, - focusNode: emailFocusNode, - textInputAction: TextInputAction.next, - onEditingComplete: () => passwordFocusNode.requestFocus(), - ), - const SizedBox(height: Spacing.md), - PasswordInput( - labelText: 'Password', - controller: passwordController, - focusNode: passwordFocusNode, - textInputAction: TextInputAction.next, - onEditingComplete: () => confirmPasswordFocusNode.requestFocus(), - extraValidator: validatePasswordStrength, - ), - const SizedBox(height: Spacing.md), - PasswordInput( - labelText: 'Confirm password', - controller: confirmPasswordController, - focusNode: confirmPasswordFocusNode, - textInputAction: TextInputAction.done, - onFieldSubmitted: (_) => onRegister(), - extraValidator: validateConfirmPassword, - validationDependency: passwordController, - ), - const SizedBox(height: Spacing.lg), - AuthContinueButton(isLoading: isLoading, onPressed: onRegister, isDesktop: isDesktop), - ], + return AutofillGroup( + child: Form( + key: formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + NameInput( + labelText: 'Display name', + controller: displayNameController, + focusNode: displayNameFocusNode, + textInputAction: TextInputAction.next, + onEditingComplete: () => emailFocusNode.requestFocus(), + ), + const SizedBox(height: Spacing.md), + EmailInput( + labelText: 'Email address', + controller: emailController, + focusNode: emailFocusNode, + textInputAction: TextInputAction.next, + onEditingComplete: () => passwordFocusNode.requestFocus(), + ), + const SizedBox(height: Spacing.md), + PasswordInput( + autofillHints: const [AutofillHints.newPassword], + labelText: 'Password', + controller: passwordController, + focusNode: passwordFocusNode, + textInputAction: TextInputAction.next, + onEditingComplete: () => confirmPasswordFocusNode.requestFocus(), + extraValidator: validatePasswordStrength, + ), + const SizedBox(height: Spacing.md), + PasswordInput( + autofillHints: const [AutofillHints.newPassword], + labelText: 'Confirm password', + controller: confirmPasswordController, + focusNode: confirmPasswordFocusNode, + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) => onRegister(), + extraValidator: validateConfirmPassword, + validationDependency: passwordController, + ), + const SizedBox(height: Spacing.lg), + AuthContinueButton(isLoading: isLoading, onPressed: onRegister, isDesktop: isDesktop), + ], + ), ), ); } diff --git a/app/lib/powersync/powersync_service.dart b/app/lib/powersync/powersync_service.dart index 8fc1c6c3..0b15be97 100644 --- a/app/lib/powersync/powersync_service.dart +++ b/app/lib/powersync/powersync_service.dart @@ -187,24 +187,40 @@ class PapyrusPowerSyncService implements BookRepository, LibraryRepository, Trac await database.connect(connector: connectorFactory()); } - Future clearGuestLibrary() async { - await _modeOperation; + /// Retains book records until their files are removed so failures can be retried. + Future clearGuestLibrary({required Future Function(List) deleteFiles}) { + final previousOperation = _modeOperation; - if (_mode != LibraryDatabaseMode.guest) { - throw StateError('Only guest libraries can be cleared with clearGuestLibrary'); - } + final operation = (() async { + await previousOperation; - final database = _requireDatabase(); + if (_mode != LibraryDatabaseMode.guest) { + throw StateError('Only the active guest library can be deleted.'); + } + + final database = _requireDatabase(); + final snapshot = await _activeLibrary.snapshot(); + await deleteFiles(snapshot.books); + + await database.writeTransaction((tx) async { + for (final table in libraryTableNames.reversed) { + await tx.execute('DELETE FROM $table'); + } + }); - await database.writeTransaction((tx) async { - for (final table in libraryTableNames.reversed) { - await tx.execute('DELETE FROM $table'); + _databaseSnapshot = const LibrarySnapshot(); + _publishLibrarySnapshot(_databaseSnapshot!, database); + _setSyncState(const SyncState()); + _setBookMetadataSyncState(const BookMetadataSyncState()); + })(); + + _modeOperation = operation; + + return operation.whenComplete(() { + if (identical(_modeOperation, operation)) { + _modeOperation = null; } }); - - _booksController.add(const []); - _setSyncState(const SyncState()); - _setBookMetadataSyncState(const BookMetadataSyncState()); } Future clearAuthenticatedCache() async { diff --git a/app/lib/powersync/storage_sync_controller.dart b/app/lib/powersync/storage_sync_controller.dart index 84e30eaf..d2a8a49b 100644 --- a/app/lib/powersync/storage_sync_controller.dart +++ b/app/lib/powersync/storage_sync_controller.dart @@ -146,6 +146,5 @@ class StorageSyncController { } Future reconnect() => powerSyncService.reconnect(); - Future clearGuestLibrary() => powerSyncService.clearGuestLibrary(); Future clearAuthenticatedCache() => powerSyncService.clearAuthenticatedCache(); } diff --git a/app/lib/providers/auth_provider.dart b/app/lib/providers/auth_provider.dart index 7d0d3103..124d4897 100644 --- a/app/lib/providers/auth_provider.dart +++ b/app/lib/providers/auth_provider.dart @@ -4,11 +4,14 @@ import 'package:flutter/foundation.dart'; import 'package:papyrus/auth/auth_api_client.dart'; import 'package:papyrus/auth/auth_models.dart'; import 'package:papyrus/auth/auth_repository.dart'; +import 'package:papyrus/services/guest_profile_store.dart'; import 'package:shared_preferences/shared_preferences.dart'; class AuthProvider extends ChangeNotifier { AuthRepository _repository; final SharedPreferences _prefs; + final GuestProfileStore _guestProfile; + Future? _guestProfileWrite; static const _keyOfflineMode = 'offline_mode'; @@ -21,10 +24,20 @@ class AuthProvider extends ChangeNotifier { bool _isOfflineMode = false; bool get isOfflineMode => _isOfflineMode; + bool _isDeletingGuestAccount = false; + bool get isDeletingGuestAccount => _isDeletingGuestAccount; + bool get isBootstrapping => _status == AuthStatus.bootstrapping; bool get isSignedIn => _user != null && _status == AuthStatus.signedIn; + bool get canEditProfile => !_isDeletingGuestAccount && (isSignedIn || (_isOfflineMode && _user == null)); + + String get profileDisplayName => + _isOfflineMode ? _guestProfile.displayName : _user?.displayName ?? GuestProfileStore.defaultDisplayName; + + Uint8List? get profilePhotoBytes => _isOfflineMode ? _guestProfile.photoBytes : null; + bool get isLoading { return _status == AuthStatus.bootstrapping || _status == AuthStatus.authenticating || @@ -42,8 +55,13 @@ class AuthProvider extends ChangeNotifier { String? _error; String? get error => _error; - AuthProvider(this._prefs, {required AuthRepository repository, bool bootstrapOnCreate = true}) - : _repository = repository { + AuthProvider( + this._prefs, { + required AuthRepository repository, + bool bootstrapOnCreate = true, + GuestProfileStore? guestProfileStore, + }) : _repository = repository, + _guestProfile = guestProfileStore ?? GuestProfileStore(_prefs) { _isOfflineMode = _prefs.getBool(_keyOfflineMode) ?? false; if (bootstrapOnCreate) { @@ -66,6 +84,12 @@ class AuthProvider extends ChangeNotifier { } Future bootstrap() async { + if (_isOfflineMode) { + _user = null; + _setStatus(AuthStatus.signedOut); + return; + } + _setStatus(AuthStatus.bootstrapping); try { @@ -161,19 +185,76 @@ class AuthProvider extends ChangeNotifier { _setStatus(AuthStatus.signedOut); } - Future updateProfile({required String displayName, String? avatarUrl}) async { + Future updateProfile({required String displayName, String? avatarUrl, Uint8List? photoBytes}) async { + if (_isOfflineMode && _user == null) { + if (!canEditProfile || _guestProfileWrite != null) { + return false; + } + + final operation = _saveGuestProfile(displayName, photoBytes); + _guestProfileWrite = operation; + + try { + return await operation; + } finally { + _guestProfileWrite = null; + } + } + + final repository = _repository; + final userId = _user?.userId; + + if (!isSignedIn) { + return false; + } + try { - _user = await _repository.updateCurrentUser(displayName: displayName, avatarUrl: avatarUrl); + final updatedUser = photoBytes == null + ? await repository.updateCurrentUser(displayName: displayName, avatarUrl: avatarUrl) + : await repository.uploadProfilePhoto(photoBytes, displayName: displayName); + + if (!identical(_repository, repository) || _user?.userId != userId || !isSignedIn) { + return false; + } + + _user = updatedUser; _error = null; notifyListeners(); return true; } catch (error) { + if (!identical(_repository, repository) || _user?.userId != userId || !isSignedIn) { + return false; + } + _error = _messageFor(error); notifyListeners(); return false; } } + Future downloadProfilePhoto(String avatarUrl) => _repository.downloadProfilePhoto(avatarUrl); + + Future _saveGuestProfile(String displayName, Uint8List? photoBytes) async { + try { + await _guestProfile.save(displayName: displayName, photoBytes: photoBytes); + + if (!_isOfflineMode || _user != null) { + return false; + } + + _error = null; + notifyListeners(); + return true; + } catch (_) { + if (_isOfflineMode && _user == null) { + _error = 'Could not save your profile on this device. Please try again.'; + notifyListeners(); + } + + return false; + } + } + Future deleteAccount() async { final repository = _repository; final userId = _user?.userId; @@ -193,6 +274,43 @@ class AuthProvider extends ChangeNotifier { return identical(_repository, repository) ? deadline : null; } + Future deleteGuestAccount(Future Function() clearLocalData) async { + if (!_isOfflineMode || _user != null || _isDeletingGuestAccount) { + throw StateError('Only the active anonymous account can be deleted.'); + } + + _isDeletingGuestAccount = true; + final repository = _repository; + notifyListeners(); + + try { + await _guestProfileWrite; + await clearLocalData(); + + if (!identical(_repository, repository) || _user != null || !_isOfflineMode) { + throw StateError('The active account changed during deletion.'); + } + + await _guestProfile.clear(); + + if (!identical(_repository, repository) || _user != null || !_isOfflineMode) { + throw StateError('The active account changed during deletion.'); + } + + if (!await _prefs.setBool(_keyOfflineMode, false)) { + throw StateError('Could not leave anonymous mode.'); + } + + _isOfflineMode = false; + _error = null; + _pendingRecovery = null; + _setStatus(AuthStatus.signedOut); + } finally { + _isDeletingGuestAccount = false; + notifyListeners(); + } + } + Future recoverAccount(AccountDeletionAuthorization authorization) async { final repository = _repository; diff --git a/app/lib/providers/preferences_provider.dart b/app/lib/providers/preferences_provider.dart index c0963645..2fad0e64 100644 --- a/app/lib/providers/preferences_provider.dart +++ b/app/lib/providers/preferences_provider.dart @@ -54,6 +54,11 @@ class PreferencesProvider extends ChangeNotifier { // Accessibility static const _keyReduceAnimations = 'reduce_animations'; static const _keyDyslexiaFont = 'dyslexia_font'; + static const _keyInterfaceTextEnlargement = 'interface_text_enlargement'; + static const _keyIncreaseContrast = 'increase_contrast'; + static const _keyBoldInterfaceText = 'bold_interface_text'; + + static const interfaceTextEnlargements = [1.0, 1.15, 1.3, 1.5, 2.0]; // -- Theme ---------------------------------------------------------------- @@ -279,6 +284,30 @@ class PreferencesProvider extends ChangeNotifier { // -- Accessibility -------------------------------------------------------- + double get interfaceTextEnlargement { + final value = _prefs.get(_keyInterfaceTextEnlargement); + return value is double && interfaceTextEnlargements.contains(value) ? value : 1.0; + } + + set interfaceTextEnlargement(double value) { + _prefs.setDouble(_keyInterfaceTextEnlargement, interfaceTextEnlargements.contains(value) ? value : 1.0); + notifyListeners(); + } + + bool get increaseContrast => _prefs.getBool(_keyIncreaseContrast) ?? false; + + set increaseContrast(bool value) { + _prefs.setBool(_keyIncreaseContrast, value); + notifyListeners(); + } + + bool get boldInterfaceText => _prefs.getBool(_keyBoldInterfaceText) ?? false; + + set boldInterfaceText(bool value) { + _prefs.setBool(_keyBoldInterfaceText, value); + notifyListeners(); + } + bool get reduceAnimations => _prefs.getBool(_keyReduceAnimations) ?? false; set reduceAnimations(bool value) { diff --git a/app/lib/reader/reader_panel_sheet.dart b/app/lib/reader/reader_panel_sheet.dart index 7a0fd734..438a9dff 100644 --- a/app/lib/reader/reader_panel_sheet.dart +++ b/app/lib/reader/reader_panel_sheet.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:papyrus/themes/app_accessibility.dart'; import 'package:papyrus/themes/app_motion.dart'; import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/widgets/shared/app_bottom_sheet.dart'; @@ -23,10 +24,12 @@ Route buildReaderPanelSheet(BuildContext context, ReaderPanelRouteContext theme.bottomSheetTheme.shape ?? const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xl))), clipBehavior: Clip.antiAlias, - builder: (context) => AppBottomSheet( - title: panel.title, - contentPadding: EdgeInsets.zero, - scrollBodyBuilder: panel.buildContent, + builder: (context) => ReaderSystemTextScope( + child: AppBottomSheet( + title: panel.title, + contentPadding: EdgeInsets.zero, + scrollBodyBuilder: panel.buildContent, + ), ), ); } diff --git a/app/lib/services/book_import_session.dart b/app/lib/services/book_import_session.dart index 9bfbdf39..75c9c527 100644 --- a/app/lib/services/book_import_session.dart +++ b/app/lib/services/book_import_session.dart @@ -50,12 +50,14 @@ class BookImportSession { throw StateError('Cannot import account media without an active media storage scope'); } - if ((authProvider.isSignedIn && (!authenticated || scope?.userId != userId)) || + if (authProvider.isDeletingGuestAccount || + (authProvider.isSignedIn && (!authenticated || scope?.userId != userId)) || (authProvider.isOfflineMode && powerSyncService.mode != LibraryDatabaseMode.guest)) { throw StateError('Wait for the selected library to finish loading before importing.'); } bool isCurrent() => + !authProvider.isDeletingGuestAccount && dataStore.isBookRepositoryCurrent(repository) && authProvider.user?.userId == userId && (authProvider.isSignedIn && powerSyncService.mode == LibraryDatabaseMode.authenticated) == authenticated && diff --git a/app/lib/services/guest_account_deletion_service.dart b/app/lib/services/guest_account_deletion_service.dart new file mode 100644 index 00000000..d393dcca --- /dev/null +++ b/app/lib/services/guest_account_deletion_service.dart @@ -0,0 +1,38 @@ +import 'package:papyrus/media/media_storage_scope.dart'; +import 'package:papyrus/opds/opds_catalogs.dart'; +import 'package:papyrus/opds/opds_downloads.dart'; +import 'package:papyrus/powersync/powersync_service.dart'; +import 'package:papyrus/providers/auth_provider.dart'; +import 'package:papyrus/services/book_import_service_stub.dart' + if (dart.library.js_interop) 'package:papyrus/services/book_import_service.dart'; + +class GuestAccountDeletionService { + const GuestAccountDeletionService({ + required this.auth, + required this.library, + required this.files, + required this.catalogs, + required this.downloads, + }); + + final AuthProvider auth; + final PapyrusPowerSyncService library; + final BookImportService files; + final OpdsCatalogs catalogs; + final OpdsDownloads downloads; + + Future delete() => auth.deleteGuestAccount(() async { + await downloads.cancelAndWait(); + + await library.clearGuestLibrary( + deleteFiles: (books) async { + for (final book in books) { + await files.deleteBookFile(book.id); + } + + await files.clearCoverFiles(MediaStorageScope.localGuest); + await catalogs.clearScope(MediaStorageScope.localGuest.persistenceKey); + }, + ); + }); +} diff --git a/app/lib/services/guest_profile_store.dart b/app/lib/services/guest_profile_store.dart new file mode 100644 index 00000000..1b469781 --- /dev/null +++ b/app/lib/services/guest_profile_store.dart @@ -0,0 +1,66 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:shared_preferences/shared_preferences.dart'; + +/// Keeps the local account separate from every signed-in account and server. +class GuestProfileStore { + static const preferenceKey = 'guest_profile'; + static const defaultDisplayName = 'Anonymous User'; + + final SharedPreferences _prefs; + String _displayName = defaultDisplayName; + Uint8List? _photoBytes; + + String get displayName => _displayName; + Uint8List? get photoBytes => _photoBytes; + + GuestProfileStore(this._prefs) { + final saved = _prefs.get(preferenceKey); + + if (saved is! String) { + return; + } + + try { + final profile = jsonDecode(saved) as Map; + final name = profile['display_name'] as String; + final photo = profile['photo'] as String?; + + if (name.trim().isEmpty || name.length > 100) { + return; + } + + _photoBytes = photo == null ? null : base64Decode(photo); + _displayName = name; + } catch (_) { + _displayName = defaultDisplayName; + _photoBytes = null; + } + } + + Future save({required String displayName, Uint8List? photoBytes}) async { + // Flutter's web image decoder requires a native typed buffer, not an unmodifiable view. + final photo = photoBytes == null ? _photoBytes : Uint8List.fromList(photoBytes); + final record = jsonEncode({ + 'display_name': displayName, + 'photo': photo == null ? null : base64Encode(photo), + }); + + if (!await _prefs.setString(preferenceKey, record)) { + throw StateError('Could not save the local profile.'); + } + + _displayName = displayName; + _photoBytes = photo; + } + + Future clear() async { + if (!await _prefs.remove(preferenceKey)) { + throw StateError('Could not clear the local profile.'); + } + + _displayName = defaultDisplayName; + _photoBytes = null; + } +} diff --git a/app/lib/services/profile_photo.dart b/app/lib/services/profile_photo.dart new file mode 100644 index 00000000..1284503f --- /dev/null +++ b/app/lib/services/profile_photo.dart @@ -0,0 +1,59 @@ +import 'dart:typed_data'; + +import 'package:image/image.dart' as image; + +const maxProfilePhotoBytes = 5 * 1024 * 1024; + +/// Bounds decoding and strips source metadata before uploading a small JPEG. +Uint8List prepareProfilePhoto(Uint8List bytes) { + if (bytes.length > maxProfilePhotoBytes) { + throw const FormatException('Choose a photo smaller than 5 MB.'); + } + + try { + return _preparePhoto(bytes); + } on FormatException { + rethrow; + } catch (_) { + throw const FormatException('This photo could not be opened. Choose another image.'); + } +} + +Uint8List _preparePhoto(Uint8List bytes) { + image.Decoder? decoder; + + if (bytes.length >= 8) { + if (bytes[0] == 0xff && bytes[1] == 0xd8 && bytes[2] == 0xff) { + decoder = image.JpegDecoder(); + } else if (bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4e && bytes[3] == 0x47) { + decoder = image.PngDecoder(); + } + } + + final info = decoder?.startDecode(bytes); + + if (info == null || info.width <= 0 || info.height <= 0 || info.width * info.height > 25000000) { + throw const FormatException('Choose a JPEG or PNG photo up to 25 megapixels.'); + } + + final decoded = decoder!.decodeFrame(0); + + if (decoded == null) { + throw const FormatException('This photo could not be opened. Choose another image.'); + } + + final oriented = image.bakeOrientation(decoded); + final longestSide = oriented.width > oriented.height ? oriented.width : oriented.height; + final resized = longestSide > 512 + ? image.copyResize( + oriented, + width: (oriented.width * 512 / longestSide).round().clamp(1, 512), + height: (oriented.height * 512 / longestSide).round().clamp(1, 512), + ) + : oriented; + + final clean = image.Image(width: resized.width, height: resized.height, numChannels: 3); + image.fill(clean, color: image.ColorRgb8(255, 255, 255)); + image.compositeImage(clean, resized); + return image.encodeJpg(clean, quality: 90); +} diff --git a/app/lib/themes/app_accessibility.dart b/app/lib/themes/app_accessibility.dart new file mode 100644 index 00000000..7f8aa6dd --- /dev/null +++ b/app/lib/themes/app_accessibility.dart @@ -0,0 +1,104 @@ +import 'package:flutter/material.dart'; +import 'package:papyrus/providers/preferences_provider.dart'; +import 'package:papyrus/themes/app_motion.dart'; +import 'package:papyrus/themes/app_theme.dart'; + +/// Preserves the platform's nonlinear curve while adding interface enlargement. +@immutable +class InterfaceTextScaler extends TextScaler { + const InterfaceTextScaler(this.system, this.multiplier); + + final TextScaler system; + final double multiplier; + + @override + double scale(double fontSize) => system.scale(fontSize) * multiplier; + + @override + double get textScaleFactor => scale(14) / 14; + + @override + bool operator ==(Object other) => + other is InterfaceTextScaler && other.system == system && other.multiplier == multiplier; + + @override + int get hashCode => Object.hash(system, multiplier); +} + +/// Wraps the navigator so preferences also reach dialogs and sheets. +class AppAccessibilityScope extends StatelessWidget { + const AppAccessibilityScope({super.key, required this.preferences, required this.child}); + + final PreferencesProvider preferences; + final Widget child; + + @override + Widget build(BuildContext context) { + final system = MediaQuery.of(context); + final theme = Theme.of(context); + final boldText = system.boldText || preferences.boldInterfaceText; + + return _SystemTextSettings( + textScaler: system.textScaler, + boldText: system.boldText, + theme: theme, + child: MediaQuery( + data: system.copyWith( + textScaler: preferences.interfaceTextEnlargement == 1 + ? system.textScaler + : InterfaceTextScaler(system.textScaler, preferences.interfaceTextEnlargement), + boldText: boldText, + ), + child: Theme( + data: AppTheme.withTypography(theme, boldText: boldText), + child: AppMotionScope( + reduceAnimations: preferences.isEinkMode || preferences.reduceAnimations, + child: child, + ), + ), + ), + ); + } +} + +/// The embedded reader owns text sizing. Keep OS accessibility, but remove the +/// extra interface overrides, including from reader panels on the root navigator. +class ReaderSystemTextScope extends StatelessWidget { + const ReaderSystemTextScope({super.key, required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + final system = context.dependOnInheritedWidgetOfExactType<_SystemTextSettings>(); + + if (system == null) { + return child; + } + + return MediaQuery( + data: MediaQuery.of(context).copyWith(textScaler: system.textScaler, boldText: system.boldText), + child: Theme( + data: AppTheme.withTypography(system.theme, boldText: system.boldText), + child: child, + ), + ); + } +} + +class _SystemTextSettings extends InheritedWidget { + const _SystemTextSettings({ + required this.textScaler, + required this.boldText, + required this.theme, + required super.child, + }); + + final TextScaler textScaler; + final bool boldText; + final ThemeData theme; + + @override + bool updateShouldNotify(_SystemTextSettings oldWidget) => + textScaler != oldWidget.textScaler || boldText != oldWidget.boldText || theme != oldWidget.theme; +} diff --git a/app/lib/themes/app_theme.dart b/app/lib/themes/app_theme.dart index 8c601611..560a84c4 100644 --- a/app/lib/themes/app_theme.dart +++ b/app/lib/themes/app_theme.dart @@ -9,40 +9,114 @@ import 'design_tokens.dart'; class AppTheme { AppTheme._(); - static ThemeData get light => ThemeData( - useMaterial3: true, - colorScheme: lightColorScheme, - // Typography - textTheme: _textTheme, - // Component themes - elevatedButtonTheme: _elevatedButtonTheme(lightColorScheme), - outlinedButtonTheme: _outlinedButtonTheme(lightColorScheme), - textButtonTheme: _textButtonTheme(lightColorScheme), - inputDecorationTheme: _inputDecorationTheme(lightColorScheme), - cardTheme: _cardTheme(lightColorScheme), - appBarTheme: _appBarTheme(lightColorScheme), - bottomNavigationBarTheme: _bottomNavTheme(lightColorScheme), - dividerTheme: _dividerTheme(lightColorScheme), - snackBarTheme: _snackBarTheme(lightColorScheme), - popupMenuTheme: _popupMenuTheme(lightColorScheme), + static const dyslexiaFontFamily = 'OpenDyslexic'; + + static ThemeData withTypography(ThemeData theme, {bool dyslexiaFont = false, bool boldText = false}) { + if (!dyslexiaFont && !boldText) { + return theme; + } + + TextStyle? fontStyle(TextStyle? style) => style?.copyWith( + fontFamily: dyslexiaFont ? dyslexiaFontFamily : null, + fontWeight: boldText && (style.fontWeight ?? FontWeight.normal).value < FontWeight.bold.value + ? FontWeight.bold + : null, + ); + + TextTheme textTheme(TextTheme source) => TextTheme( + displayLarge: fontStyle(source.displayLarge), + displayMedium: fontStyle(source.displayMedium), + displaySmall: fontStyle(source.displaySmall), + headlineLarge: fontStyle(source.headlineLarge), + headlineMedium: fontStyle(source.headlineMedium), + headlineSmall: fontStyle(source.headlineSmall), + titleLarge: fontStyle(source.titleLarge), + titleMedium: fontStyle(source.titleMedium), + titleSmall: fontStyle(source.titleSmall), + bodyLarge: fontStyle(source.bodyLarge), + bodyMedium: fontStyle(source.bodyMedium), + bodySmall: fontStyle(source.bodySmall), + labelLarge: fontStyle(source.labelLarge), + labelMedium: fontStyle(source.labelMedium), + labelSmall: fontStyle(source.labelSmall), + ); + + ButtonStyle? buttonStyle(ButtonStyle? style) { + final textStyle = style?.textStyle; + + if (textStyle == null) { + return style; + } + + return style!.copyWith( + textStyle: WidgetStateProperty.resolveWith((states) => fontStyle(textStyle.resolve(states))), + ); + } + + final navigationLabelStyle = theme.navigationBarTheme.labelTextStyle; + + return theme.copyWith( + textTheme: textTheme(theme.textTheme), + primaryTextTheme: textTheme(theme.primaryTextTheme), + appBarTheme: theme.appBarTheme.copyWith( + titleTextStyle: fontStyle(theme.appBarTheme.titleTextStyle), + toolbarTextStyle: fontStyle(theme.appBarTheme.toolbarTextStyle), + ), + elevatedButtonTheme: ElevatedButtonThemeData(style: buttonStyle(theme.elevatedButtonTheme.style)), + outlinedButtonTheme: OutlinedButtonThemeData(style: buttonStyle(theme.outlinedButtonTheme.style)), + textButtonTheme: TextButtonThemeData(style: buttonStyle(theme.textButtonTheme.style)), + inputDecorationTheme: theme.inputDecorationTheme.copyWith( + labelStyle: fontStyle(theme.inputDecorationTheme.labelStyle), + floatingLabelStyle: fontStyle(theme.inputDecorationTheme.floatingLabelStyle), + hintStyle: fontStyle(theme.inputDecorationTheme.hintStyle), + ), + navigationBarTheme: theme.navigationBarTheme.copyWith( + labelTextStyle: navigationLabelStyle == null + ? null + : WidgetStateProperty.resolveWith((states) => fontStyle(navigationLabelStyle.resolve(states))), + ), + bottomNavigationBarTheme: theme.bottomNavigationBarTheme.copyWith( + selectedLabelStyle: fontStyle(theme.bottomNavigationBarTheme.selectedLabelStyle), + unselectedLabelStyle: fontStyle(theme.bottomNavigationBarTheme.unselectedLabelStyle), + ), + chipTheme: theme.chipTheme.copyWith( + labelStyle: fontStyle(theme.chipTheme.labelStyle), + secondaryLabelStyle: fontStyle(theme.chipTheme.secondaryLabelStyle), + ), + snackBarTheme: theme.snackBarTheme.copyWith(contentTextStyle: fontStyle(theme.snackBarTheme.contentTextStyle)), + popupMenuTheme: theme.popupMenuTheme.copyWith(textStyle: fontStyle(theme.popupMenuTheme.textStyle)), + ); + } + + static ThemeData get light => _standard(lightColorScheme); + static ThemeData get dark => _standard(darkColorScheme); + + static ThemeData get highContrastLight => _highContrast(Brightness.light); + static ThemeData get highContrastDark => _highContrast(Brightness.dark); + + static ThemeData _highContrast(Brightness brightness) => _standard( + ColorScheme.fromSeed(seedColor: lightColorScheme.primary, brightness: brightness, contrastLevel: 1), ); - static ThemeData get dark => ThemeData( + static ThemeData _standard(ColorScheme colors) => ThemeData( useMaterial3: true, - colorScheme: darkColorScheme, - // Typography + colorScheme: colors, textTheme: _textTheme, - // Component themes - elevatedButtonTheme: _elevatedButtonTheme(darkColorScheme), - outlinedButtonTheme: _outlinedButtonTheme(darkColorScheme), - textButtonTheme: _textButtonTheme(darkColorScheme), - inputDecorationTheme: _inputDecorationTheme(darkColorScheme), - cardTheme: _cardTheme(darkColorScheme), - appBarTheme: _appBarTheme(darkColorScheme), - bottomNavigationBarTheme: _bottomNavTheme(darkColorScheme), - dividerTheme: _dividerTheme(darkColorScheme), - snackBarTheme: _snackBarTheme(darkColorScheme), - popupMenuTheme: _popupMenuTheme(darkColorScheme), + elevatedButtonTheme: _elevatedButtonTheme(colors), + outlinedButtonTheme: _outlinedButtonTheme(colors), + textButtonTheme: _textButtonTheme(colors), + switchTheme: _switchTheme, + inputDecorationTheme: _inputDecorationTheme(colors), + cardTheme: _cardTheme(colors), + appBarTheme: _appBarTheme(colors), + bottomSheetTheme: BottomSheetThemeData(backgroundColor: colors.surface), + dialogTheme: const DialogThemeData( + constraints: BoxConstraints(minWidth: 280, maxWidth: ComponentSizes.dialogMaxWidth), + ), + bottomNavigationBarTheme: _bottomNavTheme(colors), + dividerTheme: _dividerTheme(colors), + snackBarTheme: _snackBarTheme(colors), + popupMenuTheme: _popupMenuTheme(colors), ); static ThemeData get eink => ThemeData( @@ -64,6 +138,7 @@ class AppTheme { elevatedButtonTheme: _einkElevatedButtonTheme(), outlinedButtonTheme: _einkOutlinedButtonTheme(), textButtonTheme: _einkTextButtonTheme(), + switchTheme: _switchTheme, filledButtonTheme: FilledButtonThemeData( style: FilledButton.styleFrom( backgroundColor: EinkColors.black, @@ -121,6 +196,7 @@ class AppTheme { ), ), dialogTheme: const DialogThemeData( + constraints: BoxConstraints(minWidth: 280, maxWidth: ComponentSizes.dialogMaxWidth), backgroundColor: EinkColors.white, surfaceTintColor: Colors.transparent, elevation: 0, @@ -263,6 +339,18 @@ class AppTheme { ); } + static final _switchTheme = SwitchThemeData( + overlayColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.hovered) && + !states.contains(WidgetState.focused) && + !states.contains(WidgetState.pressed)) { + return Colors.transparent; + } + + return null; + }), + ); + static DividerThemeData _dividerTheme(ColorScheme colors) { return DividerThemeData(color: colors.outlineVariant, thickness: 1, space: Spacing.md); } diff --git a/app/lib/themes/design_tokens.dart b/app/lib/themes/design_tokens.dart index e47d7b2d..98efd185 100644 --- a/app/lib/themes/design_tokens.dart +++ b/app/lib/themes/design_tokens.dart @@ -131,6 +131,7 @@ class ComponentSizes { // Card widths static const double authCardWidth = 480.0; static const double authCardPadding = 48.0; + static const double dialogMaxWidth = 560.0; // Auth layout (split-screen) static const double authHeroMinWidth = 400.0; diff --git a/app/lib/widgets/acquisition/acquisition_endpoint_editor.dart b/app/lib/widgets/acquisition/acquisition_endpoint_editor.dart index 9792c358..a25abf63 100644 --- a/app/lib/widgets/acquisition/acquisition_endpoint_editor.dart +++ b/app/lib/widgets/acquisition/acquisition_endpoint_editor.dart @@ -289,6 +289,7 @@ class _AcquisitionEndpointEditorState extends State { value: _enabled, enabled: !_busy, builder: (focusNode) => SwitchListTile( + hoverColor: Colors.transparent, focusNode: focusNode, contentPadding: EdgeInsets.zero, title: const Text('Enabled'), diff --git a/app/lib/widgets/add_book/add_book_sheet_scaffold.dart b/app/lib/widgets/add_book/add_book_sheet_scaffold.dart index 1f57e8a5..784c3079 100644 --- a/app/lib/widgets/add_book/add_book_sheet_scaffold.dart +++ b/app/lib/widgets/add_book/add_book_sheet_scaffold.dart @@ -32,25 +32,33 @@ class AddBookSheetScaffold extends StatelessWidget { final isCompactHeight = constraints.maxHeight < 280; final verticalPadding = isCompactHeight ? 0.0 : Spacing.md; + Widget accessibleChrome(Widget child) => MediaQuery.textScalerOf(context).scale(16) > 32 + ? Flexible(child: SingleChildScrollView(primary: false, child: child)) + : child; + return Column( mainAxisSize: fitContent ? MainAxisSize.min : MainAxisSize.max, children: [ - BottomSheetHeader( - key: const Key('add-book-sheet-header'), - title: title, - onDismiss: canClose ? onClose : null, - compact: isCompactHeight, + accessibleChrome( + BottomSheetHeader( + key: const Key('add-book-sheet-header'), + title: title, + onDismiss: canClose ? onClose : null, + compact: isCompactHeight, + ), ), const Divider(height: 1), Flexible(fit: fitContent ? FlexFit.loose : FlexFit.tight, child: body), - Container( - key: const Key('add-book-sheet-footer'), - padding: footerPadding ?? EdgeInsets.symmetric(horizontal: Spacing.lg, vertical: verticalPadding), - decoration: BoxDecoration( - color: colorScheme.surface, - border: Border(top: BorderSide(color: colorScheme.outlineVariant)), + accessibleChrome( + Container( + key: const Key('add-book-sheet-footer'), + padding: footerPadding ?? EdgeInsets.symmetric(horizontal: Spacing.lg, vertical: verticalPadding), + decoration: BoxDecoration( + color: colorScheme.surface, + border: Border(top: BorderSide(color: colorScheme.outlineVariant)), + ), + child: SafeArea(top: false, child: footer), ), - child: SafeArea(top: false, child: footer), ), ], ); 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 dfca5faa..41cb056e 100644 --- a/app/lib/widgets/add_book/add_physical_book_sheet.dart +++ b/app/lib/widgets/add_book/add_physical_book_sheet.dart @@ -314,23 +314,20 @@ class _PhysicalBookContentState extends State<_PhysicalBookContent> { Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Card( - margin: const EdgeInsets.only(bottom: Spacing.xs), - child: Padding( - padding: const EdgeInsets.all(Spacing.md), - child: CoverImagePicker( - initialUrl: _coverUrl, - initialBytes: _coverImageBytes, - onUrlChanged: (url) => setState(() => _coverUrl = url), - onFileChanged: (bytes) => setState(() { - _coverImageBytes = bytes; - - if (bytes != null) { - _coverUrl = null; - } - }), - coverWidth: 240, - ), + Padding( + padding: const EdgeInsets.fromLTRB(Spacing.md, Spacing.md, Spacing.md, Spacing.md + Spacing.xs), + child: CoverImagePicker( + initialUrl: _coverUrl, + initialBytes: _coverImageBytes, + onUrlChanged: (url) => setState(() => _coverUrl = url), + onFileChanged: (bytes) => setState(() { + _coverImageBytes = bytes; + + if (bytes != null) { + _coverUrl = null; + } + }), + coverWidth: 240, ), ), @@ -354,27 +351,24 @@ class _PhysicalBookContentState extends State<_PhysicalBookContent> { ); } - Widget _buildSectionCard({required String? title, required List children}) { - return Card( - margin: const EdgeInsets.only(bottom: Spacing.xs), - child: Padding( - padding: const EdgeInsets.all(Spacing.md), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (title != null) ...[ - Text(title, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600)), - const SizedBox(height: Spacing.md), - ], - ...children, + Widget _buildFormSection({required String? title, required List children}) { + return Padding( + padding: const EdgeInsets.fromLTRB(Spacing.md, Spacing.md, Spacing.md, Spacing.md + Spacing.xs), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (title != null) ...[ + Text(title, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600)), + const SizedBox(height: Spacing.md), ], - ), + ...children, + ], ), ); } Widget _buildBasicInfoSection() { - return _buildSectionCard( + return _buildFormSection( title: 'Basic information', children: [ BookTextField(controller: _titleController, label: 'Title', required: true, onChanged: (_) => setState(() {})), @@ -396,7 +390,7 @@ class _PhysicalBookContentState extends State<_PhysicalBookContent> { } Widget _buildPublicationSection() { - return _buildSectionCard( + return _buildFormSection( title: 'Publication details', children: [ BookTextField(controller: _publisherController, label: 'Publisher'), @@ -416,7 +410,7 @@ class _PhysicalBookContentState extends State<_PhysicalBookContent> { } Widget _buildIdentifiersSection() { - return _buildSectionCard( + return _buildFormSection( title: 'Identifiers', children: [ BookTextField(controller: _isbnController, label: 'ISBN'), @@ -427,7 +421,7 @@ class _PhysicalBookContentState extends State<_PhysicalBookContent> { } Widget _buildSeriesSection() { - return _buildSectionCard( + return _buildFormSection( title: 'Series', children: [ BookTextField(controller: _seriesNameController, label: 'Series name'), @@ -442,7 +436,7 @@ class _PhysicalBookContentState extends State<_PhysicalBookContent> { } Widget _buildPhysicalBookSection() { - return _buildSectionCard( + return _buildFormSection( title: 'Other details', children: [ BookTextField(controller: _locationController, label: 'Location'), @@ -464,7 +458,7 @@ class _PhysicalBookContentState extends State<_PhysicalBookContent> { final textTheme = Theme.of(context).textTheme; final isFetching = _lookupState == _IsbnLookupState.fetching; - return _buildSectionCard( + return _buildFormSection( title: 'ISBN lookup', children: [ // ISBN field with inline look-up button diff --git a/app/lib/widgets/add_book/book_import_sheet_sections.dart b/app/lib/widgets/add_book/book_import_sheet_sections.dart index db21f5ae..62e014d6 100644 --- a/app/lib/widgets/add_book/book_import_sheet_sections.dart +++ b/app/lib/widgets/add_book/book_import_sheet_sections.dart @@ -88,7 +88,10 @@ class BookImportSelectingSection extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: Spacing.lg, vertical: Spacing.md), children: [ if (pickerError case final message?) ...[ - Text(message, style: TextStyle(color: colorScheme.error, fontSize: 13)), + Semantics( + liveRegion: true, + child: Text(message, style: TextStyle(color: colorScheme.error, fontSize: 13)), + ), const SizedBox(height: Spacing.sm), ], Wrap( diff --git a/app/lib/widgets/auth/auth_continue_button.dart b/app/lib/widgets/auth/auth_continue_button.dart index d2d7dd0f..8afc625c 100644 --- a/app/lib/widgets/auth/auth_continue_button.dart +++ b/app/lib/widgets/auth/auth_continue_button.dart @@ -32,12 +32,18 @@ class AuthContinueButton extends StatelessWidget { ? SizedBox( width: 24, height: 24, - child: AppCircularProgressIndicator(strokeWidth: 2, color: theme.colorScheme.onPrimary), + child: AppCircularProgressIndicator( + semanticsLabel: 'Please wait', + strokeWidth: 2, + color: theme.colorScheme.onPrimary, + ), ) : Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Text('Continue', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500)), + const Flexible( + child: Text('Continue', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500)), + ), const SizedBox(width: Spacing.sm), Icon(Icons.arrow_forward, size: IconSizes.medium, color: theme.colorScheme.onPrimary), ], diff --git a/app/lib/widgets/book_details/book_action_buttons.dart b/app/lib/widgets/book_details/book_action_buttons.dart index a289c219..e2106f3c 100644 --- a/app/lib/widgets/book_details/book_action_buttons.dart +++ b/app/lib/widgets/book_details/book_action_buttons.dart @@ -84,18 +84,27 @@ class BookActionButtons extends StatelessWidget { icon: digitalIcon, label: Text(digitalLabel), ), - OutlinedButton( - onPressed: onToggleFavorite, - style: iconStyle, - child: Icon( - book.isFavorite ? Icons.favorite : Icons.favorite_border, - color: book.isFavorite ? colorScheme.error : colorScheme.primary, + Tooltip( + message: book.isFavorite ? 'Remove from favorites' : 'Add to favorites', + excludeFromSemantics: true, + child: OutlinedButton( + onPressed: onToggleFavorite, + style: iconStyle, + child: Icon( + book.isFavorite ? Icons.favorite : Icons.favorite_border, + semanticLabel: book.isFavorite ? 'Remove from favorites' : 'Add to favorites', + color: book.isFavorite ? colorScheme.error : colorScheme.primary, + ), ), ), - OutlinedButton( - onPressed: onEdit, - style: iconStyle, - child: Icon(Icons.edit_outlined, color: colorScheme.primary), + Tooltip( + message: 'Edit book', + excludeFromSemantics: true, + child: OutlinedButton( + onPressed: onEdit, + style: iconStyle, + child: Icon(Icons.edit_outlined, semanticLabel: 'Edit book', color: colorScheme.primary), + ), ), ], ); diff --git a/app/lib/widgets/book_edit/cover_image_picker.dart b/app/lib/widgets/book_edit/cover_image_picker.dart index f5296e35..e736e92b 100644 --- a/app/lib/widgets/book_edit/cover_image_picker.dart +++ b/app/lib/widgets/book_edit/cover_image_picker.dart @@ -194,15 +194,29 @@ class _CoverImagePickerState extends State { ); if (widget.coverWidth != null) { - return GestureDetector( - onTap: _pickImage, - child: Center( - child: SizedBox(width: widget.coverWidth, child: cover), + return Semantics( + button: true, + label: 'Choose cover image', + child: Material( + type: MaterialType.transparency, + child: InkWell( + onTap: _pickImage, + child: Center( + child: SizedBox(width: widget.coverWidth, child: cover), + ), + ), ), ); } - return GestureDetector(onTap: _pickImage, child: cover); + return Semantics( + button: true, + label: 'Choose cover image', + child: Material( + type: MaterialType.transparency, + child: InkWell(onTap: _pickImage, child: cover), + ), + ); } Widget _buildCoverImage(BuildContext context) { @@ -254,17 +268,17 @@ class _CoverImagePickerState extends State { } Widget _buildRemoveButton(BuildContext context) { - return Material( - color: Colors.black54, - borderRadius: BorderRadius.circular(16), - child: InkWell( - borderRadius: BorderRadius.circular(16), - onTap: _clearImage, - child: const Padding( - padding: EdgeInsets.all(4), - child: Icon(Icons.close, size: 16, color: Colors.white), - ), + final colorScheme = Theme.of(context).colorScheme; + + return IconButton.filled( + tooltip: 'Remove cover image', + onPressed: _clearImage, + style: IconButton.styleFrom( + backgroundColor: colorScheme.surface, + foregroundColor: colorScheme.onSurface, + minimumSize: const Size(48, 48), ), + icon: const Icon(Icons.close, size: 20), ); } @@ -272,20 +286,23 @@ class _CoverImagePickerState extends State { final colorScheme = Theme.of(context).colorScheme; return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.add_photo_alternate_outlined, - size: widget.isDesktop ? 56 : 40, - color: colorScheme.onSurfaceVariant, - ), - const SizedBox(height: Spacing.sm), - Text( - 'Tap to add cover', - style: TextStyle(color: colorScheme.onSurfaceVariant, fontSize: widget.isDesktop ? 14 : 12), - ), - ], + child: SingleChildScrollView( + primary: false, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.add_photo_alternate_outlined, + size: widget.isDesktop ? 56 : 40, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(height: Spacing.sm), + Text( + 'No cover image', + style: TextStyle(color: colorScheme.onSurfaceVariant, fontSize: widget.isDesktop ? 14 : 12), + ), + ], + ), ), ); } diff --git a/app/lib/widgets/goals/add_goal_sheet.dart b/app/lib/widgets/goals/add_goal_sheet.dart index 4e56b5e7..c386e7dd 100644 --- a/app/lib/widgets/goals/add_goal_sheet.dart +++ b/app/lib/widgets/goals/add_goal_sheet.dart @@ -315,6 +315,7 @@ class _AddGoalSheetState extends State { ), if (_period != GoalPeriod.custom) SwitchListTile( + hoverColor: Colors.transparent, contentPadding: EdgeInsets.zero, title: const Text('Repeat each period'), value: _recurring, diff --git a/app/lib/widgets/input/email_input.dart b/app/lib/widgets/input/email_input.dart index 70bd869c..99484855 100644 --- a/app/lib/widgets/input/email_input.dart +++ b/app/lib/widgets/input/email_input.dart @@ -39,6 +39,7 @@ class EmailInput extends StatelessWidget { ), controller: controller, focusNode: focusNode, + autofillHints: const [AutofillHints.email], keyboardType: TextInputType.emailAddress, textInputAction: textInputAction ?? TextInputAction.next, onEditingComplete: onEditingComplete, diff --git a/app/lib/widgets/input/password_input.dart b/app/lib/widgets/input/password_input.dart index 48cb5348..fa503e48 100644 --- a/app/lib/widgets/input/password_input.dart +++ b/app/lib/widgets/input/password_input.dart @@ -6,6 +6,7 @@ class PasswordInput extends StatefulWidget { /// Label text displayed for the input field final String labelText; final Listenable? validationDependency; + final Iterable autofillHints; /// Optional text controller for the input final TextEditingController? controller; @@ -31,6 +32,7 @@ class PasswordInput extends StatefulWidget { this.extraValidator, required this.labelText, this.validationDependency, + this.autofillHints = const [AutofillHints.password], this.focusNode, this.onEditingComplete, this.textInputAction, @@ -57,6 +59,7 @@ class _PasswordInputState extends State { border: const OutlineInputBorder(), labelText: widget.labelText, suffixIcon: IconButton( + tooltip: _isTextHidden ? 'Show password' : 'Hide password', onPressed: _toggleVisibility, icon: Icon( _isTextHidden ? Icons.visibility_outlined : Icons.visibility_off_outlined, @@ -64,6 +67,7 @@ class _PasswordInputState extends State { ), ), ), + autofillHints: widget.autofillHints, obscureText: _isTextHidden, enableSuggestions: false, autocorrect: false, diff --git a/app/lib/widgets/input/search_field.dart b/app/lib/widgets/input/search_field.dart index b8b28d14..80db6a59 100644 --- a/app/lib/widgets/input/search_field.dart +++ b/app/lib/widgets/input/search_field.dart @@ -32,7 +32,7 @@ class SearchField extends StatelessWidget { /// [onChanged] with an empty string. final VoidCallback? onClear; - /// Height of the search field. Defaults to 40. + /// Minimum height of the search field, never below a 48-pixel touch target. final double? height; /// An optional action inside the field, after the clear button. @@ -65,15 +65,16 @@ class SearchField extends StatelessWidget { @override Widget build(BuildContext context) { - return SizedBox( - height: height ?? 40, + return ConstrainedBox( + constraints: BoxConstraints(minHeight: (height ?? 48).clamp(48, double.infinity)), child: TextField( controller: controller, onChanged: onChanged, onSubmitted: onSubmitted, textInputAction: onSubmitted == null ? null : TextInputAction.search, decoration: InputDecoration( - hintText: hintText, + labelText: hintText, + floatingLabelBehavior: FloatingLabelBehavior.never, prefixIcon: const Icon(Icons.search, size: 20), suffixIcon: trailing == null && !_hasContent ? null @@ -90,7 +91,7 @@ class SearchField extends StatelessWidget { ], ), border: OutlineInputBorder(borderRadius: BorderRadius.circular(AppRadius.md)), - contentPadding: const EdgeInsets.symmetric(horizontal: Spacing.md), + contentPadding: const EdgeInsets.symmetric(horizontal: Spacing.md, vertical: Spacing.sm), isDense: true, ), ), diff --git a/app/lib/widgets/input/validated_text_form_field.dart b/app/lib/widgets/input/validated_text_form_field.dart index f7525654..de4b7fd3 100644 --- a/app/lib/widgets/input/validated_text_form_field.dart +++ b/app/lib/widgets/input/validated_text_form_field.dart @@ -11,6 +11,7 @@ class ValidatedTextFormField extends StatefulWidget { final TextCapitalization textCapitalization; final VoidCallback? onEditingComplete; final ValueChanged? onFieldSubmitted; + final Iterable? autofillHints; final bool obscureText; final bool autocorrect; final bool enableSuggestions; @@ -27,6 +28,7 @@ class ValidatedTextFormField extends StatefulWidget { this.textCapitalization = TextCapitalization.none, this.onEditingComplete, this.onFieldSubmitted, + this.autofillHints, this.obscureText = false, this.autocorrect = true, this.enableSuggestions = true, @@ -142,6 +144,7 @@ class _ValidatedTextFormFieldState extends State { textCapitalization: widget.textCapitalization, onEditingComplete: widget.onEditingComplete, onFieldSubmitted: widget.onFieldSubmitted, + autofillHints: widget.autofillHints, obscureText: widget.obscureText, autocorrect: widget.autocorrect, enableSuggestions: widget.enableSuggestions, diff --git a/app/lib/widgets/library/book_card.dart b/app/lib/widgets/library/book_card.dart index fc0dc170..7ccd49bf 100644 --- a/app/lib/widgets/library/book_card.dart +++ b/app/lib/widgets/library/book_card.dart @@ -55,6 +55,7 @@ class BookCard extends StatefulWidget { class _BookCardState extends State { bool _isHovered = false; + bool _hasFocus = false; @override void initState() { @@ -76,13 +77,14 @@ class _BookCardState extends State { Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final inSelection = widget.isSelectionMode; + final hoverDuration = AppMotion.duration(context, const Duration(milliseconds: 150)); final effectiveTap = inSelection ? widget.onSelectToggle : widget.onTap; final effectiveLongPress = inSelection ? null : widget.onEnterSelectionMode; final isUnavailable = widget.acquisitionJob == null && !widget.book.isPhysical && widget.deviceStatus == BookDeviceStatus.missing; - final card = MouseRegion( + final visualCard = MouseRegion( onEnter: (_) => setState(() => _isHovered = true), onExit: (_) => setState(() => _isHovered = false), child: GestureDetector( @@ -116,22 +118,33 @@ class _BookCardState extends State { child: Stack( fit: StackFit.expand, children: [ - _buildCover(context), + ExcludeSemantics(child: _buildCover(context)), // Selection tint overlay if (inSelection && widget.isSelected) Container(color: colorScheme.primary.withValues(alpha: 0.15)), - if (!inSelection && - widget.acquisitionJob == null && - (widget.isFavorite || _isHovered || !WidgetsBinding.instance.mouseTracker.mouseIsConnected)) + if (!inSelection && widget.acquisitionJob == null) Positioned( top: Spacing.xs, left: Spacing.xs, - child: _CardIconButton( - icon: widget.isFavorite ? Icons.favorite : Icons.favorite_border, - color: widget.isFavorite ? colorScheme.error : Colors.white, - onTap: widget.onToggleFavorite != null - ? () => widget.onToggleFavorite!(widget.isFavorite) - : null, + child: AnimatedOpacity( + key: ValueKey(AppMotion.disabled(context)), + duration: hoverDuration, + opacity: + widget.isFavorite || + _isHovered || + _hasFocus || + !WidgetsBinding.instance.mouseTracker.mouseIsConnected + ? 1 + : 0, + alwaysIncludeSemantics: true, + child: _CardIconButton( + tooltip: widget.isFavorite ? 'Remove from favorites' : 'Add to favorites', + icon: widget.isFavorite ? Icons.favorite : Icons.favorite_border, + color: widget.isFavorite ? colorScheme.error : Colors.white, + onTap: widget.onToggleFavorite != null + ? () => widget.onToggleFavorite!(widget.isFavorite) + : null, + ), ), ), // Selection mode: checkbox overlay (top-right) @@ -140,6 +153,9 @@ class _BookCardState extends State { top: Spacing.xs, right: Spacing.xs, child: _CardIconButton( + tooltip: widget.isSelected + ? 'Deselect ${widget.book.title}' + : 'Select ${widget.book.title}', icon: widget.isSelected ? Icons.check_circle : Icons.radio_button_unchecked, color: widget.isSelected ? colorScheme.primary : Colors.white, onTap: widget.onSelectToggle, @@ -152,15 +168,21 @@ class _BookCardState extends State { right: Spacing.xs, child: AnimatedOpacity( key: ValueKey(AppMotion.disabled(context)), - opacity: _isHovered ? 1.0 : 0.0, - duration: AppMotion.duration(context, const Duration(milliseconds: 150)), + opacity: _isHovered || _hasFocus ? 1.0 : 0.0, + alwaysIncludeSemantics: true, + duration: hoverDuration, child: Row( mainAxisSize: MainAxisSize.min, children: [ - _CardIconButton(icon: Icons.radio_button_unchecked, onTap: widget.onEnterSelectionMode), + _CardIconButton( + tooltip: 'Select ${widget.book.title}', + icon: Icons.radio_button_unchecked, + onTap: widget.onEnterSelectionMode, + ), if (widget.acquisitionJob == null) ...[ const SizedBox(width: Spacing.xs), _CardIconButton( + tooltip: 'More options for ${widget.book.title}', icon: Icons.more_vert, onTap: () => showBookContextMenu( context: context, @@ -299,6 +321,12 @@ class _BookCardState extends State { ), ); + final card = Focus( + canRequestFocus: false, + onFocusChange: (focused) => setState(() => _hasFocus = focused), + child: visualCard, + ); + final job = widget.acquisitionJob; if (job == null) { @@ -422,22 +450,32 @@ class _BookCardState extends State { /// Icon button overlay for book card. class _CardIconButton extends StatelessWidget { final IconData icon; + final String tooltip; final Color? color; final VoidCallback? onTap; - const _CardIconButton({required this.icon, this.color, this.onTap}); + const _CardIconButton({required this.icon, required this.tooltip, this.color, this.onTap}); @override Widget build(BuildContext context) { - return Material( - color: Colors.black45, - borderRadius: BorderRadius.circular(AppRadius.full), - child: InkWell( - borderRadius: BorderRadius.circular(AppRadius.full), - onTap: onTap, - child: Padding( - padding: const EdgeInsets.all(6), - child: Icon(icon, size: IconSizes.small, color: color ?? Colors.white), + return Tooltip( + message: tooltip, + excludeFromSemantics: true, + child: Semantics( + label: tooltip, + container: true, + button: true, + child: Material( + color: Colors.black45, + borderRadius: BorderRadius.circular(AppRadius.full), + child: InkWell( + borderRadius: BorderRadius.circular(AppRadius.full), + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(6), + child: Icon(icon, size: IconSizes.small, color: color ?? Colors.white), + ), + ), ), ), ); diff --git a/app/lib/widgets/library/book_list_item.dart b/app/lib/widgets/library/book_list_item.dart index e2bb3122..3fa5effe 100644 --- a/app/lib/widgets/library/book_list_item.dart +++ b/app/lib/widgets/library/book_list_item.dart @@ -7,7 +7,6 @@ import 'package:papyrus/themes/design_tokens.dart'; 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/themes/app_motion.dart'; import 'package:papyrus/widgets/shared/app_progress_indicator.dart'; import 'package:papyrus/widgets/shared/app_motion_control.dart'; @@ -55,8 +54,6 @@ class BookListItem extends StatefulWidget { } class _BookListItemState extends State { - bool _isHovered = false; - bool get _isDesktop => MediaQuery.of(context).size.width >= Breakpoints.desktopSmall; @override @@ -78,179 +75,169 @@ class _BookListItemState extends State { final isUnavailable = !isAcquisition && !widget.book.isPhysical && widget.deviceStatus == BookDeviceStatus.missing; - return MouseRegion( - onEnter: (_) => setState(() => _isHovered = true), - onExit: (_) => setState(() => _isHovered = false), - child: GestureDetector( - onLongPressStart: switch (isAcquisition) { - true => switch (widget.onAcquisitionSelectionToggle == null) { - true => null, - false => (_) => widget.onAcquisitionSelectionToggle!(), - }, - false when _isDesktop || inSelection => null, - false => (details) { - showBookContextMenu( - context: context, - book: widget.book, - position: details.globalPosition, - onRemoveFromShelf: widget.onRemoveFromShelf, - ); - }, + return GestureDetector( + onLongPressStart: switch (isAcquisition) { + true => switch (widget.onAcquisitionSelectionToggle == null) { + true => null, + false => (_) => widget.onAcquisitionSelectionToggle!(), + }, + false when _isDesktop || inSelection => null, + false => (details) { + showBookContextMenu( + context: context, + book: widget.book, + position: details.globalPosition, + onRemoveFromShelf: widget.onRemoveFromShelf, + ); + }, + }, + child: Material( + color: switch (inSelection && isSelected) { + true => colorScheme.primary.withValues(alpha: 0.08), + false when isUnavailable => colorScheme.surfaceContainerLow, + false => Colors.transparent, }, - child: Material( - color: switch (inSelection && isSelected) { - true => colorScheme.primary.withValues(alpha: 0.08), - false when isUnavailable => colorScheme.surfaceContainerLow, - false => Colors.transparent, - }, - child: InkWell( - onTap: onTap, - child: Container( - // The list owns the horizontal page gutter. - padding: const EdgeInsets.symmetric(vertical: Spacing.sm), - decoration: BoxDecoration( - border: Border(bottom: BorderSide(color: colorScheme.outlineVariant)), - ), - child: Row( - children: [ - // Selection checkbox (leading) - if (inSelection) ...[ - AppMotionControl( + child: InkWell( + onTap: onTap, + child: Container( + // The list owns the horizontal page gutter. + padding: const EdgeInsets.symmetric(vertical: Spacing.sm), + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: colorScheme.outlineVariant)), + ), + child: Row( + children: [ + // Selection checkbox (leading) + if (inSelection) ...[ + AppMotionControl( + value: isSelected, + builder: (focusNode) => Checkbox( + focusNode: focusNode, + semanticLabel: 'Select ${widget.book.title}', value: isSelected, - builder: (focusNode) => Checkbox( - focusNode: focusNode, - value: isSelected, - onChanged: (_) => onSelectionToggle?.call(), - ), + onChanged: (_) => onSelectionToggle?.call(), ), - const SizedBox(width: Spacing.sm), - ], - // Cover thumbnail - SizedBox( - width: ComponentSizes.bookCoverWidthList, - height: ComponentSizes.bookCoverHeightList, - child: ClipRRect(borderRadius: BorderRadius.circular(AppRadius.sm), child: _buildCover(context)), ), - const SizedBox(width: Spacing.md), + const SizedBox(width: Spacing.sm), + ], + // Cover thumbnail + SizedBox( + width: ComponentSizes.bookCoverWidthList, + height: ComponentSizes.bookCoverHeightList, + child: ClipRRect(borderRadius: BorderRadius.circular(AppRadius.sm), child: _buildCover(context)), + ), + const SizedBox(width: Spacing.md), - // Book info - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - widget.book.title, - style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), + // Book info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + widget.book.title, + style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: Spacing.xs), + Text( + widget.book.author, + style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (widget.acquisitionJob case final job?) ...[ const SizedBox(height: Spacing.xs), Text( - widget.book.author, - style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant), + acquisitionStatusLabel(job), + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: job.requiresAttention ? colorScheme.error : colorScheme.onSurfaceVariant, + fontWeight: job.requiresAttention ? FontWeight.w600 : null, + ), maxLines: 1, overflow: TextOverflow.ellipsis, ), - if (widget.acquisitionJob case final job?) ...[ - const SizedBox(height: Spacing.xs), + if (acquisitionTransferDetails(job) case final details?) ...[ + const SizedBox(height: 2), Text( - acquisitionStatusLabel(job), - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: job.requiresAttention ? colorScheme.error : colorScheme.onSurfaceVariant, - fontWeight: job.requiresAttention ? FontWeight.w600 : null, - ), + details, + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(color: colorScheme.onSurfaceVariant), maxLines: 1, overflow: TextOverflow.ellipsis, ), - if (acquisitionTransferDetails(job) case final details?) ...[ - const SizedBox(height: 2), + ], + if (job.progress case final progress?) ...[ + const SizedBox(height: Spacing.xs), + AppLinearProgressIndicator( + value: progress, + backgroundColor: colorScheme.surfaceContainerHighest, + color: job.requiresAttention ? colorScheme.error : colorScheme.primary, + minHeight: 3, + ), + ], + ] else if (widget.showProgress && widget.book.currentPosition > 0) ...[ + const SizedBox(height: Spacing.xs), + Row( + children: [ + Expanded( + child: AppLinearProgressIndicator( + value: widget.book.currentPosition, + backgroundColor: colorScheme.surfaceContainerHighest, + color: widget.book.readingStatus == LibraryReadingStatus.completed + ? colorScheme.tertiary + : colorScheme.primary, + minHeight: 3, + ), + ), + const SizedBox(width: Spacing.sm), Text( - details, + widget.book.progressLabel, style: Theme.of( context, ).textTheme.labelSmall?.copyWith(color: colorScheme.onSurfaceVariant), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - if (job.progress case final progress?) ...[ - const SizedBox(height: Spacing.xs), - AppLinearProgressIndicator( - value: progress, - backgroundColor: colorScheme.surfaceContainerHighest, - color: job.requiresAttention ? colorScheme.error : colorScheme.primary, - minHeight: 3, ), ], - ] else if (widget.showProgress && widget.book.currentPosition > 0) ...[ - const SizedBox(height: Spacing.xs), - Row( - children: [ - Expanded( - child: AppLinearProgressIndicator( - value: widget.book.currentPosition, - backgroundColor: colorScheme.surfaceContainerHighest, - color: widget.book.readingStatus == LibraryReadingStatus.completed - ? colorScheme.tertiary - : colorScheme.primary, - minHeight: 3, - ), - ), - const SizedBox(width: Spacing.sm), - Text( - widget.book.progressLabel, - style: Theme.of( - context, - ).textTheme.labelSmall?.copyWith(color: colorScheme.onSurfaceVariant), - ), - ], - ), - ], + ), ], - ), + ], ), + ), - if (!inSelection && !isAcquisition) - Row( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(width: Spacing.xs), - IconButton( - style: IconButton.styleFrom( - minimumSize: const Size.square(TouchTargets.mobileRecommended), - ), - onPressed: widget.onToggleFavorite == null - ? null - : () => widget.onToggleFavorite!(widget.isFavorite), - tooltip: widget.isFavorite ? 'Remove from favorites' : 'Add to favorites', - isSelected: widget.isFavorite, - icon: const Icon(Icons.favorite_border), - selectedIcon: const Icon(Icons.favorite), - color: widget.isFavorite ? colorScheme.error : colorScheme.onSurfaceVariant, + if (!inSelection && !isAcquisition) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(width: Spacing.xs), + IconButton( + style: IconButton.styleFrom( + minimumSize: const Size.square(TouchTargets.mobileRecommended), ), - // Overflow menu - show on hover (desktop only) - if (_isDesktop) - AnimatedOpacity( - key: ValueKey(AppMotion.disabled(context)), - opacity: _isHovered ? 1.0 : 0.0, - duration: AppMotion.duration(context, const Duration(milliseconds: 150)), - child: IconButton( - icon: const Icon(Icons.more_vert), - iconSize: IconSizes.action, - onPressed: () => showBookContextMenu( - context: context, - book: widget.book, - onRemoveFromShelf: widget.onRemoveFromShelf, - ), - tooltip: 'More options', - visualDensity: VisualDensity.compact, - ), - ), - ], - ), - ], - ), + onPressed: widget.onToggleFavorite == null + ? null + : () => widget.onToggleFavorite!(widget.isFavorite), + tooltip: widget.isFavorite ? 'Remove from favorites' : 'Add to favorites', + isSelected: widget.isFavorite, + icon: const Icon(Icons.favorite_border), + selectedIcon: const Icon(Icons.favorite), + color: widget.isFavorite ? colorScheme.error : colorScheme.onSurfaceVariant, + ), + IconButton( + icon: const Icon(Icons.more_vert), + iconSize: IconSizes.action, + onPressed: () => showBookContextMenu( + context: context, + book: widget.book, + onRemoveFromShelf: widget.onRemoveFromShelf, + ), + tooltip: 'More options', + style: IconButton.styleFrom(minimumSize: const Size.square(TouchTargets.mobileRecommended)), + ), + ], + ), + ], ), ), ), diff --git a/app/lib/widgets/library/library_filter_chips.dart b/app/lib/widgets/library/library_filter_chips.dart index 951433da..2f117ebf 100644 --- a/app/lib/widgets/library/library_filter_chips.dart +++ b/app/lib/widgets/library/library_filter_chips.dart @@ -54,13 +54,22 @@ class _DropdownFilterChip extends StatelessWidget { final colorScheme = Theme.of(context).colorScheme; final foregroundColor = isSelected ? colorScheme.onSecondaryContainer : colorScheme.onSurfaceVariant; - return Semantics( - button: true, - selected: isSelected, - label: '$semanticLabel: $label', - child: AppMotionControl( - value: null, - builder: (focusNode) => ActionChip( + return AppMotionControl( + value: null, + builder: (focusNode) => ListenableBuilder( + listenable: focusNode, + builder: (context, child) => Semantics( + button: true, + selected: isSelected, + label: '$semanticLabel: $label', + focusable: true, + focused: focusNode.hasFocus, + onFocus: focusNode.requestFocus, + onTap: onPressed, + excludeSemantics: true, + child: child, + ), + child: ActionChip( focusNode: focusNode, chipAnimationStyle: appChipAnimationStyle(context), tooltip: tooltip, @@ -542,8 +551,17 @@ class LibraryFilterChips extends StatelessWidget { final orderKey = [...chips.map((chip) => chip.id), if (hasActiveSelections) 'clear-all'].join('-'); + final labelPainter = TextPainter( + text: TextSpan(text: 'Filter', style: Theme.of(context).textTheme.labelLarge), + textScaler: MediaQuery.textScalerOf(context), + textDirection: Directionality.of(context), + )..layout(); + + final chipHeight = (labelPainter.height + 24).clamp(48.0, double.infinity); + labelPainter.dispose(); + return SizedBox( - height: 48, + height: chipHeight, child: AnimatedSwitcher( key: ValueKey(AppMotion.disabled(context)), duration: AppMotion.duration(context, const Duration(milliseconds: 200)), diff --git a/app/lib/widgets/library/library_filter_fields.dart b/app/lib/widgets/library/library_filter_fields.dart index d0c1123d..8adcdeb5 100644 --- a/app/lib/widgets/library/library_filter_fields.dart +++ b/app/lib/widgets/library/library_filter_fields.dart @@ -206,6 +206,7 @@ class LibraryProgressFilterFieldState extends State child: Material( type: MaterialType.transparency, child: InkWell( + hoverColor: Colors.transparent, excludeFromSemantics: true, onTap: toggleEnabled, child: ConstrainedBox( diff --git a/app/lib/widgets/profile/accessibility_settings_section.dart b/app/lib/widgets/profile/accessibility_settings_section.dart index b3e2ec01..7a6d47ab 100644 --- a/app/lib/widgets/profile/accessibility_settings_section.dart +++ b/app/lib/widgets/profile/accessibility_settings_section.dart @@ -1,81 +1,109 @@ import 'package:flutter/material.dart'; import 'package:papyrus/providers/preferences_provider.dart'; import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/widgets/profile/profile_picker_sheet.dart'; import 'package:papyrus/widgets/settings/settings_row.dart'; import 'package:papyrus/widgets/settings/settings_section.dart'; import 'package:provider/provider.dart'; -/// Accessibility settings section for ProfilePage. class AccessibilitySettingsSection extends StatelessWidget { final bool isDesktop; const AccessibilitySettingsSection({super.key, this.isDesktop = false}); + static String enlargementLabel(double value) => value == 1 ? 'System default' : '+${((value - 1) * 100).round()}%'; + @override Widget build(BuildContext context) { + final prefs = context.watch(); + final colorScheme = Theme.of(context).colorScheme; + final children = [ + SettingsRow( + label: 'Interface text enlargement', + value: enlargementLabel(prefs.interfaceTextEnlargement), + onTap: () => showProfilePickerSheet( + context, + title: 'Interface text enlargement', + items: PreferencesProvider.interfaceTextEnlargements + .map((value) => (enlargementLabel(value), '$value')) + .toList(), + selected: '${prefs.interfaceTextEnlargement}', + onSelected: (value) => prefs.interfaceTextEnlargement = double.parse(value), + ), + ), + SettingsRow( + label: 'Interface font', + value: prefs.dyslexiaFont ? 'OpenDyslexic' : 'System default', + onTap: () => showProfilePickerSheet( + context, + title: 'Interface font', + items: const [('System default', 'system'), ('OpenDyslexic', 'opendyslexic')], + selected: prefs.dyslexiaFont ? 'opendyslexic' : 'system', + onSelected: (value) => prefs.dyslexiaFont = value == 'opendyslexic', + ), + ), + Padding( + padding: const EdgeInsets.all(Spacing.sm), + child: Semantics( + label: 'Interface text preview', + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(Spacing.md), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + border: Border.all(color: colorScheme.outlineVariant), + borderRadius: BorderRadius.circular(AppRadius.md), + ), + child: Text( + 'The quick brown fox jumps over the lazy dog.', + style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: colorScheme.onSurface), + ), + ), + ), + ), + SettingsToggleRow( + label: 'Increase contrast', + value: prefs.isEinkMode || prefs.increaseContrast, + onChanged: prefs.isEinkMode ? null : (value) => prefs.increaseContrast = value, + ), + _description( + context, + prefs.isEinkMode + ? 'E-ink already provides increased contrast.' + : 'Always increase contrast. When off, follows your device setting.', + ), + SettingsToggleRow( + label: 'Bold interface text', + value: prefs.boldInterfaceText, + onChanged: (value) => prefs.boldInterfaceText = value, + ), + _description(context, 'Always use bold interface text. When off, follows your device setting.'), + SettingsToggleRow( + label: 'Reduce animations', + value: prefs.reduceAnimations, + onChanged: (value) => prefs.reduceAnimations = value, + ), + _description(context, 'Your device’s reduced-motion setting is always respected. E-ink also reduces motion.'), + ]; + if (isDesktop) { - return _buildDesktop(context); + return SettingsCard(children: children); } - return _buildMobile(context); - } - - Widget _buildMobile(BuildContext context) { - final prefs = context.watch(); - return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const SettingsSectionHeader(title: 'Accessibility'), - SettingsToggleRow( - label: 'Reduce animations', - value: prefs.reduceAnimations, - onChanged: (value) => prefs.reduceAnimations = value, - ), - SettingsToggleRow( - label: 'Dyslexia-friendly font', - value: prefs.dyslexiaFont, - onChanged: (value) => prefs.dyslexiaFont = value, - ), + ...children, ], ); } - Widget _buildDesktop(BuildContext context) { - final prefs = context.watch(); - - return SettingsCard( - children: [ - SettingsToggleRow( - label: 'Reduce animations', - value: prefs.reduceAnimations, - onChanged: (value) => prefs.reduceAnimations = value, - ), - Padding( - padding: const EdgeInsets.only(left: Spacing.sm, right: Spacing.sm, bottom: Spacing.md), - child: Text( - 'Minimizes motion effects throughout the app. ' - 'Separate from e-ink mode.', - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), - ), - ), - SettingsToggleRow( - label: 'Dyslexia-friendly font', - value: prefs.dyslexiaFont, - onChanged: (value) => prefs.dyslexiaFont = value, - ), - Padding( - padding: const EdgeInsets.only(left: Spacing.sm, right: Spacing.sm, bottom: Spacing.md), - child: Text( - 'Use OpenDyslexic font across the app interface.', - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), - ), - ), - ], - ); - } + Widget _description(BuildContext context, String text) => Padding( + padding: const EdgeInsets.only(left: Spacing.sm, right: Spacing.sm, bottom: Spacing.md), + child: Text( + text, + style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), + ), + ); } diff --git a/app/lib/widgets/profile/account_deletion_dialog.dart b/app/lib/widgets/profile/account_deletion_dialog.dart index 62fe871e..9d843f24 100644 --- a/app/lib/widgets/profile/account_deletion_dialog.dart +++ b/app/lib/widgets/profile/account_deletion_dialog.dart @@ -4,6 +4,7 @@ import 'package:intl/intl.dart'; import 'package:papyrus/auth/auth_api_client.dart'; import 'package:papyrus/auth/auth_models.dart'; import 'package:papyrus/providers/auth_provider.dart'; +import 'package:papyrus/services/guest_account_deletion_service.dart'; import 'package:papyrus/themes/app_motion.dart'; import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/widgets/shared/app_progress_indicator.dart'; @@ -17,6 +18,9 @@ Future showAccountDeletionDialog( final router = GoRouter.of(context); final messenger = ScaffoldMessenger.of(context); final animationStyle = AppMotion.animationStyle(context); + final deleteGuest = authorization == null && auth.isOfflineMode && !auth.isSignedIn + ? context.read().delete + : null; final message = await showDialog( context: context, @@ -25,6 +29,7 @@ Future showAccountDeletionDialog( builder: (_) => AccountDeletionDialog( auth: auth, authorization: authorization, + deleteGuest: deleteGuest, ), ); @@ -48,11 +53,13 @@ Future showAccountDeletionDialog( class AccountDeletionDialog extends StatefulWidget { final AuthProvider auth; final AccountDeletionAuthorization? authorization; + final Future Function()? deleteGuest; const AccountDeletionDialog({ super.key, required this.auth, this.authorization, + this.deleteGuest, }); @override @@ -71,6 +78,9 @@ class _AccountDeletionDialogState extends State { if (await widget.auth.recoverAccount(authorization)) { message = 'Account kept.'; } + } else if (widget.deleteGuest != null) { + await widget.deleteGuest!(); + message = 'Anonymous account and local data deleted.'; } else { final deadline = await widget.auth.deleteAccount(); @@ -100,7 +110,13 @@ class _AccountDeletionDialogState extends State { await action(); } catch (error) { if (mounted) { - _error = error is AuthApiException ? error.message : 'Unable to connect. Please try again.'; + if (error is AuthApiException) { + _error = error.message; + } else if (widget.deleteGuest != null) { + _error = 'Could not delete all local data. Please try again.'; + } else { + _error = 'Unable to connect. Please try again.'; + } } } finally { if (mounted) { @@ -144,6 +160,12 @@ class _AccountDeletionDialogState extends State { ], ), ) + else if (widget.deleteGuest != null) + const Text( + 'This permanently deletes your anonymous account’s local library, imported files, ' + 'covers, reading data and saved catalogs from this device. ' + 'You’ll return to the login page. This cannot be undone.', + ) else const Text( 'Your account, synced library and reading data will be permanently deleted after 30 days. ' diff --git a/app/lib/widgets/profile/account_profile_form.dart b/app/lib/widgets/profile/account_profile_form.dart new file mode 100644 index 00000000..daa20ea7 --- /dev/null +++ b/app/lib/widgets/profile/account_profile_form.dart @@ -0,0 +1,275 @@ +import 'dart:async'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:papyrus/providers/auth_provider.dart'; +import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/services/profile_photo.dart'; +import 'package:papyrus/widgets/shared/app_progress_indicator.dart'; +import 'package:provider/provider.dart'; + +class AccountProfileForm extends StatefulWidget { + const AccountProfileForm({super.key}); + + @override + State createState() => _AccountProfileFormState(); +} + +class _AccountProfileFormState extends State { + final _formKey = GlobalKey(); + late final TextEditingController _nameController; + bool _saving = false; + String? _message; + bool _pickingPhoto = false; + Uint8List? _photoBytes; + String? _loadedPhotoUrl; + Uint8List? _savedPhotoBytes; + int _photoLoadGeneration = 0; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final auth = context.watch(); + final url = auth.user?.avatarUrl; + + if (url != _loadedPhotoUrl) { + _loadedPhotoUrl = url; + _savedPhotoBytes = null; + final generation = ++_photoLoadGeneration; + + if (url != null && url.startsWith('/users/me/avatar/')) { + unawaited(_loadSavedPhoto(auth, url, generation)); + } + } + } + + Future _loadSavedPhoto(AuthProvider auth, String url, int generation) async { + try { + final bytes = await auth.downloadProfilePhoto(url); + + if (mounted && generation == _photoLoadGeneration) { + setState(() => _savedPhotoBytes = bytes); + } + } catch (_) { + // A failed download must not discard a newly saved photo or an unsaved selection. + } + } + + @override + void initState() { + super.initState(); + _nameController = TextEditingController( + text: context.read().profileDisplayName, + ); + } + + @override + void dispose() { + _nameController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final auth = context.watch(); + final theme = Theme.of(context); + final hasChanges = _nameController.text.trim() != auth.profileDisplayName || _photoBytes != null; + + final fields = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!auth.isOfflineMode) ...[ + TextFormField( + key: ValueKey(auth.user?.email), + initialValue: auth.user?.email?.trim().isNotEmpty == true ? auth.user!.email! : 'No email provided', + enabled: false, + decoration: const InputDecoration(labelText: 'Email'), + ), + const SizedBox(height: Spacing.md), + ], + TextFormField( + controller: _nameController, + enabled: auth.canEditProfile && !_saving, + textCapitalization: TextCapitalization.words, + textInputAction: TextInputAction.done, + autofillHints: const [AutofillHints.name], + decoration: const InputDecoration(labelText: 'Display name'), + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Name cannot be empty'; + } + + if (value.trim().length > 100) { + return 'Name is too long'; + } + + return null; + }, + autovalidateMode: AutovalidateMode.onUserInteraction, + onChanged: (_) => setState(() => _message = null), + onFieldSubmitted: (_) { + if (auth.canEditProfile && !_saving && !_pickingPhoto && hasChanges) { + _save(); + } + }, + ), + const SizedBox(height: Spacing.lg), + FilledButton( + onPressed: auth.canEditProfile && !_saving && !_pickingPhoto && hasChanges ? _save : null, + child: _saving + ? const SizedBox.square( + dimension: 20, + child: AppCircularProgressIndicator(strokeWidth: 2, semanticsLabel: 'Saving profile'), + ) + : const Text('Save'), + ), + if (_message != null) ...[ + const SizedBox(height: Spacing.sm), + Semantics( + liveRegion: true, + child: Text( + _message!, + style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.error), + ), + ), + ], + ], + ); + + return Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildAvatar(context), + const SizedBox(height: Spacing.lg), + fields, + ], + ), + ); + } + + Widget _buildAvatar(BuildContext context) { + final theme = Theme.of(context); + final auth = context.watch(); + final url = auth.user?.avatarUrl; + final fallback = Icon(Icons.person_outline, size: 40, color: theme.colorScheme.onPrimaryContainer); + + Widget photo = fallback; + + final bytes = _photoBytes ?? _savedPhotoBytes ?? auth.profilePhotoBytes; + + if (bytes != null) { + photo = Image.memory(bytes, fit: BoxFit.cover, errorBuilder: (_, _, _) => fallback); + } else if (url != null && (url.startsWith('https://') || url.startsWith('http://'))) { + photo = Image.network(url, fit: BoxFit.cover, errorBuilder: (_, _, _) => fallback); + } + + return Tooltip( + message: _pickingPhoto ? 'Opening photo…' : 'Change photo', + child: TextButton( + onPressed: auth.canEditProfile && !_saving && !_pickingPhoto ? _pickPhoto : null, + style: + TextButton.styleFrom( + fixedSize: const Size.square(88), + visualDensity: VisualDensity.standard, + padding: EdgeInsets.zero, + shape: const CircleBorder(), + backgroundColor: theme.colorScheme.primaryContainer, + ).copyWith( + side: WidgetStateProperty.resolveWith( + (states) => states.contains(WidgetState.focused) + ? BorderSide(width: 2, color: theme.colorScheme.onSurface) + : BorderSide.none, + ), + ), + child: ExcludeSemantics( + child: ClipOval(child: SizedBox.expand(child: photo)), + ), + ), + ); + } + + Future _pickPhoto() async { + setState(() { + _pickingPhoto = true; + _message = null; + }); + + try { + final result = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: ['jpg', 'jpeg', 'png'], + allowMultiple: false, + withData: true, + ); + + if (result == null || !mounted) { + return; + } + + final file = result.files.single; + + if (file.size > maxProfilePhotoBytes) { + throw const FormatException('Choose a photo smaller than 5 MB.'); + } + + if (file.bytes == null) { + throw const FormatException('This photo could not be opened. Choose another image.'); + } + + final bytes = await compute(prepareProfilePhoto, file.bytes!); + + if (mounted) { + setState(() => _photoBytes = bytes); + } + } catch (error) { + if (mounted) { + setState(() { + _message = error is FormatException ? error.message : 'Could not open this photo. Please try again.'; + }); + } + } finally { + if (mounted) { + setState(() => _pickingPhoto = false); + } + } + } + + Future _save() async { + if (!_formKey.currentState!.validate()) { + return; + } + + setState(() { + _saving = true; + _message = null; + }); + + final auth = context.read(); + final name = _nameController.text.trim(); + final success = await auth.updateProfile(displayName: name, photoBytes: _photoBytes); + + if (!mounted) { + return; + } + + setState(() { + _saving = false; + _message = success ? null : auth.error ?? 'Could not save your profile. Please try again.'; + + if (success) { + _nameController.text = name; + + if (_photoBytes != null) { + _savedPhotoBytes = _photoBytes; + _loadedPhotoUrl = auth.user?.avatarUrl; + _photoLoadGeneration++; + } + + _photoBytes = null; + } + }); + } +} diff --git a/app/lib/widgets/profile/account_settings_section.dart b/app/lib/widgets/profile/account_settings_section.dart index cfd8b01f..0b4b557a 100644 --- a/app/lib/widgets/profile/account_settings_section.dart +++ b/app/lib/widgets/profile/account_settings_section.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; import 'package:papyrus/providers/auth_provider.dart'; +import 'package:papyrus/widgets/profile/account_profile_form.dart'; import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/widgets/settings/settings_row.dart'; import 'package:papyrus/widgets/profile/account_deletion_dialog.dart'; @@ -26,14 +26,19 @@ class AccountSettingsSection extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const SettingsSectionHeader(title: 'Account'), + const SettingsSectionHeader( + title: 'Account', + padding: EdgeInsets.only(bottom: Spacing.md), + ), + _buildProfileForm(context), + const SizedBox(height: Spacing.lg), const SettingsRow(label: 'Change password', enabled: false, showChevron: false), _buildGoogleConnection(context), const SettingsRow(label: 'Two-factor authentication', enabled: false, showChevron: false), const SettingsRow(label: 'Active sessions', enabled: false, showChevron: false), SettingsRow( label: 'Delete account', - enabled: context.watch().isSignedIn, + enabled: context.watch().isSignedIn || context.watch().isOfflineMode, onTap: () => showAccountDeletionDialog(context), ), ], @@ -41,45 +46,12 @@ class AccountSettingsSection extends StatelessWidget { } Widget _buildDesktop(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final textTheme = Theme.of(context).textTheme; final auth = context.watch(); - final user = auth.user; - final displayName = user?.displayName ?? 'Anonymous User'; - final userEmail = user?.email; - final email = (userEmail != null && userEmail.trim().isNotEmpty) ? userEmail : 'No email provided'; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - SettingsCard( - children: [ - Row( - children: [ - _buildAvatar(context, displayName: displayName, avatarUrl: user?.avatarUrl, size: 96), - const SizedBox(width: Spacing.lg), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(displayName, style: textTheme.headlineSmall), - const SizedBox(height: Spacing.xs), - Text(email, style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)), - const SizedBox(height: Spacing.md), - Align( - alignment: Alignment.centerLeft, - child: OutlinedButton( - onPressed: () => context.go('/profile/edit'), - child: const Text('Edit profile'), - ), - ), - ], - ), - ), - ], - ), - ], - ), + SettingsCard(children: [_buildProfileForm(context)]), const SizedBox(height: Spacing.lg), SettingsCard( title: 'Security', @@ -100,7 +72,7 @@ class AccountSettingsSection extends StatelessWidget { children: [ SettingsRow( label: 'Delete account', - enabled: context.watch().isSignedIn, + enabled: auth.isSignedIn || auth.isOfflineMode, onTap: () => showAccountDeletionDialog(context), ), ], @@ -119,51 +91,8 @@ class AccountSettingsSection extends StatelessWidget { ); } - Widget _buildAvatar( - BuildContext context, { - required String displayName, - required String? avatarUrl, - required double size, - }) { - final colorScheme = Theme.of(context).colorScheme; - final textTheme = Theme.of(context).textTheme; - final initials = _getInitials(displayName); - - return Container( - width: size, - height: size, - decoration: BoxDecoration(borderRadius: BorderRadius.circular(size / 2), color: colorScheme.primaryContainer), - clipBehavior: Clip.antiAlias, - child: avatarUrl != null && avatarUrl.isNotEmpty - ? Image.network( - avatarUrl, - fit: BoxFit.cover, - errorBuilder: (_, _, _) => Center( - child: Text( - initials, - style: textTheme.headlineMedium?.copyWith( - color: colorScheme.onPrimaryContainer, - fontSize: size * 0.35, - ), - ), - ), - ) - : Center( - child: Text( - initials, - style: textTheme.headlineMedium?.copyWith(color: colorScheme.onPrimaryContainer, fontSize: size * 0.35), - ), - ), - ); - } - - String _getInitials(String name) { - final parts = name.split(' '); - - if (parts.length >= 2) { - return '${parts[0][0]}${parts[1][0]}'.toUpperCase(); - } - - return name.isNotEmpty ? name[0].toUpperCase() : '?'; + Widget _buildProfileForm(BuildContext context) { + final auth = context.watch(); + return AccountProfileForm(key: ValueKey(auth.user?.userId ?? 'guest')); } } diff --git a/app/lib/widgets/profile/notifications_settings_section.dart b/app/lib/widgets/profile/notifications_settings_section.dart index c9e97358..222cc599 100644 --- a/app/lib/widgets/profile/notifications_settings_section.dart +++ b/app/lib/widgets/profile/notifications_settings_section.dart @@ -29,17 +29,14 @@ class NotificationsSettingsSection extends StatelessWidget { SettingsToggleRow( label: 'Goal reminders', value: prefs.goalReminders, - onChanged: (value) => prefs.goalReminders = value, ), SettingsToggleRow( label: 'Streak alerts', value: prefs.streakAlerts, - onChanged: (value) => prefs.streakAlerts = value, ), SettingsToggleRow( label: 'Sync status', value: prefs.syncStatusNotifications, - onChanged: (value) => prefs.syncStatusNotifications = value, ), ], ); @@ -53,17 +50,14 @@ class NotificationsSettingsSection extends StatelessWidget { SettingsToggleRow( label: 'Goal reminders', value: prefs.goalReminders, - onChanged: (value) => prefs.goalReminders = value, ), SettingsToggleRow( label: 'Streak alerts', value: prefs.streakAlerts, - onChanged: (value) => prefs.streakAlerts = value, ), SettingsToggleRow( label: 'Sync status', value: prefs.syncStatusNotifications, - onChanged: (value) => prefs.syncStatusNotifications = value, ), ], ); diff --git a/app/lib/widgets/profile/privacy_settings_section.dart b/app/lib/widgets/profile/privacy_settings_section.dart index 3fdc1e24..396ae2a3 100644 --- a/app/lib/widgets/profile/privacy_settings_section.dart +++ b/app/lib/widgets/profile/privacy_settings_section.dart @@ -30,12 +30,11 @@ class PrivacySettingsSection extends StatelessWidget { SettingsToggleRow( label: 'Analytics', value: prefs.analyticsOptIn, - onChanged: (value) => prefs.analyticsOptIn = value, ), - SettingsRow(label: 'Privacy policy', onTap: () {}), - SettingsRow(label: 'Export all data', onTap: () {}), - SettingsRow(label: 'Import data', onTap: () {}), - SettingsRow(label: 'Clear local data', onTap: () {}), + const SettingsRow(label: 'Privacy policy', enabled: false), + const SettingsRow(label: 'Export all data', enabled: false), + const SettingsRow(label: 'Import data', enabled: false), + const SettingsRow(label: 'Clear local data', enabled: false), ], ); } @@ -52,7 +51,6 @@ class PrivacySettingsSection extends StatelessWidget { SettingsToggleRow( label: 'Send anonymous usage data', value: prefs.analyticsOptIn, - onChanged: (value) => prefs.analyticsOptIn = value, ), Padding( padding: const EdgeInsets.symmetric(horizontal: Spacing.sm), @@ -70,15 +68,15 @@ class PrivacySettingsSection extends StatelessWidget { SettingsCard( title: 'Data management', children: [ - SettingsRow(label: 'Export all data', onTap: () {}), - SettingsRow(label: 'Import data', onTap: () {}), - SettingsRow(label: 'Clear local data', onTap: () {}), + const SettingsRow(label: 'Export all data', enabled: false), + const SettingsRow(label: 'Import data', enabled: false), + const SettingsRow(label: 'Clear local data', enabled: false), ], ), const SizedBox(height: Spacing.lg), SettingsCard( title: 'Legal', - children: [SettingsRow(label: 'Privacy policy', onTap: () {})], + children: [const SettingsRow(label: 'Privacy policy', enabled: false)], ), ], ); diff --git a/app/lib/widgets/profile/profile_header.dart b/app/lib/widgets/profile/profile_header.dart deleted file mode 100644 index 57c59821..00000000 --- a/app/lib/widgets/profile/profile_header.dart +++ /dev/null @@ -1,166 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:papyrus/themes/design_tokens.dart'; - -/// Header section for the profile page displaying user avatar, name, and email. -/// -/// Supports two display modes: -/// - **Mobile**: Centered vertical layout with rounded avatar -/// - **Desktop**: Horizontal layout with avatar alongside user info -/// -/// ## Features -/// -/// - Circular avatar with network image or initials fallback -/// - User display name and email -/// - Optional "Edit Profile" button -/// - Responsive sizing based on display mode -/// -/// ## Example -/// -/// ```dart -/// ProfileHeader( -/// displayName: 'John Doe', -/// email: 'john@email.com', -/// avatarUrl: user.photoURL, -/// onEditProfile: () => context.push('/profile/edit'), -/// ) -/// ``` -class ProfileHeader extends StatelessWidget { - /// User's display name. - final String displayName; - - /// User's email address. - final String email; - - /// URL for the user's avatar image. - final String? avatarUrl; - - /// Called when "Edit Profile" button is tapped. - final VoidCallback? onEditProfile; - - /// Whether to use desktop horizontal layout. - final bool isDesktopLayout; - - /// Creates a profile header widget. - const ProfileHeader({ - super.key, - required this.displayName, - required this.email, - this.avatarUrl, - this.onEditProfile, - this.isDesktopLayout = false, - }); - - /// Gets user initials from display name. - String get _initials { - final parts = displayName.split(' '); - - if (parts.length >= 2) { - return '${parts[0][0]}${parts[1][0]}'.toUpperCase(); - } - - return displayName.isNotEmpty ? displayName[0].toUpperCase() : '?'; - } - - @override - Widget build(BuildContext context) { - if (isDesktopLayout) { - return _buildDesktopHeader(context); - } - - return _buildMobileHeader(context); - } - - /// Mobile layout with centered vertical alignment. - Widget _buildMobileHeader(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final textTheme = Theme.of(context).textTheme; - - return Column( - children: [ - _buildAvatar(context, size: 128, borderRadius: 64), - const SizedBox(height: Spacing.md), - Text(displayName, style: textTheme.headlineSmall, textAlign: TextAlign.center), - const SizedBox(height: Spacing.xs), - Text( - email, - style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant), - textAlign: TextAlign.center, - ), - const SizedBox(height: Spacing.md), - SizedBox( - width: 200, - height: 40, - child: OutlinedButton(onPressed: onEditProfile, child: const Text('Edit profile')), - ), - ], - ); - } - - /// Desktop layout with horizontal avatar and info alignment. - Widget _buildDesktopHeader(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final textTheme = Theme.of(context).textTheme; - - return Container( - padding: const EdgeInsets.all(Spacing.lg), - decoration: BoxDecoration( - color: colorScheme.surfaceContainerLow, - borderRadius: BorderRadius.circular(AppRadius.lg), - ), - child: Row( - children: [ - _buildAvatar(context, size: 96, borderRadius: 48), - const SizedBox(width: Spacing.lg), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text(displayName, style: textTheme.headlineMedium), - const SizedBox(height: Spacing.xs), - Text(email, style: textTheme.bodyLarge?.copyWith(color: colorScheme.onSurfaceVariant)), - ], - ), - ), - const SizedBox(width: Spacing.md), - OutlinedButton( - // Override the theme's infinite-width minimumSize for Row usage. - style: OutlinedButton.styleFrom(minimumSize: Size.zero), - onPressed: onEditProfile, - child: const Text('Edit profile'), - ), - ], - ), - ); - } - - /// Builds circular avatar for standard modes. - Widget _buildAvatar(BuildContext context, {required double size, required double borderRadius}) { - final colorScheme = Theme.of(context).colorScheme; - final textTheme = Theme.of(context).textTheme; - - return Container( - width: size, - height: size, - decoration: BoxDecoration(borderRadius: BorderRadius.circular(borderRadius), color: colorScheme.primaryContainer), - clipBehavior: Clip.antiAlias, - child: avatarUrl != null && avatarUrl!.isNotEmpty - ? Image.network( - avatarUrl!, - fit: BoxFit.cover, - errorBuilder: (_, _, _) => _buildInitialsAvatar(context, colorScheme, textTheme, size), - ) - : _buildInitialsAvatar(context, colorScheme, textTheme, size), - ); - } - - /// Builds initials fallback for avatar. - Widget _buildInitialsAvatar(BuildContext context, ColorScheme colorScheme, TextTheme textTheme, double size) { - return Center( - child: Text( - _initials, - style: textTheme.headlineMedium?.copyWith(color: colorScheme.onPrimaryContainer, fontSize: size * 0.35), - ), - ); - } -} diff --git a/app/lib/widgets/profile/profile_picker_sheet.dart b/app/lib/widgets/profile/profile_picker_sheet.dart index bce7c100..675b8a37 100644 --- a/app/lib/widgets/profile/profile_picker_sheet.dart +++ b/app/lib/widgets/profile/profile_picker_sheet.dart @@ -14,6 +14,7 @@ void showProfilePickerSheet( sheetAnimationStyle: AppMotion.animationStyle(context), context: context, isScrollControlled: true, + useRootNavigator: true, useSafeArea: true, builder: (sheetContext) => AppBottomSheet( header: Text(title, style: Theme.of(sheetContext).textTheme.titleMedium), @@ -24,16 +25,20 @@ void showProfilePickerSheet( final colorScheme = Theme.of(sheetContext).colorScheme; final isSelected = selected == item.$2; - return ListTile( - title: Text(item.$1), - leading: Icon( - isSelected ? Icons.radio_button_checked : Icons.radio_button_unchecked, - color: isSelected ? colorScheme.primary : colorScheme.outline, + return Semantics( + checked: isSelected, + inMutuallyExclusiveGroup: true, + child: ListTile( + title: Text(item.$1), + leading: Icon( + isSelected ? Icons.radio_button_checked : Icons.radio_button_unchecked, + color: isSelected ? colorScheme.primary : colorScheme.outline, + ), + onTap: () { + onSelected(item.$2); + Navigator.pop(sheetContext); + }, ), - onTap: () { - onSelected(item.$2); - Navigator.pop(sheetContext); - }, ); }).toList(), ), diff --git a/app/lib/widgets/profile/storage_sync_settings_section.dart b/app/lib/widgets/profile/storage_sync_settings_section.dart index c9a65eae..68735e8c 100644 --- a/app/lib/widgets/profile/storage_sync_settings_section.dart +++ b/app/lib/widgets/profile/storage_sync_settings_section.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:papyrus/data/data_store.dart'; import 'package:papyrus/media/media_upload_queue.dart'; +import 'package:papyrus/media/media_storage_scope.dart'; import 'package:papyrus/powersync/powersync_service.dart'; import 'package:papyrus/powersync/storage_sync_controller.dart'; import 'package:papyrus/powersync/sync_state.dart'; @@ -324,6 +325,7 @@ class StorageSyncSettingsSection extends StatelessWidget { showModalBottomSheet( sheetAnimationStyle: AppMotion.animationStyle(context), context: context, + useRootNavigator: true, isScrollControlled: true, useSafeArea: true, builder: (sheetContext) => AppBottomSheet( @@ -529,7 +531,17 @@ class StorageSyncSettingsSection extends StatelessWidget { final snackBarAnimationStyle = AppMotion.animationStyle(context); try { - await context.read().clearGuestLibrary(); + final files = context.read(); + + await context.read().clearGuestLibrary( + deleteFiles: (books) async { + for (final book in books) { + await files.deleteBookFile(book.id); + } + + await files.clearCoverFiles(MediaStorageScope.localGuest); + }, + ); messenger.showSnackBar( snackBarAnimationStyle: snackBarAnimationStyle, diff --git a/app/lib/widgets/settings/settings_row.dart b/app/lib/widgets/settings/settings_row.dart index 63fb99db..a740ad83 100644 --- a/app/lib/widgets/settings/settings_row.dart +++ b/app/lib/widgets/settings/settings_row.dart @@ -143,17 +143,51 @@ class SettingsToggleRow extends StatelessWidget { Widget build(BuildContext context) { final textTheme = Theme.of(context).textTheme; - return Padding( - padding: const EdgeInsets.symmetric(horizontal: Spacing.sm, vertical: Spacing.xs), - child: Row( - children: [ - Expanded(child: Text(label, style: textTheme.bodyLarge)), - AppMotionControl( - value: value, - enabled: onChanged != null, - builder: (focusNode) => Switch(focusNode: focusNode, value: value, onChanged: onChanged), + final enabled = onChanged != null; + final toggle = enabled ? () => onChanged!(!value) : null; + + return MergeSemantics( + child: Semantics( + toggled: value, + enabled: enabled, + onTap: toggle, + child: AppMotionControl( + value: value, + enabled: enabled, + builder: (focusNode) => InkWell( + hoverColor: Colors.transparent, + focusNode: focusNode, + canRequestFocus: enabled, + onTap: toggle, + excludeFromSemantics: true, + borderRadius: BorderRadius.circular(AppRadius.sm), + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: TouchTargets.mobileRecommended), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: Spacing.sm, vertical: Spacing.xs), + child: Row( + children: [ + Expanded( + child: Text( + label, + style: textTheme.bodyLarge?.copyWith( + color: enabled ? null : Theme.of(context).disabledColor, + ), + ), + ), + ExcludeSemantics( + child: ExcludeFocus( + child: IgnorePointer( + child: Switch(value: value, onChanged: onChanged), + ), + ), + ), + ], + ), + ), + ), ), - ], + ), ), ); } diff --git a/app/lib/widgets/settings/settings_section.dart b/app/lib/widgets/settings/settings_section.dart index 8d8394fa..85b54046 100644 --- a/app/lib/widgets/settings/settings_section.dart +++ b/app/lib/widgets/settings/settings_section.dart @@ -7,9 +7,14 @@ import 'package:papyrus/themes/design_tokens.dart'; class SettingsSectionHeader extends StatelessWidget { /// Section title text. final String title; + final EdgeInsetsGeometry padding; /// Creates a settings section header. - const SettingsSectionHeader({super.key, required this.title}); + const SettingsSectionHeader({ + super.key, + required this.title, + this.padding = const EdgeInsets.only(top: Spacing.lg, bottom: Spacing.md), + }); @override Widget build(BuildContext context) { @@ -17,13 +22,12 @@ class SettingsSectionHeader extends StatelessWidget { final textTheme = Theme.of(context).textTheme; return Padding( - padding: const EdgeInsets.only(top: Spacing.lg, bottom: Spacing.sm), - child: Text( - title, - style: textTheme.labelMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w500, - letterSpacing: 0.5, + padding: padding, + child: Semantics( + header: true, + child: Text( + title, + style: textTheme.titleLarge?.copyWith(color: colorScheme.onSurface, fontWeight: FontWeight.w600), ), ), ); diff --git a/app/lib/widgets/shared/app_bottom_sheet.dart b/app/lib/widgets/shared/app_bottom_sheet.dart index 5fe0d4fd..376888ba 100644 --- a/app/lib/widgets/shared/app_bottom_sheet.dart +++ b/app/lib/widgets/shared/app_bottom_sheet.dart @@ -64,29 +64,36 @@ class AppBottomSheet extends StatelessWidget { return Padding(padding: contentPadding, child: body); } - Widget buildFrame(ScrollController? controller, {required bool compact}) => SafeArea( - top: false, - bottom: footer == null, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - BottomSheetHeader( - key: headerKey ?? const Key('bottom-sheet-header'), - title: header == null ? title : null, - onDismiss: dismiss, - compact: compact, - child: header, - ), - const Divider(height: 1), - Flexible( - fit: expandBody ? FlexFit.tight : FlexFit.loose, - child: buildBody(controller), - ), - if (footer != null) BottomSheetFooter(key: footerKey, child: footer!), - ], - ), - ); + Widget buildFrame(ScrollController? controller, {required bool compact}) { + final sheetHeader = BottomSheetHeader( + key: headerKey ?? const Key('bottom-sheet-header'), + title: header == null ? title : null, + onDismiss: dismiss, + compact: compact, + child: header, + ); + + return SafeArea( + top: false, + bottom: footer == null, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (MediaQuery.textScalerOf(context).scale(16) > 32) + Flexible(child: SingleChildScrollView(primary: false, child: sheetHeader)) + else + sheetHeader, + const Divider(height: 1), + Flexible( + fit: expandBody ? FlexFit.tight : FlexFit.loose, + child: buildBody(controller), + ), + if (footer != null) BottomSheetFooter(key: footerKey, child: footer!), + ], + ), + ); + } return Padding( padding: EdgeInsets.only(bottom: avoidKeyboard ? MediaQuery.viewInsetsOf(context).bottom : 0), diff --git a/app/lib/widgets/shared/app_motion_control.dart b/app/lib/widgets/shared/app_motion_control.dart index 623171bc..60e25880 100644 --- a/app/lib/widgets/shared/app_motion_control.dart +++ b/app/lib/widgets/shared/app_motion_control.dart @@ -30,6 +30,10 @@ class _AppMotionControlState extends State { if (_previousKey != null && key != _previousKey && focusNode.hasFocus) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && focusNode.canRequestFocus) { + // A replacement control can inherit an already-focused node without + // receiving the focus event that paints its native highlight. + focusNode.unfocus(disposition: UnfocusDisposition.previouslyFocusedChild); + FocusManager.instance.applyFocusChangesIfNeeded(); focusNode.requestFocus(); } }); diff --git a/app/lib/widgets/shell/adaptive_app_shell.dart b/app/lib/widgets/shell/adaptive_app_shell.dart index 943d82d8..6a6ec26e 100644 --- a/app/lib/widgets/shell/adaptive_app_shell.dart +++ b/app/lib/widgets/shell/adaptive_app_shell.dart @@ -107,7 +107,12 @@ class AdaptiveAppShell extends StatelessWidget { icon: Icons.bar_chart_outlined, selectedIcon: Icons.bar_chart, ), - const AppShellNavItem(path: '/profile', label: 'Profile', icon: Icons.person_outline, selectedIcon: Icons.person), + const AppShellNavItem( + path: '/profile', + label: 'Settings', + icon: Icons.settings_outlined, + selectedIcon: Icons.settings, + ), ]; } @@ -136,7 +141,7 @@ class AdaptiveAppShell extends StatelessWidget { currentPath: GoRouterState.of(context).uri.toString(), onNavigate: (path) => context.go(path), ), - Expanded(child: child), + Expanded(child: Semantics(container: true, child: child)), ], ), ); @@ -147,11 +152,14 @@ class AdaptiveAppShell extends StatelessWidget { final isInLibrary = currentPath.startsWith('/library'); return Scaffold( - body: isInLibrary - ? Builder( - builder: (context) => AppDrawerScope(scaffold: Scaffold.of(context), child: child), - ) - : child, + body: Semantics( + container: true, + child: isInLibrary + ? Builder( + builder: (context) => AppDrawerScope(scaffold: Scaffold.of(context), child: child), + ) + : child, + ), bottomNavigationBar: MobileBottomNav( items: navItems, currentPath: currentPath, diff --git a/app/lib/widgets/shell/desktop_sidebar.dart b/app/lib/widgets/shell/desktop_sidebar.dart index e426f441..03684648 100644 --- a/app/lib/widgets/shell/desktop_sidebar.dart +++ b/app/lib/widgets/shell/desktop_sidebar.dart @@ -36,7 +36,7 @@ class DesktopSidebar extends StatelessWidget { width: sidebarWidth, child: DecoratedBox( decoration: BoxDecoration( - color: colorScheme.surfaceContainerLow, + color: colorScheme.surface, border: Border(right: BorderSide(color: colorScheme.outlineVariant, width: 1)), ), child: Column( @@ -78,11 +78,15 @@ class DesktopSidebar extends StatelessWidget { child: Row( mainAxisAlignment: isCollapsed ? MainAxisAlignment.center : MainAxisAlignment.start, children: [ - // Logo icon from SVG - SvgPicture.asset( - isDark ? 'assets/images/logo-icon-dark.svg' : 'assets/images/logo-icon-light.svg', - width: 40, - height: 40, + SizedBox.square( + dimension: 40, + child: Center( + child: SvgPicture.asset( + isDark ? 'assets/images/logo-icon-dark.svg' : 'assets/images/logo-icon-light.svg', + width: 40, + height: 40, + ), + ), ), if (!isCollapsed) ...[ const SizedBox(width: Spacing.sm), @@ -114,54 +118,59 @@ class DesktopSidebar extends StatelessWidget { return _buildExpandableNavItem(context, item, isCollapsed); } - return Padding( - padding: const EdgeInsets.symmetric(horizontal: Spacing.sm, vertical: 2), - child: Material( - color: Colors.transparent, - borderRadius: BorderRadius.circular(AppRadius.md), - child: InkWell( + return Semantics( + label: isCollapsed ? item.label : null, + selected: isSelected, + button: true, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: Spacing.sm, vertical: 2), + child: Material( + color: Colors.transparent, borderRadius: BorderRadius.circular(AppRadius.md), - onTap: () { - if (hasChildren && isCollapsed) { - // When collapsed with children, expand sidebar and navigate - sidebarProvider.setCollapsed(false); - sidebarProvider.setLibraryExpanded(true); - } + child: InkWell( + borderRadius: BorderRadius.circular(AppRadius.md), + onTap: () { + if (hasChildren && isCollapsed) { + // When collapsed with children, expand sidebar and navigate + sidebarProvider.setCollapsed(false); + sidebarProvider.setLibraryExpanded(true); + } - onNavigate(item.path); - }, - child: Container( - height: 48, - padding: EdgeInsets.symmetric(horizontal: isCollapsed ? Spacing.md : Spacing.md), - decoration: BoxDecoration( - color: isSelected ? Theme.of(context).colorScheme.primaryContainer : Colors.transparent, - borderRadius: BorderRadius.circular(AppRadius.md), - ), - child: Row( - mainAxisAlignment: isCollapsed ? MainAxisAlignment.center : MainAxisAlignment.start, - children: [ - Icon( - isSelected ? item.selectedIcon ?? item.icon : item.icon, - color: isSelected - ? Theme.of(context).colorScheme.onPrimaryContainer - : Theme.of(context).colorScheme.onSurfaceVariant, - size: IconSizes.navigation, - ), - if (!isCollapsed) ...[ - const SizedBox(width: Spacing.md), - Expanded( - child: Text( - item.label, - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: isSelected - ? Theme.of(context).colorScheme.onPrimaryContainer - : Theme.of(context).colorScheme.onSurfaceVariant, - fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, + onNavigate(item.path); + }, + child: Container( + constraints: const BoxConstraints(minHeight: TouchTargets.mobileRecommended), + padding: const EdgeInsets.symmetric(horizontal: Spacing.md, vertical: Spacing.sm), + decoration: BoxDecoration( + color: isSelected ? Theme.of(context).colorScheme.primaryContainer : Colors.transparent, + borderRadius: BorderRadius.circular(AppRadius.md), + ), + child: Row( + mainAxisAlignment: isCollapsed ? MainAxisAlignment.center : MainAxisAlignment.start, + children: [ + Icon( + isSelected ? item.selectedIcon ?? item.icon : item.icon, + color: isSelected + ? Theme.of(context).colorScheme.onPrimaryContainer + : Theme.of(context).colorScheme.onSurfaceVariant, + size: IconSizes.navigation, + ), + if (!isCollapsed) ...[ + const SizedBox(width: Spacing.md), + Expanded( + child: Text( + item.label, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: isSelected + ? Theme.of(context).colorScheme.onPrimaryContainer + : Theme.of(context).colorScheme.onSurfaceVariant, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, + ), ), ), - ), + ], ], - ], + ), ), ), ), @@ -192,8 +201,8 @@ class DesktopSidebar extends StatelessWidget { } }, child: Container( - height: 48, - padding: const EdgeInsets.symmetric(horizontal: Spacing.md), + constraints: const BoxConstraints(minHeight: TouchTargets.mobileRecommended), + padding: const EdgeInsets.symmetric(horizontal: Spacing.md, vertical: Spacing.sm), decoration: BoxDecoration( color: hasSelectedBackground ? Theme.of(context).colorScheme.primaryContainer : Colors.transparent, borderRadius: BorderRadius.circular(AppRadius.md), @@ -248,55 +257,57 @@ class DesktopSidebar extends StatelessWidget { Widget _buildChildNavItem(BuildContext context, AppShellNavItem item) { final isSelected = currentPath.startsWith(item.path); - return Padding( - padding: const EdgeInsets.only(left: Spacing.xl + Spacing.sm, right: Spacing.sm, top: 2, bottom: 2), - child: Material( - color: Colors.transparent, - borderRadius: BorderRadius.circular(AppRadius.md), - child: InkWell( + return Semantics( + selected: isSelected, + button: true, + child: Padding( + padding: const EdgeInsets.only(left: Spacing.xl + Spacing.sm, right: Spacing.sm, top: 2, bottom: 2), + child: Material( + color: Colors.transparent, borderRadius: BorderRadius.circular(AppRadius.md), - onTap: () => onNavigate(item.path), - child: Container( - height: 40, - padding: const EdgeInsets.symmetric(horizontal: Spacing.md), - decoration: BoxDecoration( - color: isSelected ? 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 - ? Theme.of(context).colorScheme.onPrimaryContainer - : Theme.of(context).colorScheme.onSurfaceVariant, - size: IconSizes.small, - ), - const SizedBox(width: Spacing.sm), - Expanded( - child: Row( - children: [ - 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, + child: InkWell( + borderRadius: BorderRadius.circular(AppRadius.md), + onTap: () => onNavigate(item.path), + child: Container( + constraints: const BoxConstraints(minHeight: TouchTargets.mobileRecommended), + padding: const EdgeInsets.symmetric(horizontal: Spacing.md, vertical: Spacing.sm), + decoration: BoxDecoration( + color: isSelected ? 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 + ? Theme.of(context).colorScheme.onPrimaryContainer + : Theme.of(context).colorScheme.onSurfaceVariant, + size: IconSizes.small, + ), + const SizedBox(width: Spacing.sm), + Expanded( + child: Row( + children: [ + Expanded( + child: 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, + ), ), ), - ), - if ((item.count ?? 0) > 0) ...[ - const SizedBox(width: Spacing.xs), - NavItemCount(count: item.count!, selected: isSelected), + if ((item.count ?? 0) > 0) ...[ + const SizedBox(width: Spacing.xs), + NavItemCount(count: item.count!, selected: isSelected), + ], ], - ], + ), ), - ), - ], + ], + ), ), ), ), @@ -307,30 +318,34 @@ class DesktopSidebar extends StatelessWidget { Widget _buildCollapseToggle(BuildContext context, bool isCollapsed) { final sidebarProvider = context.read(); - return Material( - color: Colors.transparent, - child: InkWell( - onTap: () => sidebarProvider.toggleCollapsed(), - child: Container( - height: 56, - padding: const EdgeInsets.symmetric(horizontal: Spacing.md), - child: Row( - mainAxisAlignment: isCollapsed ? MainAxisAlignment.center : MainAxisAlignment.start, - children: [ - Icon( - isCollapsed ? Icons.chevron_right : Icons.chevron_left, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - if (!isCollapsed) ...[ - const SizedBox(width: Spacing.md), - Text( - '', - style: Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), + return Semantics( + label: isCollapsed ? 'Expand sidebar' : 'Collapse sidebar', + button: true, + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () => sidebarProvider.toggleCollapsed(), + child: Container( + height: 56, + padding: const EdgeInsets.symmetric(horizontal: Spacing.md), + child: Row( + mainAxisAlignment: isCollapsed ? MainAxisAlignment.center : MainAxisAlignment.start, + children: [ + Icon( + isCollapsed ? Icons.chevron_right : Icons.chevron_left, + color: Theme.of(context).colorScheme.onSurfaceVariant, ), + if (!isCollapsed) ...[ + const SizedBox(width: Spacing.md), + Text( + '', + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), + ), + ], ], - ], + ), ), ), ), diff --git a/app/lib/widgets/shell/mobile_bottom_nav.dart b/app/lib/widgets/shell/mobile_bottom_nav.dart index 01ba1d38..daa1cbc5 100644 --- a/app/lib/widgets/shell/mobile_bottom_nav.dart +++ b/app/lib/widgets/shell/mobile_bottom_nav.dart @@ -17,19 +17,54 @@ class MobileBottomNav extends StatelessWidget { final mainItems = items.where((item) => !isChildPath(item.path)).toList(); final currentIndex = _getCurrentIndex(mainItems); + void navigate(int index) { + final item = mainItems[index]; + onNavigate(item.path == '/library' ? '/library/books' : item.path); + } + + // Material's NavigationBar caps label scaling. Keep enlarged labels fully + // readable and keyboard reachable in a horizontally scrollable alternative. + if (MediaQuery.textScalerOf(context).scale(14) > 14 * 1.3) { + final colors = Theme.of(context).colorScheme; + + return Material( + color: colors.surface, + child: SafeArea( + top: false, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: List.generate(mainItems.length, (index) { + final item = mainItems[index]; + final selected = currentIndex == index; + + return Semantics( + selected: selected, + child: Padding( + padding: const EdgeInsets.all(4), + child: TextButton.icon( + style: TextButton.styleFrom( + minimumSize: const Size(48, 48), + backgroundColor: selected ? colors.primaryContainer : null, + foregroundColor: selected ? colors.onPrimaryContainer : colors.onSurface, + ), + onPressed: () => navigate(index), + icon: Icon(selected ? item.selectedIcon ?? item.icon : item.icon), + label: Text(item.label), + ), + ), + ); + }), + ), + ), + ), + ); + } + return NavigationBar( animationDuration: AppMotion.duration(context, const Duration(milliseconds: 500)), selectedIndex: currentIndex, - onDestinationSelected: (index) { - final item = mainItems[index]; - // For library, navigate to books subpage - - if (item.path == '/library') { - onNavigate('/library/books'); - } else { - onNavigate(item.path); - } - }, + onDestinationSelected: navigate, destinations: mainItems.map((item) { return NavigationDestination( icon: Icon(item.icon), diff --git a/app/lib/widgets/titled_divider.dart b/app/lib/widgets/titled_divider.dart index e4ef191a..df43a212 100644 --- a/app/lib/widgets/titled_divider.dart +++ b/app/lib/widgets/titled_divider.dart @@ -16,16 +16,31 @@ class TitledDivider extends StatelessWidget { final theme = Theme.of(context); final padding = verticalPadding ?? Spacing.lg; + Widget line(EdgeInsets padding) => ConstrainedBox( + constraints: const BoxConstraints(minWidth: Spacing.xl), + child: Padding( + padding: padding, + child: Divider(thickness: 1.5, color: theme.colorScheme.outlineVariant), + ), + ); + return Padding( padding: EdgeInsets.symmetric(vertical: padding), - child: Row( + child: Table( + defaultVerticalAlignment: TableCellVerticalAlignment.middle, + columnWidths: const {0: FlexColumnWidth(), 1: IntrinsicColumnWidth(), 2: FlexColumnWidth()}, children: [ - Expanded(child: Divider(thickness: 1.5, color: theme.colorScheme.outlineVariant)), - Padding( - padding: const EdgeInsets.symmetric(horizontal: Spacing.md), - child: Text(title, style: theme.textTheme.titleSmall?.copyWith(color: theme.colorScheme.outline)), + TableRow( + children: [ + line(const EdgeInsets.only(right: Spacing.md)), + Text( + title, + textAlign: TextAlign.center, + style: theme.textTheme.titleSmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + line(const EdgeInsets.only(left: Spacing.md)), + ], ), - Expanded(child: Divider(thickness: 1.5, color: theme.colorScheme.outlineVariant)), ], ), ); diff --git a/app/pubspec.yaml b/app/pubspec.yaml index e0ca4d2b..cea48162 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -76,8 +76,19 @@ flutter: - assets/images/book_placeholder.jpg - assets/images/auth-illustration.png - assets/images/welcome-illustration.png + - fonts/OpenDyslexic/OFL.txt fonts: - family: MadimiOne fonts: - asset: fonts/MadimiOne-Regular.ttf + - family: OpenDyslexic + fonts: + - asset: fonts/OpenDyslexic/OpenDyslexic-Regular.otf + - asset: fonts/OpenDyslexic/OpenDyslexic-Bold.otf + weight: 700 + - asset: fonts/OpenDyslexic/OpenDyslexic-Italic.otf + style: italic + - asset: fonts/OpenDyslexic/OpenDyslexic-BoldItalic.otf + weight: 700 + style: italic diff --git a/app/test/auth/auth_api_client_test.dart b/app/test/auth/auth_api_client_test.dart index d7999b3d..387e69df 100644 --- a/app/test/auth/auth_api_client_test.dart +++ b/app/test/auth/auth_api_client_test.dart @@ -26,6 +26,44 @@ const _authResponse = { }; void main() { + test('profile photos upload with the name and download only from the configured server', () async { + const avatarUrl = '/users/me/avatar/11111111-1111-1111-1111-111111111111'; + var calls = 0; + final client = AuthApiClient( + config: PapyrusApiConfig(serverBaseUri: Uri.parse('https://server.test'), apiPrefix: '/api/v2'), + httpClient: MockClient((request) async { + calls++; + expect(request.headers['Authorization'], 'Bearer access'); + expect(request.url.host, 'server.test'); + + if (request.method == 'POST') { + expect(request.url.path, '/api/v2/users/me/avatar'); + expect(request.headers['content-type'], startsWith('multipart/form-data')); + expect(request.body, contains('name="display_name"')); + expect(request.body, contains('Updated Reader')); + expect(request.body, contains('filename="avatar.jpg"')); + return http.Response(jsonEncode({..._authResponse['user']! as Map, 'avatar_url': avatarUrl}), 200); + } + + expect(request.url.path, '/api/v2$avatarUrl'); + return http.Response.bytes([1, 2, 3], 200); + }), + ); + + final user = await client.uploadProfilePhoto( + 'access', + Uint8List.fromList([1, 2, 3]), + displayName: 'Updated Reader', + ); + expect(user.avatarUrl, avatarUrl); + expect(await client.downloadProfilePhoto('access', avatarUrl), [1, 2, 3]); + await expectLater( + client.downloadProfilePhoto('access', 'https://other.test/photo.jpg'), + throwsA(isA()), + ); + expect(calls, 2); + }); + test('login maps Papyrus token response', () async { final client = AuthApiClient( config: PapyrusApiConfig(serverBaseUri: Uri.parse('http://server.test')), diff --git a/app/test/auth/profile_photo_contract_test.dart b/app/test/auth/profile_photo_contract_test.dart new file mode 100644 index 00000000..f7ff2788 --- /dev/null +++ b/app/test/auth/profile_photo_contract_test.dart @@ -0,0 +1,115 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:papyrus/auth/auth_api_client.dart'; +import 'package:papyrus/auth/auth_models.dart'; +import 'package:papyrus/auth/auth_repository.dart'; +import 'package:papyrus/auth/papyrus_api_config.dart'; +import 'package:papyrus/auth/token_store.dart'; +import 'package:papyrus/providers/auth_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../providers/auth_provider_test.dart' show FakeAuthRepository, MemoryRefreshTokenStorage; + +const _avatarUrl = '/users/me/avatar/11111111-1111-1111-1111-111111111111'; +const _userJson = { + 'user_id': 'reader', + 'email': 'reader@example.com', + 'display_name': 'Reader', + 'avatar_url': _avatarUrl, + 'email_verified': true, + 'connected_providers': [], +}; + +class _DelayedRepository extends FakeAuthRepository { + final saved = Completer(); + + @override + Future uploadProfilePhoto(Uint8List bytes, {required String displayName}) => saved.future; +} + +void main() { + test('photo upload and download retry with refreshed credentials', () async { + for (final uploading in [true, false]) { + var attempts = 0; + var refreshes = 0; + final store = TokenStore(MemoryRefreshTokenStorage()); + await store.saveTokens(accessToken: 'expired-access', refreshToken: 'refresh'); + + final repository = AuthRepository( + tokenStore: store, + apiClient: AuthApiClient( + config: PapyrusApiConfig(serverBaseUri: Uri.parse('https://server.test')), + httpClient: MockClient((request) async { + if (request.url.path == '/v1/auth/refresh') { + refreshes++; + return http.Response( + jsonEncode({ + 'access_token': 'fresh-access', + 'refresh_token': 'fresh-refresh', + 'token_type': 'Bearer', + 'expires_in': 3600, + 'user': _userJson, + }), + 200, + ); + } + + attempts++; + + if (attempts == 1) { + return http.Response( + jsonEncode({ + 'error': {'message': 'Expired'}, + }), + 401, + ); + } + + expect(request.headers['Authorization'], 'Bearer fresh-access'); + return uploading ? http.Response(jsonEncode(_userJson), 200) : http.Response.bytes([1, 2, 3], 200); + }), + ), + ); + + if (uploading) { + expect((await repository.uploadProfilePhoto(Uint8List(3), displayName: 'Reader')).avatarUrl, _avatarUrl); + } else { + expect(await repository.downloadProfilePhoto(_avatarUrl), [1, 2, 3]); + } + + expect(attempts, 2); + expect(refreshes, 1); + } + }); + + test('an upload completing after a server switch does not replace the active profile', () async { + SharedPreferences.setMockInitialValues({}); + final tokens = AuthTokens( + accessToken: 'access', + refreshToken: 'refresh', + tokenType: 'Bearer', + expiresIn: 3600, + user: PapyrusUser.fromJson(_userJson), + ); + + final repository = _DelayedRepository()..bootstrapResult = tokens; + final provider = AuthProvider( + await SharedPreferences.getInstance(), + repository: repository, + bootstrapOnCreate: false, + ); + addTearDown(provider.dispose); + await provider.bootstrap(); + final saved = provider.updateProfile(displayName: 'New name', photoBytes: Uint8List(3)); + await provider.replaceRepository(FakeAuthRepository(), bootstrapNewRepository: false); + repository.saved.complete(tokens.user); + expect(await saved, isFalse); + expect(provider.user, isNull); + expect(provider.isSignedIn, isFalse); + }); +} diff --git a/app/test/opds/layout_test.dart b/app/test/opds/layout_test.dart index d51c4dbf..de600cf6 100644 --- a/app/test/opds/layout_test.dart +++ b/app/test/opds/layout_test.dart @@ -20,6 +20,7 @@ import 'package:papyrus/opds/opds_http_client.dart'; import 'package:papyrus/opds/opds_library.dart'; import 'package:papyrus/opds/opds_resource_cache.dart'; import 'package:papyrus/widgets/opds/opds_publication_details.dart'; +import 'package:papyrus/widgets/shell/mobile_bottom_nav.dart'; import 'package:papyrus/widgets/book_details/book_details_tab_rail.dart'; import 'package:papyrus/opds/opds_models.dart'; import 'package:papyrus/pages/catalog_book_page.dart'; @@ -406,7 +407,7 @@ void main() { expect( tester.getBottomRight(find.byType(FloatingActionButton)).dy, - lessThan(tester.getTopLeft(find.byType(NavigationBar)).dy), + lessThan(tester.getTopLeft(find.byType(MobileBottomNav)).dy), ); expect( diff --git a/app/test/pages/account_deletion_dialog_test.dart b/app/test/pages/account_deletion_dialog_test.dart index 4514f186..e3eb2906 100644 --- a/app/test/pages/account_deletion_dialog_test.dart +++ b/app/test/pages/account_deletion_dialog_test.dart @@ -14,6 +14,7 @@ import 'package:papyrus/pages/login_page.dart'; import 'package:papyrus/pages/auth/oauth_callback_page.dart'; import 'package:papyrus/themes/app_theme.dart'; import 'package:papyrus/providers/auth_provider.dart'; +import 'package:papyrus/services/guest_account_deletion_service.dart'; import 'package:papyrus/widgets/profile/account_settings_section.dart'; import 'package:papyrus/widgets/settings/settings_row.dart'; import 'package:provider/provider.dart'; @@ -32,6 +33,24 @@ class _Storage implements RefreshTokenStorage { Future delete() async => value = null; } +class _GuestDeletion extends Fake implements GuestAccountDeletionService { + _GuestDeletion(this.auth); + + @override + final AuthProvider auth; + int calls = 0; + bool fail = false; + + @override + Future delete() => auth.deleteGuestAccount(() async { + calls++; + + if (fail) { + throw StateError('Storage unavailable'); + } + }); +} + class _Repository extends AuthRepository { _Repository({required super.apiClient, required super.tokenStore, required this.connected}); @@ -155,11 +174,17 @@ void main() { addTearDown(auth.dispose); } - Future showPage(WidgetTester tester, {bool desktop = false, bool login = false, String? callback}) async { + Future showPage( + WidgetTester tester, { + bool desktop = false, + bool login = false, + String? callback, + GuestAccountDeletionService? guestDeletion, + }) async { final router = GoRouter( initialLocation: callback ?? (login ? '/login' : '/profile'), refreshListenable: auth, - redirect: (_, state) => state.uri.path == '/profile' && !auth.isSignedIn ? '/login' : null, + redirect: (_, state) => state.uri.path == '/profile' && !auth.isSignedIn && !auth.isOfflineMode ? '/login' : null, routes: [ GoRoute( path: '/auth/callback', @@ -183,8 +208,11 @@ void main() { addTearDown(router.dispose); await tester.pumpWidget( - ChangeNotifierProvider.value( - value: auth, + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: auth), + if (guestDeletion != null) Provider.value(value: guestDeletion), + ], child: MaterialApp.router(theme: AppTheme.light, routerConfig: router), ), ); @@ -211,6 +239,30 @@ void main() { }); for (final desktop in [false, true]) { + testWidgets('guest deletion can cancel, retry, then return to login, desktop=$desktop', (tester) async { + await setup(signedIn: false); + auth.setOfflineMode(true); + final deletion = _GuestDeletion(auth); + await showPage(tester, desktop: desktop, guestDeletion: deletion); + await openDialog(tester); + expect(find.textContaining('This cannot be undone.'), findsOneWidget); + expect(find.textContaining('30 days'), findsNothing); + await tester.tap(find.widgetWithText(TextButton, 'Cancel')); + await tester.pumpAndSettle(); + expect(deletion.calls, 0); + expect(auth.isOfflineMode, isTrue); + await openDialog(tester); + deletion.fail = true; + await confirm(tester); + expect(find.text('Could not delete all local data. Please try again.'), findsOneWidget); + expect(auth.isOfflineMode, isTrue); + deletion.fail = false; + await confirm(tester); + expect(auth.isOfflineMode, isFalse); + expect(find.byType(LoginPage), findsOneWidget); + expect(requests, isEmpty); + }); + testWidgets('Google deletion completes in the existing session, desktop=$desktop', (tester) async { await setup(connected: true); final size = Size(desktop ? 1200 : 360, 800); @@ -232,7 +284,7 @@ void main() { expect(find.text('Delete account?'), findsOneWidget); expect(find.text('Confirm it’s you'), findsNothing); expect(find.text('Continue with Google'), findsNothing); - expect(find.byType(TextFormField), findsNothing); + expect(find.descendant(of: find.byType(AlertDialog), matching: find.byType(TextFormField)), findsNothing); expect(requests, isEmpty); await tester.tap(find.text('Cancel')); await tester.pumpAndSettle(); diff --git a/app/test/pages/account_profile_form_test.dart b/app/test/pages/account_profile_form_test.dart new file mode 100644 index 00000000..e863bad6 --- /dev/null +++ b/app/test/pages/account_profile_form_test.dart @@ -0,0 +1,198 @@ +import 'dart:typed_data'; + +import 'package:file_picker/file_picker.dart'; +import 'package:image/image.dart' as image; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/auth/auth_models.dart'; +import 'package:papyrus/widgets/profile/account_settings_section.dart'; +import 'package:papyrus/providers/auth_provider.dart'; +import 'package:papyrus/themes/app_theme.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../providers/auth_provider_test.dart' show FakeAuthRepository; +import '../helpers/support_file_picker.dart'; + +class _Repository extends FakeAuthRepository { + String? savedName; + bool fail = false; + Uint8List? savedPhoto; + int photoDownloads = 0; + + @override + Future updateCurrentUser({String? displayName, String? avatarUrl}) async { + if (fail) { + throw StateError('Offline'); + } + + savedName = displayName; + return _user(displayName!); + } + + @override + Future uploadProfilePhoto(Uint8List bytes, {required String displayName}) async { + if (fail) { + throw StateError('Offline'); + } + + savedName = displayName; + savedPhoto = bytes; + return _user(displayName, avatarUrl: '/users/me/avatar/11111111-1111-1111-1111-111111111111'); + } + + @override + Future downloadProfilePhoto(String avatarUrl) async { + photoDownloads++; + return savedPhoto!; + } +} + +PapyrusUser _user(String name, {String? avatarUrl}) => PapyrusUser( + userId: 'reader', + email: 'reader@example.com', + displayName: name, + avatarUrl: avatarUrl, + emailVerified: true, + createdAt: null, + lastLoginAt: null, +); + +void main() { + for (final guest in [false, true]) { + for (final width in [320.0, 390.0, 1280.0]) { + for (final scale in [1.0, 2.0]) { + testWidgets('Account settings saves inline at $width, scale $scale, guest $guest', (tester) async { + tester.view.physicalSize = Size(width, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + SharedPreferences.setMockInitialValues({'offline_mode': guest}); + + final repository = _Repository() + ..bootstrapResult = AuthTokens( + accessToken: 'access', + refreshToken: 'refresh', + tokenType: 'Bearer', + expiresIn: 3600, + user: _user('Reader'), + ); + + final auth = AuthProvider( + await SharedPreferences.getInstance(), + repository: repository, + bootstrapOnCreate: false, + ); + + await auth.bootstrap(); + addTearDown(auth.dispose); + + final app = ChangeNotifierProvider.value( + value: auth, + child: MaterialApp( + theme: AppTheme.dark.copyWith(visualDensity: VisualDensity.compact), + home: Scaffold( + body: SingleChildScrollView(child: AccountSettingsSection(isDesktop: width >= 840)), + ), + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith(textScaler: TextScaler.linear(scale)), + child: child!, + ), + ), + ); + await tester.pumpWidget(app); + + await tester.pumpAndSettle(); + expect(tester.widget(find.widgetWithText(FilledButton, 'Save')).onPressed, isNull); + expect(find.text('Change photo'), findsNothing); + expect(find.text('Email'), guest ? findsNothing : findsOneWidget); + expect(find.text('No email provided'), findsNothing); + final avatarCrop = find.descendant(of: find.byTooltip('Change photo'), matching: find.byType(ClipOval)); + final cropSize = tester.getSize(avatarCrop); + expect(cropSize.width, cropSize.height); + expect(tester.getCenter(avatarCrop), tester.getCenter(find.byTooltip('Change photo'))); + final name = find.widgetWithText(TextFormField, 'Display name'); + await tester.ensureVisible(name); + await tester.enterText(name, 'Updated Reader'); + await tester.pump(); + await tester.ensureVisible(find.widgetWithText(FilledButton, 'Save')); + expect(find.widgetWithText(FilledButton, 'Save').hitTestable(), findsOneWidget); + final picker = SupportFilePicker(); + FilePicker.platform = picker; + await tester.ensureVisible(find.byTooltip('Change photo')); + await tester.tap(find.byTooltip('Change photo')); + await tester.pumpAndSettle(); + expect(repository.savedPhoto, isNull); + final photo = image.encodePng(image.Image(width: 8, height: 8)); + picker.result = FilePickerResult([PlatformFile(name: 'photo.png', size: photo.length, bytes: photo)]); + + await tester.runAsync(() async { + await tester.tap(find.byTooltip('Change photo')); + await tester.pump(); + while (find.byTooltip('Opening photo…').evaluate().isNotEmpty) { + await Future.delayed(const Duration(milliseconds: 10)); + await tester.pump(); + } + }); + + await tester.pumpAndSettle(); + expect(repository.savedPhoto, isNull); + await tester.ensureVisible(find.widgetWithText(FilledButton, 'Save')); + if (!guest && width == 390 && scale == 1) { + repository.fail = true; + await tester.tap(find.widgetWithText(FilledButton, 'Save')); + await tester.pumpAndSettle(); + expect(repository.savedName, isNull); + expect(find.text('Updated Reader'), findsOneWidget); + expect(find.textContaining('Please try again.'), findsOneWidget); + repository.fail = false; + } + + final avatarImage = find.descendant(of: find.byTooltip('Change photo'), matching: find.byType(Image)); + final preview = tester.widget(avatarImage).image; + await tester.tap(find.widgetWithText(FilledButton, 'Save')); + await tester.pump(); + expect(tester.widget(avatarImage).image, preview); + await tester.pumpAndSettle(); + expect(tester.widget(avatarImage).image, preview); + if (guest) { + expect(repository.savedName, isNull); + expect(repository.savedPhoto, isNull); + expect(repository.photoDownloads, 0); + expect(auth.profileDisplayName, 'Updated Reader'); + expect(image.decodeJpg(auth.profilePhotoBytes!), isNotNull); + final prefs = await SharedPreferences.getInstance(); + await prefs.reload(); + final restarted = AuthProvider(prefs, repository: _Repository(), bootstrapOnCreate: false); + addTearDown(restarted.dispose); + expect(restarted.profileDisplayName, 'Updated Reader'); + expect(restarted.profilePhotoBytes, auth.profilePhotoBytes); + } else { + expect(repository.savedName, 'Updated Reader'); + expect(repository.savedPhoto, isNotNull); + expect(repository.photoDownloads, 0); + expect(image.decodeJpg(repository.savedPhoto!), isNotNull); + } + expect(find.byType(AccountSettingsSection), findsOneWidget); + expect(find.text('Profile saved.'), findsNothing); + expect(find.text('Edit profile'), findsNothing); + expect(tester.widget(find.widgetWithText(FilledButton, 'Save')).onPressed, isNull); + await tester.pumpWidget(const SizedBox()); + await tester.pumpWidget(app); + await tester.pumpAndSettle(); + expect(avatarImage, findsOneWidget); + final renderedAvatar = find.descendant(of: avatarImage, matching: find.byType(RawImage)); + + for (var attempt = 0; attempt < 100 && tester.widget(renderedAvatar).image == null; attempt++) { + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 10))); + await tester.pump(); + } + + expect(tester.widget(renderedAvatar).image, isNotNull); + expect(repository.photoDownloads, guest ? 0 : 1); + expect(tester.takeException(), isNull); + }); + } + } + } +} diff --git a/app/test/pages/book_edit_details_alignment_test.dart b/app/test/pages/book_edit_details_alignment_test.dart index ab49acfb..8807e023 100644 --- a/app/test/pages/book_edit_details_alignment_test.dart +++ b/app/test/pages/book_edit_details_alignment_test.dart @@ -1,5 +1,3 @@ -import 'dart:math' as math; - import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:papyrus/pages/book_details_page.dart'; @@ -14,7 +12,7 @@ import 'package:papyrus/widgets/book_edit/cover_image_picker.dart'; import '../helpers/test_helpers.dart'; void main() { - for (final theme in [AppTheme.dark, AppTheme.eink]) { + for (final theme in [AppTheme.dark, AppTheme.highContrastDark, AppTheme.highContrastLight, AppTheme.eink]) { for (final layout in [ (screen: const Size(3840, 2160), width: 3560.0, scale: 1.0), (screen: const Size(1800, 1200), width: 1520.0, scale: 1.0), @@ -95,13 +93,6 @@ void main() { expect(tester.getRect(search).left, detailsTitle.left); } - final formCard = find.ancestor(of: find.text('Basic information'), matching: find.byType(Card)).first; - - expect( - tester.getRect(formCard).right, - layout.screen.width - layout.width + math.min(layout.width, 1120) - Spacing.lg, - ); - final searchField = find.ancestor(of: find.text('Search'), matching: find.byType(TextFormField)).first; final saveButton = find.widgetWithText(FilledButton, 'Save'); expect(tester.getRect(saveButton).right, tester.getRect(searchField).right); @@ -112,7 +103,7 @@ void main() { } } - testWidgets('mobile edit retains its padded card and full-width bordered cover', (tester) async { + testWidgets('mobile edit retains its padding and full-width bordered cover', (tester) async { const screen = Size(400, 1100); tester.view.devicePixelRatio = 1; tester.view.physicalSize = screen; @@ -146,7 +137,6 @@ void main() { expect(preview.width, screen.width - Spacing.md * 4); expect(preview.height, preview.width / CoverImagePicker.coverAspectRatio); expect(image, preview.deflate(1)); - expect(find.ancestor(of: picker, matching: find.byType(Card)), findsOneWidget); expect(tester.takeException(), isNull); }); } diff --git a/app/test/pages/book_edit_page_layout_test.dart b/app/test/pages/book_edit_page_layout_test.dart index 76daa56f..66670a63 100644 --- a/app/test/pages/book_edit_page_layout_test.dart +++ b/app/test/pages/book_edit_page_layout_test.dart @@ -66,10 +66,7 @@ void main() { final formHeading = tester.getTopLeft(find.text('Basic information')); expect(metadataHeading.dx, greaterThan(cover.dx)); - expect( - tester.getTopLeft(find.ancestor(of: find.text('Fetch metadata'), matching: find.byType(Card)).first).dy, - cover.dy, - ); + expect(metadataHeading.dy, cover.dy); expect(formHeading.dx, closeTo(metadataHeading.dx, 1)); expect(formHeading.dy, greaterThan(metadataHeading.dy)); @@ -83,10 +80,7 @@ void main() { final language = tester.getTopLeft(find.text('Language')); expect(metadataHeading.dx, greaterThan(cover.dx)); - expect( - tester.getTopLeft(find.ancestor(of: find.text('Fetch metadata'), matching: find.byType(Card)).first).dy, - cover.dy, - ); + expect(metadataHeading.dy, cover.dy); expect(language.dy, greaterThan(publisher.dy)); expect(tester.takeException(), isNull); @@ -98,25 +92,20 @@ void main() { final metadataHeading = tester.getTopLeft(find.text('Fetch metadata')); final formHeading = tester.getTopLeft(find.text('Basic information')); final coverPreview = find.descendant(of: find.byType(CoverImagePicker), matching: find.byType(AspectRatio)); - final metadataCard = find.ancestor(of: find.text('Fetch metadata'), matching: find.byType(Card)).first; - final formCard = find.ancestor(of: find.text('Basic information'), matching: find.byType(Card)).first; expect(metadataHeading.dy, greaterThan(cover.dy)); expect(formHeading.dy, greaterThan(metadataHeading.dy)); expect(tester.getTopLeft(coverPreview).dx, Spacing.lg); - expect(tester.getCenter(metadataCard).dx, closeTo(320, 1)); - expect(tester.getSize(metadataCard).width, closeTo(tester.getSize(formCard).width, 1)); + expect(metadataHeading.dx, formHeading.dx); expect(tester.getSize(find.byType(AspectRatio).first).width, 240); expect(tester.takeException(), isNull); }); testWidgets('metadata search precedes a compact visible source selector', (tester) async { await pumpPage(tester, size: const Size(1000, 1200), contentWidth: 640); - final metadataCard = find.ancestor(of: find.text('Fetch metadata'), matching: find.byType(Card)).first; final searchField = find.ancestor(of: find.text('Search'), matching: find.byType(TextFormField)).first; final selector = find.byWidgetPredicate((widget) => widget is SegmentedButton); final sourceLabel = find.text('Source'); expect(sourceLabel, findsOneWidget); - expect(tester.getSize(searchField).width, closeTo(tester.getSize(metadataCard).width - (Spacing.md * 2), 1)); expect(tester.getTopLeft(sourceLabel).dy, greaterThan(tester.getTopLeft(searchField).dy)); expect((tester.getCenter(selector).dy - tester.getCenter(sourceLabel).dy).abs(), lessThan(1)); }); diff --git a/app/test/pages/profile_page_test.dart b/app/test/pages/profile_page_test.dart index 71a75e22..cb0390f1 100644 --- a/app/test/pages/profile_page_test.dart +++ b/app/test/pages/profile_page_test.dart @@ -1,4 +1,8 @@ +import 'dart:ui' show SemanticsAction, Tristate; + +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:papyrus/auth/auth_api_client.dart'; import 'package:papyrus/auth/auth_models.dart'; @@ -16,6 +20,7 @@ import 'package:papyrus/providers/preferences_provider.dart'; import 'package:papyrus/providers/sync_settings_provider.dart'; import 'package:papyrus/widgets/profile/profile_menu_item.dart'; import 'package:papyrus/widgets/settings/settings_section.dart'; +import 'package:papyrus/widgets/settings/settings_row.dart'; import 'package:powersync/powersync.dart' hide Column; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -111,7 +116,7 @@ void main() { availabilityProvider = AcquisitionAvailabilityProvider(authProvider: authProvider); }); - Widget buildApp({required Size screenSize}) { + Widget buildApp({required Size screenSize, TextScaler textScaler = TextScaler.noScaling}) { return MultiProvider( providers: [ ChangeNotifierProvider.value(value: authProvider), @@ -125,7 +130,7 @@ void main() { ], child: MaterialApp( home: MediaQuery( - data: MediaQueryData(size: screenSize), + data: MediaQueryData(size: screenSize, textScaler: textScaler), child: const ProfilePage(), ), ), @@ -137,28 +142,75 @@ void main() { addTearDown(() => tester.binding.setSurfaceSize(null)); await tester.pumpWidget(buildApp(screenSize: const Size(400, 900))); await tester.pumpAndSettle(); - expect(find.text('Profile'), findsOneWidget); + expect(find.text('Settings'), findsOneWidget); expect(find.text('Reader Profile'), findsOneWidget); expect(find.text('reader@example.com'), findsOneWidget); - await tester.scrollUntilVisible(find.widgetWithText(SettingsSectionHeader, 'Account'), 300); + await tester.scrollUntilVisible( + find.widgetWithText(SettingsSectionHeader, 'Account'), + 300, + scrollable: find.byType(Scrollable).first, + ); expect(find.text('Change password'), findsOneWidget); - await tester.scrollUntilVisible(find.widgetWithText(SettingsSectionHeader, 'Appearance'), 300); + await tester.scrollUntilVisible( + find.widgetWithText(SettingsSectionHeader, 'Appearance'), + 300, + scrollable: find.byType(Scrollable).first, + ); expect(find.text('Theme'), findsOneWidget); - await tester.scrollUntilVisible(find.widgetWithText(SettingsSectionHeader, 'Reading'), 300); - expect(find.text('Default font'), findsOneWidget); - await tester.scrollUntilVisible(find.widgetWithText(SettingsSectionHeader, 'Library'), 300); - expect(find.text('Default view'), findsOneWidget); - await tester.scrollUntilVisible(find.widgetWithText(SettingsSectionHeader, 'Notifications'), 300); + expect(find.text('Default font'), findsNothing); + expect(find.text('Default view'), findsNothing); + expect(find.text('Developer options'), findsNothing); + await tester.scrollUntilVisible( + find.widgetWithText(SettingsSectionHeader, 'Notifications'), + 300, + scrollable: find.byType(Scrollable).first, + ); expect(find.text('Goal reminders'), findsOneWidget); - await tester.scrollUntilVisible(find.widgetWithText(SettingsSectionHeader, 'Storage'), 300); + final reminders = find.widgetWithText(SettingsToggleRow, 'Goal reminders'); + final remindersValue = prefsProvider.goalReminders; + expect(tester.widget(reminders).onChanged, isNull); + await tester.tap(reminders); + expect(prefsProvider.goalReminders, remindersValue); + await tester.scrollUntilVisible( + find.widgetWithText(SettingsSectionHeader, 'Storage'), + 300, + scrollable: find.byType(Scrollable).first, + ); expect(find.text('Clear local library'), findsOneWidget); - await tester.scrollUntilVisible(find.widgetWithText(SettingsSectionHeader, 'Privacy & data'), 300); + await tester.scrollUntilVisible( + find.widgetWithText(SettingsSectionHeader, 'Privacy & data'), + 300, + scrollable: find.byType(Scrollable).first, + ); expect(find.text('Analytics'), findsOneWidget); - await tester.scrollUntilVisible(find.widgetWithText(SettingsSectionHeader, 'Accessibility'), 300); + final analytics = find.widgetWithText(SettingsToggleRow, 'Analytics'); + final analyticsValue = prefsProvider.analyticsOptIn; + expect(tester.widget(analytics).onChanged, isNull); + await tester.tap(analytics); + expect(prefsProvider.analyticsOptIn, analyticsValue); + + for (final label in ['Privacy policy', 'Export all data', 'Import data', 'Clear local data']) { + final row = tester.widget(find.widgetWithText(SettingsRow, label)); + expect(row.enabled, isFalse); + expect(row.onTap, isNull); + } + await tester.scrollUntilVisible( + find.widgetWithText(SettingsSectionHeader, 'Accessibility'), + 300, + scrollable: find.byType(Scrollable).first, + ); expect(find.text('Reduce animations'), findsOneWidget); - await tester.scrollUntilVisible(find.widgetWithText(SettingsSectionHeader, 'About'), 300); + await tester.scrollUntilVisible( + find.widgetWithText(SettingsSectionHeader, 'About'), + 300, + scrollable: find.byType(Scrollable).first, + ); expect(find.text('Version'), findsOneWidget); - await tester.scrollUntilVisible(find.widgetWithText(ProfileMenuItem, 'Log out'), 300); + await tester.scrollUntilVisible( + find.widgetWithText(ProfileMenuItem, 'Log out'), + 300, + scrollable: find.byType(Scrollable).first, + ); await tester.tap(find.widgetWithText(ProfileMenuItem, 'Log out')); await tester.pumpAndSettle(); expect(find.text('Are you sure you want to log out?'), findsOneWidget); @@ -167,6 +219,89 @@ void main() { expect(find.text('Are you sure you want to log out?'), findsNothing); }); + testWidgets('desktop settings scrolls when the pointer is over empty space beside the form', (tester) async { + const screenSize = Size(1280, 600); + await tester.binding.setSurfaceSize(screenSize); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget(buildApp(screenSize: screenSize)); + await tester.pumpAndSettle(); + final name = find.widgetWithText(TextFormField, 'Display name'); + final initialTop = tester.getTopLeft(name).dy; + const pointerPosition = Offset(1240, 300); + expect(pointerPosition.dx, greaterThan(tester.getTopRight(name).dx)); + await tester.sendEventToBinding( + const PointerScrollEvent(position: pointerPosition, scrollDelta: Offset(0, 200)), + ); + await tester.pumpAndSettle(); + expect(tester.getTopLeft(name).dy, lessThan(initialTop)); + expect(tester.takeException(), isNull); + }); + + testWidgets('short desktop settings sections stay at the top of the content pane', (tester) async { + const screenSize = Size(1280, 900); + await tester.binding.setSurfaceSize(screenSize); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget(buildApp(screenSize: screenSize)); + await tester.pumpAndSettle(); + final initialHeadingTop = tester.getTopLeft(find.text('Account').last).dy; + await tester.tap(find.widgetWithText(InkWell, 'About')); + await tester.pumpAndSettle(); + expect(tester.getTopLeft(find.text('About').last).dy, initialHeadingTop); + expect(tester.takeException(), isNull); + }); + + for (final textScale in [1.0, 2.0]) { + testWidgets('desktop settings keeps keyboard traversal within each column at ${textScale}x text', (tester) async { + const screenSize = Size(1280, 900); + await tester.binding.setSurfaceSize(screenSize); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget(buildApp(screenSize: screenSize, textScaler: TextScaler.linear(textScale))); + await tester.pumpAndSettle(); + final accessibility = find.widgetWithText(InkWell, 'Accessibility'); + await tester.ensureVisible(accessibility); + await tester.tap(accessibility); + await tester.pumpAndSettle(); + + final labels = [ + 'Account', + 'Appearance', + 'Storage', + 'Accessibility', + 'About', + 'Log out', + 'Interface text enlargement', + 'Interface font', + 'Increase contrast', + 'Bold interface text', + 'Reduce animations', + ]; + + FocusNode focusFor(String label) => Focus.of(tester.element(find.text(label).first)); + + focusFor(labels.first).requestFocus(); + await tester.pumpAndSettle(); + + for (final label in labels) { + expect(focusFor(label).hasPrimaryFocus, isTrue, reason: 'Forward traversal should focus $label'); + + if (label != labels.last) { + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + } + } + + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + + for (final label in labels.reversed.skip(1)) { + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + expect(focusFor(label).hasPrimaryFocus, isTrue, reason: 'Reverse traversal should focus $label'); + } + + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + }); + } + testWidgets('desktop layout navigates across all sections correctly', (tester) async { await tester.binding.setSurfaceSize(const Size(1200, 900)); addTearDown(() => tester.binding.setSurfaceSize(null)); @@ -176,40 +311,42 @@ void main() { // Account section default expect(find.text('Account'), findsWidgets); expect(find.text('Reader Profile'), findsOneWidget); - expect(find.text('Edit profile'), findsOneWidget); + expect(find.text('Edit profile'), findsNothing); + expect(find.widgetWithText(TextFormField, 'Display name'), findsOneWidget); + expect(find.widgetWithText(FilledButton, 'Save'), findsOneWidget); // Navigate to Appearance await tester.tap(find.widgetWithText(InkWell, 'Appearance')); await tester.pumpAndSettle(); expect(find.text('System default'), findsOneWidget); - // Navigate to Reading - await tester.tap(find.widgetWithText(InkWell, 'Reading')); - await tester.pumpAndSettle(); - expect(find.text('Typography'), findsOneWidget); - expect(find.text('Behavior'), findsOneWidget); + for (final label in ['Reading', 'Library', 'Developer options']) { + expect(find.text(label), findsNothing); + } - // Navigate to Library - await tester.tap(find.widgetWithText(InkWell, 'Library')); - await tester.pumpAndSettle(); - expect(find.text('Display'), findsOneWidget); - expect(find.text('Default view mode'), findsOneWidget); + final semantics = tester.ensureSemantics(); - // Navigate to Notifications - await tester.tap(find.widgetWithText(InkWell, 'Notifications')); - await tester.pumpAndSettle(); - expect(find.text('Goal reminders'), findsOneWidget); + try { + await tester.pumpAndSettle(); + + for (final label in ['Notifications', 'Privacy']) { + final item = find.widgetWithText(InkWell, label); + final node = tester.getSemantics(find.bySemanticsLabel(label)); + expect(node.flagsCollection.isEnabled, Tristate.isFalse); + expect(node.getSemanticsData().hasAction(SemanticsAction.tap), isFalse); + await tester.tap(item); + await tester.pumpAndSettle(); + expect(find.text('System default'), findsOneWidget); + } + } finally { + semantics.dispose(); + } // Navigate to Storage await tester.tap(find.widgetWithText(InkWell, 'Storage')); await tester.pumpAndSettle(); expect(find.text('Library storage'), findsOneWidget); - // Navigate to Privacy - await tester.tap(find.widgetWithText(InkWell, 'Privacy')); - await tester.pumpAndSettle(); - expect(find.text('Send anonymous usage data'), findsOneWidget); - // Navigate to Accessibility await tester.tap(find.widgetWithText(InkWell, 'Accessibility')); await tester.pumpAndSettle(); diff --git a/app/test/pages/profile_storage_sync_test.dart b/app/test/pages/profile_storage_sync_test.dart index b49aa274..f564773f 100644 --- a/app/test/pages/profile_storage_sync_test.dart +++ b/app/test/pages/profile_storage_sync_test.dart @@ -12,6 +12,10 @@ import 'package:papyrus/auth/token_store.dart'; import 'package:papyrus/data/data_store.dart'; import 'package:papyrus/data/repositories/book_repository.dart'; import 'package:papyrus/media/media_upload_queue.dart'; +import 'package:papyrus/media/media_storage_scope.dart'; +import 'package:papyrus/services/book_import_service_stub.dart' + if (dart.library.js_interop) 'package:papyrus/services/book_import_service.dart'; +import 'package:papyrus/widgets/profile/storage_sync_settings_section.dart'; import 'package:papyrus/models/book.dart'; import 'package:papyrus/pages/profile_page.dart'; import 'package:papyrus/powersync/powersync_service.dart'; @@ -76,6 +80,7 @@ class _FakePowerSyncService extends PapyrusPowerSyncService { int reconnectCalls = 0; int clearGuestLibraryCalls = 0; int clearAuthenticatedCacheCalls = 0; + List guestBooks = []; @override LibraryDatabaseMode? get mode => currentMode; @@ -92,7 +97,9 @@ class _FakePowerSyncService extends PapyrusPowerSyncService { } @override - Future clearGuestLibrary() async { + Future clearGuestLibrary({required Future Function(List) deleteFiles}) async { + await deleteFiles(guestBooks); + guestBooks = []; clearGuestLibraryCalls += 1; } @@ -102,6 +109,26 @@ class _FakePowerSyncService extends PapyrusPowerSyncService { } } +class _Files extends BookImportService { + final deletedBooks = []; + final clearedScopes = []; + bool failDeletion = false; + + @override + Future deleteBookFile(String bookId) async { + if (failDeletion) { + throw StateError('Storage unavailable'); + } + + deletedBooks.add(bookId); + } + + @override + Future clearCoverFiles(MediaStorageScope scope) async { + clearedScopes.add(scope); + } +} + void main() { setUp(() { SharedPreferences.setMockInitialValues({}); @@ -133,6 +160,8 @@ void main() { DataStore? dataStore, MediaUploadQueue? mediaUploadQueue, AcquisitionAvailabilityProvider? acquisitionAvailabilityProvider, + Widget Function(Widget page)? pageBuilder, + BookImportService? files, }) async { final prefs = await SharedPreferences.getInstance(); @@ -149,6 +178,7 @@ void main() { value: syncSettingsProvider ?? SyncSettingsProvider(prefs, officialConfig: config), ), Provider.value(value: powerSyncService), + if (files != null) Provider.value(value: files), StreamProvider.value(value: powerSyncService.syncStates, initialData: powerSyncService.syncState), ChangeNotifierProvider.value(value: authProvider), ChangeNotifierProvider.value( @@ -170,18 +200,53 @@ void main() { child: MaterialApp( home: MediaQuery( data: MediaQueryData(size: screenSize), - child: const ProfilePage(), + child: pageBuilder?.call(const ProfilePage()) ?? const ProfilePage(), ), ), ); } + testWidgets('clearing the guest library removes its files and covers before discarding records', (tester) async { + final auth = await buildAuthProvider(guest: true); + final service = _FakePowerSyncService(currentMode: LibraryDatabaseMode.guest, currentSyncState: const SyncState()); + final files = _Files()..failDeletion = true; + service.guestBooks = [Book(id: 'guest-book', title: 'Guest book', author: 'Reader', addedAt: DateTime.now())]; + + await tester.pumpWidget( + await buildPage( + authProvider: auth, + powerSyncService: service, + files: files, + pageBuilder: (_) => const Scaffold(body: StorageSyncSettingsSection()), + ), + ); + + await tester.pumpAndSettle(); + await tester.tap(find.text('Clear local library')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Clear library')); + await tester.pumpAndSettle(); + expect(find.textContaining('Could not clear local library'), findsOneWidget); + expect(service.guestBooks.single.id, 'guest-book'); + expect(service.clearGuestLibraryCalls, 0); + files.failDeletion = false; + await tester.tap(find.text('Clear local library')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Clear library')); + await tester.pumpAndSettle(); + expect(files.deletedBooks, ['guest-book']); + expect(files.clearedScopes, [MediaStorageScope.localGuest]); + expect(service.guestBooks, isEmpty); + expect(service.clearGuestLibraryCalls, 1); + expect(auth.isOfflineMode, isTrue); + }); + testWidgets('offline storage sync UI is local-first and hides sync internals', (tester) async { final auth = await buildAuthProvider(guest: true); final service = _FakePowerSyncService(currentMode: LibraryDatabaseMode.guest, currentSyncState: const SyncState()); await tester.pumpWidget(await buildPage(authProvider: auth, powerSyncService: service)); await tester.pumpAndSettle(); - await tester.scrollUntilVisible(find.text('Storage'), 400); + await tester.scrollUntilVisible(find.text('Storage'), 400, scrollable: find.byType(Scrollable).first); await tester.pumpAndSettle(); expect(find.text('Stored on this device'), findsOneWidget); expect(find.text('Export or import a backup'), findsOneWidget); @@ -224,7 +289,11 @@ void main() { ); await tester.pumpAndSettle(); - await tester.scrollUntilVisible(find.byKey(const Key('profile-acquisition-section')), 400); + await tester.scrollUntilVisible( + find.byKey(const Key('profile-acquisition-section')), + 400, + scrollable: find.byType(Scrollable).first, + ); await tester.pumpAndSettle(); final acquisitionSection = find.byKey(const Key('profile-acquisition-section')); expect(find.descendant(of: acquisitionSection, matching: find.text('Data sync')), findsNothing); @@ -268,7 +337,11 @@ void main() { ); await tester.pump(); - await tester.scrollUntilVisible(find.byKey(const Key('profile-acquisition-section')), 400); + await tester.scrollUntilVisible( + find.byKey(const Key('profile-acquisition-section')), + 400, + scrollable: find.byType(Scrollable).first, + ); await tester.pump(); expect(find.text('Checking server support…'), findsOneWidget); completer.complete(_capabilities(enabled: true)); @@ -293,7 +366,7 @@ void main() { ); await tester.pumpAndSettle(); - await tester.scrollUntilVisible(find.text('Storage'), 400); + await tester.scrollUntilVisible(find.text('Storage'), 400, scrollable: find.byType(Scrollable).first); await tester.pumpAndSettle(); final acquisitionSection = find.byKey(const Key('profile-acquisition-section')); expect(acquisitionSection, findsOneWidget); @@ -456,7 +529,7 @@ void main() { ); await tester.pumpAndSettle(); - await tester.scrollUntilVisible(find.text('Storage'), 400); + await tester.scrollUntilVisible(find.text('Storage'), 400, scrollable: find.byType(Scrollable).first); await tester.pumpAndSettle(); await tester.tap(find.text('Manage servers')); await tester.pumpAndSettle(); @@ -466,6 +539,60 @@ void main() { expect(find.text('Add custom server'), findsOneWidget); }); + testWidgets('manage servers blocks the sidebar outside the settings navigator', (tester) async { + const size = Size(1440, 900); + await tester.binding.setSurfaceSize(size); + addTearDown(() => tester.binding.setSurfaceSize(null)); + final auth = await buildAuthProvider(signedIn: true); + final service = _FakePowerSyncService( + currentMode: LibraryDatabaseMode.authenticated, + currentSyncState: const SyncState(connected: true), + ); + var sidebarActivations = 0; + + await tester.pumpWidget( + await buildPage( + authProvider: auth, + powerSyncService: service, + screenSize: size, + pageBuilder: (page) => Scaffold( + body: Row( + children: [ + SizedBox( + width: 240, + child: TextButton( + onPressed: () => sidebarActivations++, + child: const Text('Main sidebar'), + ), + ), + Expanded( + child: Navigator(onGenerateRoute: (_) => MaterialPageRoute(builder: (_) => page)), + ), + ], + ), + ), + ), + ); + + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(InkWell, 'Storage')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Manage servers')); + await tester.pumpAndSettle(); + final barrier = find.byType(ModalBarrier).last; + expect(tester.getRect(barrier), Offset.zero & size); + await tester.tapAt(tester.getCenter(find.text('Main sidebar'))); + await tester.pumpAndSettle(); + expect(sidebarActivations, 0); + expect(find.text('Sync servers'), findsNothing); + await tester.tap(find.text('Manage servers')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(ListTile, 'Official server')); + await tester.pumpAndSettle(); + expect(find.text('Sync servers'), findsNothing); + expect(find.text('Manage servers'), findsOneWidget); + }); + testWidgets('storage sync UI shows pending writes and sync errors', (tester) async { final auth = await buildAuthProvider(signedIn: true); diff --git a/app/test/powersync/powersync_service_test.dart b/app/test/powersync/powersync_service_test.dart index 195fdfd4..bb275bb1 100644 --- a/app/test/powersync/powersync_service_test.dart +++ b/app/test/powersync/powersync_service_test.dart @@ -282,7 +282,31 @@ void main() { final first = service(); await first.activateGuest(); await first.upsert(_book('guest-book')); - await first.clearGuestLibrary(); + await first.clearGuestLibrary( + deleteFiles: (books) async { + expect(books.map((book) => book.id), ['guest-book']); + }, + ); + expect(await first.getById('guest-book'), isNull); + await first.close(); + }); + + test('clearGuestLibrary retains records when file cleanup fails and permits retry', () async { + final first = service(); + await first.activateGuest(); + await first.upsert(_book('guest-book')); + + await expectLater( + first.clearGuestLibrary(deleteFiles: (_) async => throw StateError('Storage unavailable')), + throwsStateError, + ); + + expect(await first.getById('guest-book'), isNotNull); + + await first.clearGuestLibrary( + deleteFiles: (books) async => expect(books.map((book) => book.id), ['guest-book']), + ); + expect(await first.getById('guest-book'), isNull); await first.close(); }); diff --git a/app/test/providers/accessibility_preferences_test.dart b/app/test/providers/accessibility_preferences_test.dart new file mode 100644 index 00000000..9ec8bd03 --- /dev/null +++ b/app/test/providers/accessibility_preferences_test.dart @@ -0,0 +1,40 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/providers/preferences_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('accessibility defaults follow the system and changes survive reload', () async { + SharedPreferences.setMockInitialValues({}); + final storage = await SharedPreferences.getInstance(); + final preferences = PreferencesProvider(storage); + addTearDown(preferences.dispose); + expect(preferences.interfaceTextEnlargement, 1); + expect(preferences.increaseContrast, isFalse); + expect(preferences.boldInterfaceText, isFalse); + var notifications = 0; + preferences.addListener(() => notifications++); + preferences.interfaceTextEnlargement = 1.3; + preferences.increaseContrast = true; + preferences.boldInterfaceText = true; + expect(notifications, 3); + await storage.reload(); + final restored = PreferencesProvider(storage); + addTearDown(restored.dispose); + expect(restored.interfaceTextEnlargement, 1.3); + expect(restored.increaseContrast, isTrue); + expect(restored.boldInterfaceText, isTrue); + }); + + for (final value in [-1.0, 0.0, 1.2, 3.0, double.nan, double.infinity, 'large', 2]) { + test('invalid saved text enlargement $value uses the system default', () async { + SharedPreferences.setMockInitialValues({'interface_text_enlargement': value}); + final preferences = PreferencesProvider(await SharedPreferences.getInstance()); + addTearDown(preferences.dispose); + expect(preferences.interfaceTextEnlargement, 1); + preferences.interfaceTextEnlargement = 0.5; + expect(preferences.interfaceTextEnlargement, 1); + }); + } +} diff --git a/app/test/providers/auth_provider_test.dart b/app/test/providers/auth_provider_test.dart index e875a7f2..30a08794 100644 --- a/app/test/providers/auth_provider_test.dart +++ b/app/test/providers/auth_provider_test.dart @@ -89,6 +89,40 @@ void main() { SharedPreferences.setMockInitialValues({}); }); + test('guest deletion rejects duplicate submissions and preserves guest mode on failure', () async { + final prefs = await SharedPreferences.getInstance(); + final provider = AuthProvider(prefs, repository: FakeAuthRepository(), bootstrapOnCreate: false); + addTearDown(provider.dispose); + provider.setOfflineMode(true); + final cleanup = Completer(); + final deletion = provider.deleteGuestAccount(() => cleanup.future); + expect(provider.isDeletingGuestAccount, isTrue); + await expectLater(provider.deleteGuestAccount(() async => fail('Duplicate cleanup')), throwsStateError); + final failure = expectLater(deletion, throwsStateError); + cleanup.completeError(StateError('Storage failure')); + await failure; + expect(provider.isOfflineMode, isTrue); + expect(provider.isDeletingGuestAccount, isFalse); + }); + + test('guest deletion does not sign out an account selected during cleanup', () async { + final prefs = await SharedPreferences.getInstance(); + final provider = AuthProvider(prefs, repository: FakeAuthRepository(), bootstrapOnCreate: false); + addTearDown(provider.dispose); + provider.setOfflineMode(true); + final cleanup = Completer(); + final deletion = provider.deleteGuestAccount(() => cleanup.future); + provider.setOfflineMode(false); + final repository = FakeAuthRepository()..bootstrapResult = _tokens('new-user'); + await provider.replaceRepository(repository); + final failure = expectLater(deletion, throwsStateError); + cleanup.complete(); + await failure; + expect(provider.isSignedIn, isTrue); + expect(provider.user?.displayName, 'new-user'); + expect(repository.clearCalled, isFalse); + }); + test('deletion finishing after a server switch preserves the newly selected account', () async { final prefs = await SharedPreferences.getInstance(); diff --git a/app/test/providers/guest_profile_test.dart b/app/test/providers/guest_profile_test.dart new file mode 100644 index 00000000..4224fe72 --- /dev/null +++ b/app/test/providers/guest_profile_test.dart @@ -0,0 +1,135 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/auth/auth_models.dart'; +import 'package:papyrus/providers/auth_provider.dart'; +import 'package:papyrus/services/guest_profile_store.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'auth_provider_test.dart' show FakeAuthRepository; + +class _Store extends GuestProfileStore { + _Store(super.prefs); + + bool fail = false; + Completer? saving; + + @override + Future save({required String displayName, Uint8List? photoBytes}) async { + await saving?.future; + + if (fail) { + throw StateError('Storage unavailable'); + } + + await super.save(displayName: displayName, photoBytes: photoBytes); + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + late SharedPreferences prefs; + late AuthProvider auth; + late _Store store; + final photo = Uint8List.fromList([1, 2, 3]); + + setUp(() async { + SharedPreferences.setMockInitialValues({'offline_mode': true}); + prefs = await SharedPreferences.getInstance(); + store = _Store(prefs); + auth = AuthProvider(prefs, repository: FakeAuthRepository(), guestProfileStore: store, bootstrapOnCreate: false); + addTearDown(auth.dispose); + }); + + test('local name and photo persist together, including name-only updates', () async { + expect(auth.canEditProfile, isTrue); + expect(auth.profileDisplayName, 'Anonymous User'); + expect(await auth.updateProfile(displayName: 'Local Reader', photoBytes: photo), isTrue); + expect(await auth.updateProfile(displayName: 'New Name'), isTrue); + await prefs.reload(); + final restarted = AuthProvider(prefs, repository: FakeAuthRepository(), bootstrapOnCreate: false); + addTearDown(restarted.dispose); + await restarted.bootstrap(); + expect(restarted.profileDisplayName, 'New Name'); + expect(restarted.profilePhotoBytes, photo); + expect(restarted.user, isNull); + expect(restarted.isOfflineMode, isTrue); + }); + + test('signed-in profiles do not inherit or overwrite the local profile', () async { + await auth.updateProfile(displayName: 'Local Reader', photoBytes: photo); + auth.setOfflineMode(false); + final repository = FakeAuthRepository() + ..bootstrapResult = AuthTokens( + accessToken: 'access', + refreshToken: 'refresh', + tokenType: 'Bearer', + expiresIn: 3600, + user: const PapyrusUser( + userId: 'account', + email: 'reader@example.com', + displayName: 'Server Reader', + avatarUrl: null, + emailVerified: true, + createdAt: null, + lastLoginAt: null, + ), + ); + + await auth.replaceRepository(repository); + expect(auth.profileDisplayName, 'Server Reader'); + expect(auth.profilePhotoBytes, isNull); + auth.setOfflineMode(true); + expect(auth.profileDisplayName, 'Local Reader'); + expect(auth.profilePhotoBytes, photo); + }); + + test('failed local save keeps the previous profile and allows retry', () async { + await auth.updateProfile(displayName: 'Original', photoBytes: photo); + store.fail = true; + expect(await auth.updateProfile(displayName: 'Updated', photoBytes: Uint8List.fromList([4])), isFalse); + expect(auth.profileDisplayName, 'Original'); + expect(auth.profilePhotoBytes, photo); + expect(auth.error, contains('on this device')); + store.fail = false; + expect(await auth.updateProfile(displayName: 'Updated'), isTrue); + expect(auth.profileDisplayName, 'Updated'); + expect(auth.error, isNull); + }); + + test('deletion waits for a pending save and then removes the entire local profile', () async { + store.saving = Completer(); + final saved = auth.updateProfile(displayName: 'Local Reader', photoBytes: photo); + var cleared = false; + final deleted = auth.deleteGuestAccount(() async => cleared = true); + expect(auth.canEditProfile, isFalse); + expect(await auth.updateProfile(displayName: 'Too late'), isFalse); + expect(cleared, isFalse); + store.saving!.complete(); + await saved; + await deleted; + expect(cleared, isTrue); + await prefs.reload(); + expect(prefs.containsKey(GuestProfileStore.preferenceKey), isFalse); + auth.setOfflineMode(true); + expect(auth.profileDisplayName, 'Anonymous User'); + expect(auth.profilePhotoBytes, isNull); + }); + + test('failed account cleanup retains the local profile for retry', () async { + await auth.updateProfile(displayName: 'Local Reader', photoBytes: photo); + await expectLater(auth.deleteGuestAccount(() async => throw StateError('Disk unavailable')), throwsStateError); + expect(auth.profileDisplayName, 'Local Reader'); + expect(auth.profilePhotoBytes, photo); + expect(auth.canEditProfile, isTrue); + }); + + test('malformed stored profile falls back without preventing startup', () async { + await prefs.setString(GuestProfileStore.preferenceKey, '{"display_name":"Reader","photo":"bad base64"}'); + final restarted = AuthProvider(prefs, repository: FakeAuthRepository(), bootstrapOnCreate: false); + addTearDown(restarted.dispose); + expect(restarted.profileDisplayName, 'Anonymous User'); + expect(restarted.profilePhotoBytes, isNull); + }); +} diff --git a/app/test/services/book_import_session_test.dart b/app/test/services/book_import_session_test.dart index d3f09176..d17bf0db 100644 --- a/app/test/services/book_import_session_test.dart +++ b/app/test/services/book_import_session_test.dart @@ -23,19 +23,28 @@ class _DataStore extends Fake implements DataStore { } class _Auth extends Fake implements AuthProvider { + _Auth({this.guest = false}); + final bool guest; + + @override + bool isDeletingGuestAccount = false; + @override - bool get isSignedIn => true; + bool get isSignedIn => !guest; @override - bool get isOfflineMode => false; + bool get isOfflineMode => guest; @override - PapyrusUser get user => PapyrusUser.fromJson({'user_id': 'bob', 'connected_providers': []}); + PapyrusUser? get user => guest ? null : PapyrusUser.fromJson({'user_id': 'bob', 'connected_providers': []}); } class _Queue extends Fake implements MediaUploadQueue { + _Queue({this.guest = false}); + final bool guest; + @override - MediaStorageScope get activeScope => MediaStorageScope(profileKey: 'official', userId: 'alice'); + MediaStorageScope? get activeScope => guest ? null : MediaStorageScope(profileKey: 'official', userId: 'alice'); } class _Database extends Fake implements PapyrusPowerSyncService { @@ -48,6 +57,24 @@ class _Database extends Fake implements PapyrusPowerSyncService { class _Importer extends Fake implements BookImportService {} void main() { + test('guest deletion invalidates imports and prevents new sessions', () { + final auth = _Auth(guest: true); + + BookImportSession capture() => BookImportSession.capture( + dataStore: _DataStore(), + queue: _Queue(guest: true), + importService: _Importer(), + authProvider: auth, + powerSyncService: _Database(LibraryDatabaseMode.guest), + ); + + final session = capture(); + expect(session.isCurrent(), isTrue); + auth.isDeletingGuestAccount = true; + expect(session.isCurrent(), isFalse); + expect(capture, throwsStateError); + }); + test('cannot capture the previous account media scope during account activation', () { expect( () => BookImportSession.capture( diff --git a/app/test/services/guest_account_deletion_service_test.dart b/app/test/services/guest_account_deletion_service_test.dart new file mode 100644 index 00000000..aeac9c14 --- /dev/null +++ b/app/test/services/guest_account_deletion_service_test.dart @@ -0,0 +1,194 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/media/media_storage_scope.dart'; +import 'package:papyrus/models/book.dart'; +import 'package:papyrus/models/bookmark.dart'; +import 'package:papyrus/opds/opds_catalog_store.dart'; +import 'package:papyrus/opds/opds_catalogs.dart'; +import 'package:papyrus/opds/opds_downloads.dart'; +import 'package:papyrus/opds/opds_http_client.dart'; +import 'package:papyrus/opds/opds_library.dart'; +import 'package:papyrus/opds/opds_models.dart'; +import 'package:papyrus/opds/opds_resource_cache.dart'; +import 'package:papyrus/powersync/powersync_service.dart'; +import 'package:papyrus/powersync/sync_state.dart'; +import 'package:papyrus/providers/auth_provider.dart'; +import 'package:papyrus/services/book_import_service_stub.dart'; +import 'package:papyrus/services/guest_account_deletion_service.dart'; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../opds/catalog_store_test.dart' show MemorySecrets; +import '../powersync/powersync_service_test.dart' show OfflineConnector; +import '../providers/auth_provider_test.dart' show FakeAuthRepository; + +class _Paths extends Fake with MockPlatformInterfaceMixin implements PathProviderPlatform { + _Paths(this.directory); + final Directory directory; + + @override + Future getApplicationDocumentsPath() async => directory.path; + + @override + Future getApplicationSupportPath() async => directory.path; +} + +class _Files extends BookImportService { + bool failDeletion = false; + + @override + Future deleteBookFile(String bookId) async { + if (failDeletion) { + throw const FileSystemException('Storage unavailable'); + } + + await super.deleteBookFile(bookId); + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + late Directory directory; + late SharedPreferences prefs; + late AuthProvider auth; + late PapyrusPowerSyncService database; + late _Files files; + late OpdsCatalogs catalogs; + late OpdsCatalogStore catalogStore; + late MemorySecrets secrets; + late OpdsResourceCache cache; + late GuestAccountDeletionService deletion; + const guest = MediaStorageScope.localGuest; + final account = MediaStorageScope(profileKey: 'official', userId: 'reader'); + final catalog = OpdsCatalog(id: 'catalog', name: 'Books', uri: Uri.parse('https://catalog.test/feed')); + final bytes = Uint8List.fromList([1, 2, 3]); + + PapyrusPowerSyncService createDatabase() => PapyrusPowerSyncService( + connectorFactory: OfflineConnector.new, + connectAuthenticated: false, + pathResolver: (mode, profile, user) async => + '${directory.path}/${mode == LibraryDatabaseMode.guest ? 'guest' : '$profile-$user'}.db', + ); + + setUp(() async { + directory = await Directory.systemTemp.createTemp('papyrus-guest-delete-'); + final originalPaths = PathProviderPlatform.instance; + PathProviderPlatform.instance = _Paths(directory); + addTearDown(() => PathProviderPlatform.instance = originalPaths); + SharedPreferences.setMockInitialValues({'offline_mode': true, 'bold_interface_text': true}); + prefs = await SharedPreferences.getInstance(); + auth = AuthProvider(prefs, repository: FakeAuthRepository(), bootstrapOnCreate: false); + await auth.bootstrap(); + database = createDatabase(); + final store = DataStore(bookRepository: database); + secrets = MemorySecrets(); + catalogStore = OpdsCatalogStore(prefs, secrets: secrets); + cache = OpdsResourceCache(prefs); + final provenance = OpdsLibrary(prefs, dataStore: store); + catalogs = OpdsCatalogs(catalogStore, library: provenance, cache: cache)..setScope(guest.persistenceKey); + final downloads = OpdsDownloads(captureImport: () => throw StateError('No import expected'), library: provenance); + files = _Files(); + deletion = GuestAccountDeletionService( + auth: auth, + library: database, + files: files, + catalogs: catalogs, + downloads: downloads, + ); + + await database.activateAuthenticated('reader'); + await database.upsert(Book(id: 'account', title: 'Account book', author: 'Reader', addedAt: DateTime.now())); + await database.activateGuest(); + await database.upsert(Book(id: 'guest', title: 'Guest book', author: 'Reader', addedAt: DateTime.now())); + await database.bookmarks.upsert(Bookmark(id: 'bookmark', bookId: 'guest', position: .5, createdAt: DateTime.now())); + await Directory('${directory.path}/books').create(); + await File('${directory.path}/books/guest.epub').writeAsBytes(bytes); + await File('${directory.path}/books/account.epub').writeAsBytes(bytes); + await files.storeGuestCoverFile('guest', bytes); + await files.storeCoverFile(account, 'cover', bytes); + + for (final scope in [guest.persistenceKey, account.persistenceKey]) { + await catalogStore.save( + scope, + catalog, + credentials: const OpdsCredentials(username: 'user', password: 'secret'), + ); + cache.setScope(scope); + await cache.write( + cache.capture(catalog, catalog.uri)!, + OpdsResponse(uri: catalog.uri, headers: const {}, bytes: bytes), + ); + } + + cache.setScope(guest.persistenceKey); + catalogs.reload(); + await prefs.setString('papyrus.opds.imports.v1.${guest.persistenceKey}', '{}'); + + addTearDown(() async { + downloads.dispose(); + catalogs.dispose(); + provenance.dispose(); + cache.dispose(); + store.dispose(); + auth.dispose(); + await database.close(); + await directory.delete(recursive: true); + }); + }); + + test('deletes persistent guest data and files while preserving other accounts', () async { + final staleToken = cache.capture(catalog, catalog.uri)!; + await deletion.delete(); + expect(auth.isOfflineMode, isFalse); + expect(prefs.getBool('offline_mode'), isFalse); + expect(await files.getBookFile('guest'), isNull); + expect(await files.getGuestCoverFile('guest'), isNull); + expect(await files.getBookFile('account'), bytes); + expect(await files.getCoverFile(account, 'cover'), bytes); + expect(catalogStore.load(guest.persistenceKey), isEmpty); + expect(await catalogStore.credentials(guest.persistenceKey, catalog.id), isNull); + expect(await catalogStore.credentials(account.persistenceKey, catalog.id), isNotNull); + expect(prefs.containsKey('papyrus.opds.imports.v1.${guest.persistenceKey}'), isFalse); + expect(cache.isCurrent(staleToken), isFalse); + cache.setScope(account.persistenceKey); + expect(cache.read(cache.capture(catalog, catalog.uri)!), isNotNull); + final restoredCache = OpdsResourceCache(prefs)..setScope(guest.persistenceKey); + expect(restoredCache.read(restoredCache.capture(catalog, catalog.uri)!), isNull); + restoredCache.setScope(account.persistenceKey); + expect(restoredCache.read(restoredCache.capture(catalog, catalog.uri)!), isNotNull); + restoredCache.dispose(); + expect(prefs.getBool('bold_interface_text'), isTrue); + await database.close(); + database = createDatabase(); + await database.activateGuest(); + expect(await database.getById('guest'), isNull); + expect(await database.bookmarks.getById('bookmark'), isNull); + await database.activateAuthenticated('reader'); + expect(await database.getById('account'), isNotNull); + }); + + test('failed cleanup keeps guest mode and records available for retry', () async { + files.failDeletion = true; + await expectLater(deletion.delete(), throwsA(isA())); + expect(auth.isOfflineMode, isTrue); + expect(auth.isDeletingGuestAccount, isFalse); + expect(await database.getById('guest'), isNotNull); + expect(await files.getBookFile('guest'), bytes); + files.failDeletion = false; + await deletion.delete(); + expect(auth.isOfflineMode, isFalse); + expect(await database.getById('guest'), isNull); + }); + + test('rejects deleting an authenticated library even if guest auth state is stale', () async { + await database.activateAuthenticated('reader'); + await expectLater(deletion.delete(), throwsStateError); + expect(await database.getById('account'), isNotNull); + expect(await files.getBookFile('account'), bytes); + expect(auth.isOfflineMode, isTrue); + }); +} diff --git a/app/test/services/guest_profile_browser_test.dart b/app/test/services/guest_profile_browser_test.dart new file mode 100644 index 00000000..2094a211 --- /dev/null +++ b/app/test/services/guest_profile_browser_test.dart @@ -0,0 +1,42 @@ +@TestOn('browser') +library; + +import 'dart:ui' as ui; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:image/image.dart' as image; +import 'package:papyrus/services/guest_profile_store.dart'; +import 'package:papyrus/services/profile_photo.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +// ignore: depend_on_referenced_packages +import 'package:shared_preferences_web/shared_preferences_web.dart'; + +void main() { + test('saved guest photo decodes after real browser storage reload', () async { + SharedPreferencesPlugin.registerWith(null); + final prefs = await SharedPreferences.getInstance(); + final store = GuestProfileStore(prefs); + addTearDown(store.clear); + final original = prepareProfilePhoto(image.encodePng(image.Image(width: 80, height: 100))); + await store.save(displayName: 'Browser test', photoBytes: original); + await _expectDecodable(store); + await prefs.reload(); + final restored = GuestProfileStore(prefs); + expect(restored.displayName, 'Browser test'); + expect(restored.photoBytes, original); + await _expectDecodable(restored); + }); +} + +Future _expectDecodable(GuestProfileStore store) async { + final codec = await ui.instantiateImageCodec(store.photoBytes!); + + try { + final frame = await codec.getNextFrame(); + expect(frame.image.width, 80); + expect(frame.image.height, 100); + frame.image.dispose(); + } finally { + codec.dispose(); + } +} diff --git a/app/test/services/profile_photo_test.dart b/app/test/services/profile_photo_test.dart new file mode 100644 index 00000000..e9598f4b --- /dev/null +++ b/app/test/services/profile_photo_test.dart @@ -0,0 +1,23 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:image/image.dart' as image; +import 'package:papyrus/services/profile_photo.dart'; + +void main() { + test('photo preparation resizes, removes metadata and produces a JPEG', () { + final source = image.Image(width: 1200, height: 600); + source.exif.imageIfd[0x010e] = image.IfdValueAscii('Private description'); + final prepared = prepareProfilePhoto(image.encodeJpg(source)); + final result = image.decodeJpg(prepared)!; + expect(result.width, 512); + expect(result.height, 256); + expect(result.exif.imageIfd[0x010e], isNull); + expect(prepared.length, lessThan(maxProfilePhotoBytes)); + }); + + test('invalid and oversized photos are rejected before upload', () { + expect(() => prepareProfilePhoto(Uint8List.fromList([1, 2, 3])), throwsFormatException); + expect(() => prepareProfilePhoto(Uint8List(maxProfilePhotoBytes + 1)), throwsFormatException); + }); +} diff --git a/app/test/themes/app_accessibility_test.dart b/app/test/themes/app_accessibility_test.dart new file mode 100644 index 00000000..0037ac13 --- /dev/null +++ b/app/test/themes/app_accessibility_test.dart @@ -0,0 +1,171 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/providers/preferences_provider.dart'; +import 'package:papyrus/themes/app_accessibility.dart'; +import 'package:papyrus/themes/app_motion.dart'; +import 'package:papyrus/themes/app_theme.dart'; +import 'package:papyrus/widgets/settings/settings_row.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class _NonlinearScaler extends TextScaler { + const _NonlinearScaler(); + @override + double scale(double fontSize) => fontSize < 20 ? fontSize * 2 : fontSize * 1.5; + @override + double get textScaleFactor => 2; +} + +void main() { + test('additional enlargement preserves the system curve', () { + const scaler = InterfaceTextScaler(_NonlinearScaler(), 1.5); + expect(scaler.scale(16), 48); + expect(scaler.scale(24), 54); + }); + + testWidgets('interface overrides and live system changes leave reader text independent', (tester) async { + SharedPreferences.setMockInitialValues({}); + final preferences = PreferencesProvider(await SharedPreferences.getInstance()); + addTearDown(preferences.dispose); + final system = ValueNotifier(const MediaQueryData(textScaler: _NonlinearScaler())); + addTearDown(system.dispose); + + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light, + builder: (context, child) => ListenableBuilder( + listenable: Listenable.merge([preferences, system]), + builder: (context, _) => MediaQuery( + data: system.value, + child: AppAccessibilityScope(preferences: preferences, child: child!), + ), + ), + home: const Scaffold( + body: Column( + children: [ + TextField(decoration: InputDecoration(labelText: 'Interface input')), + ReaderSystemTextScope( + child: TextField(decoration: InputDecoration(labelText: 'Reader input')), + ), + ], + ), + ), + ), + ); + preferences.interfaceTextEnlargement = 2; + preferences.boldInterfaceText = true; + await tester.pumpAndSettle(); + final interface = tester.widget(find.byType(EditableText).first); + final reader = tester.widget(find.byType(EditableText).last); + expect(MediaQuery.textScalerOf(tester.element(find.byType(EditableText).first)).scale(16), 64); + expect(interface.style.fontWeight, FontWeight.bold); + expect(MediaQuery.textScalerOf(tester.element(find.byType(EditableText).last)).scale(16), 32); + expect(reader.style.fontWeight, isNot(FontWeight.bold)); + preferences.boldInterfaceText = false; + system.value = const MediaQueryData(textScaler: TextScaler.linear(1.5), boldText: true, disableAnimations: true); + await tester.pumpAndSettle(); + expect(MediaQuery.textScalerOf(tester.element(find.byType(EditableText).first)).scale(16), 48); + expect(MediaQuery.textScalerOf(tester.element(find.byType(EditableText).last)).scale(16), 24); + expect(tester.widget(find.byType(EditableText).last).style.fontWeight, FontWeight.bold); + expect(AppMotion.disabled(tester.element(find.byType(EditableText).first)), isTrue); + preferences.interfaceTextEnlargement = 1; + await tester.pumpAndSettle(); + expect(MediaQuery.textScalerOf(tester.element(find.byType(EditableText).first)).scale(16), 24); + }); + + testWidgets('disabled toggle exposes its state without an activation action', (tester) async { + final semantics = tester.ensureSemantics(); + try { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: SettingsToggleRow(label: 'Increase contrast', value: true, onChanged: null)), + ), + ); + expect( + tester.getSemantics(find.bySemanticsLabel('Increase contrast')), + isSemantics( + hasEnabledState: true, + isEnabled: false, + hasToggledState: true, + isToggled: true, + hasTapAction: false, + isFocusable: false, + ), + ); + } finally { + semantics.dispose(); + } + }); + + for (final theme in [ + AppTheme.light, + AppTheme.dark, + AppTheme.eink, + AppTheme.highContrastLight, + AppTheme.highContrastDark, + ]) { + testWidgets( + 'toggle has one labeled state and supports pointer, keyboard and semantics in ${theme.brightness}, e-ink ${theme.extension() != null}', + ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + + final value = ValueNotifier(false); + addTearDown(value.dispose); + var changes = 0; + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: ValueListenableBuilder( + valueListenable: value, + builder: (_, enabled, _) => SettingsToggleRow( + label: 'Bold interface text', + value: enabled, + onChanged: (next) { + changes++; + value.value = next; + }, + ), + ), + ), + ), + ); + expect(find.bySemanticsLabel('Bold interface text'), findsOneWidget); + expect( + tester.getSemantics(find.bySemanticsLabel('Bold interface text')), + matchesSemantics( + label: 'Bold interface text', + hasEnabledState: true, + isEnabled: true, + hasToggledState: true, + isToggled: false, + hasTapAction: true, + hasFocusAction: true, + isFocusable: true, + ), + ); + await tester.tap(find.text('Bold interface text')); + await tester.pumpAndSettle(); + expect(changes, 1); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pumpAndSettle(); + expect(changes, 2); + expect(value.value, isFalse); + final node = tester.getSemantics(find.bySemanticsLabel('Bold interface text')); + node.owner!.performAction(node.id, SemanticsAction.tap); + await tester.pumpAndSettle(); + expect(changes, 3); + expect(value.value, isTrue); + await expectLater(tester, meetsGuideline(labeledTapTargetGuideline)); + await expectLater(tester, meetsGuideline(androidTapTargetGuideline)); + await expectLater(tester, meetsGuideline(iOSTapTargetGuideline)); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + semantics.dispose(); + }, + ); + } +} diff --git a/app/test/widgets/accessibility_journeys_test.dart b/app/test/widgets/accessibility_journeys_test.dart new file mode 100644 index 00000000..f18afd6b --- /dev/null +++ b/app/test/widgets/accessibility_journeys_test.dart @@ -0,0 +1,203 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/models/book.dart'; +import 'package:papyrus/pages/login_page.dart'; +import 'package:papyrus/pages/register_page.dart'; +import 'package:papyrus/pages/forgot_password_page.dart'; +import 'package:papyrus/providers/preferences_provider.dart'; +import 'package:papyrus/themes/app_accessibility.dart'; +import 'package:papyrus/themes/app_theme.dart'; +import 'package:papyrus/widgets/book_details/book_action_buttons.dart'; +import 'package:papyrus/widgets/input/email_input.dart'; +import 'package:papyrus/widgets/input/name_input.dart'; +import 'package:papyrus/widgets/input/password_input.dart'; +import 'package:papyrus/widgets/input/search_field.dart'; +import 'package:papyrus/widgets/shell/adaptive_app_shell.dart'; +import 'package:papyrus/widgets/shell/mobile_bottom_nav.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() async { + final loader = FontLoader(AppTheme.dyslexiaFontFamily); + + for (final style in ['Regular', 'Bold', 'Italic', 'BoldItalic']) { + loader.addFont(rootBundle.load('fonts/OpenDyslexic/OpenDyslexic-$style.otf')); + } + + await loader.load(); + }); + + for (final (name, page) in [ + ('login', const LoginPage()), + ('registration', const RegisterPage()), + ('recovery', const ForgotPasswordPage()), + ]) { + for (final width in [320.0, 390.0, 1280.0]) { + testWidgets('$name retains reachable fields and submission at $width with combined enlargement', (tester) async { + tester.view.physicalSize = Size(width, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + SharedPreferences.setMockInitialValues({}); + final preferences = PreferencesProvider(await SharedPreferences.getInstance()) + ..interfaceTextEnlargement = 2 + ..boldInterfaceText = true; + addTearDown(preferences.dispose); + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.withTypography(AppTheme.highContrastLight, dyslexiaFont: true), + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith(textScaler: const TextScaler.linear(2)), + child: AppAccessibilityScope(preferences: preferences, child: child!), + ), + home: page, + ), + ); + await tester.pumpAndSettle(); + if (name == 'registration') { + final displayName = find.descendant(of: find.byType(NameInput), matching: find.byType(TextFormField)); + await tester.ensureVisible(displayName); + await tester.enterText(displayName, 'Reader'); + } + + final email = find.byType(EmailInput); + await tester.ensureVisible(email); + await tester.enterText(find.descendant(of: email, matching: find.byType(TextFormField)), 'invalid'); + await tester.pump(); + // Opening the keyboard must not trap the submit action below the viewport. + tester.view.viewInsets = const FakeViewPadding(bottom: 250); + await tester.pumpAndSettle(); + final submit = find.byType(ElevatedButton).first; + await tester.ensureVisible(submit); + await tester.pumpAndSettle(); + expect(submit.hitTestable(), findsOneWidget); + await tester.tap(submit); + await tester.pumpAndSettle(); + expect(find.text('Not a valid email'), findsOneWidget); + final field = tester.widget(find.descendant(of: email, matching: find.byType(EditableText))); + expect(field.controller.text, 'invalid'); + expect(field.focusNode.hasFocus, isTrue); + expect(tester.takeException(), isNull); + }); + } + } + + testWidgets('email and password support autofill, paste, and labeled visibility controls', (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light, + home: const Scaffold( + body: AutofillGroup( + child: Column( + children: [ + EmailInput(labelText: 'Email'), + PasswordInput(labelText: 'Password'), + ], + ), + ), + ), + ), + ); + final password = find.descendant(of: find.byType(PasswordInput), matching: find.byType(EditableText)); + final field = tester.widget(password); + expect(field.autofillHints, contains(AutofillHints.password)); + expect(tester.widget(find.byType(EditableText).first).autofillHints, contains(AutofillHints.email)); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, (call) async { + if (call.method == 'Clipboard.getData') { + return {'text': 'pasted-password'}; + } + return null; + }); + addTearDown(() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(SystemChannels.platform, null)); + await tester.enterText(password, ''); + await tester.pump(); + await tester.runAsync(() async { + await Clipboard.setData(const ClipboardData(text: 'pasted-password')); + await tester.state(password).pasteText(SelectionChangedCause.keyboard); + }); + await tester.pump(); + expect(field.controller.text, 'pasted-password'); + await tester.tap(find.byTooltip('Show password')); + await tester.pump(); + expect(tester.widget(password).obscureText, isFalse); + expect(find.byTooltip('Hide password'), findsOneWidget); + }); + + for (final (name, theme) in [ + ('light', AppTheme.light), + ('dark', AppTheme.dark), + ('eink', AppTheme.eink), + ('high contrast light', AppTheme.highContrastLight), + ('high contrast dark', AppTheme.highContrastDark), + ]) { + testWidgets('$name book actions and search pass label, touch-target and text contrast checks', (tester) async { + final semantics = tester.ensureSemantics(); + final search = TextEditingController(text: 'Book'); + addTearDown(search.dispose); + try { + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: Column( + children: [ + BookActionButtons( + book: Book(id: 'book', title: 'Book', author: 'Author', addedAt: DateTime(2026)), + onContinueReading: () {}, + onEdit: () {}, + onToggleFavorite: () {}, + ), + SearchField(controller: search, hintText: 'Search books'), + ], + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.byTooltip('Edit book'), findsOneWidget); + expect(find.byTooltip('Add to favorites'), findsOneWidget); + await expectLater(tester, meetsGuideline(labeledTapTargetGuideline)); + await expectLater(tester, meetsGuideline(androidTapTargetGuideline)); + await expectLater(tester, meetsGuideline(iOSTapTargetGuideline)); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + } finally { + semantics.dispose(); + } + }); + } + + testWidgets('large-text navigation reaches Settings without clamping text', (tester) async { + tester.view.physicalSize = const Size(320, 720); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + String? destination; + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: const MediaQueryData(size: Size(320, 720), textScaler: TextScaler.linear(4)), + child: Scaffold( + bottomNavigationBar: MobileBottomNav( + currentPath: '/library/books', + items: const [ + AppShellNavItem(path: '/library', label: 'Library', icon: Icons.book), + AppShellNavItem(path: '/statistics', label: 'Statistics', icon: Icons.bar_chart), + AppShellNavItem(path: '/profile', label: 'Settings', icon: Icons.settings), + ], + onNavigate: (value) => destination = value, + ), + ), + ), + ), + ); + await tester.ensureVisible(find.text('Settings')); + await tester.tap(find.text('Settings')); + await tester.pumpAndSettle(); + expect(destination, '/profile'); + expect(MediaQuery.textScalerOf(tester.element(find.text('Settings'))).scale(14), 56); + expect(tester.takeException(), isNull); + }); +} diff --git a/app/test/widgets/accessibility_library_journeys_test.dart b/app/test/widgets/accessibility_library_journeys_test.dart new file mode 100644 index 00000000..bddd36bc --- /dev/null +++ b/app/test/widgets/accessibility_library_journeys_test.dart @@ -0,0 +1,110 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/pages/book_details_page.dart'; +import 'package:papyrus/pages/book_edit_page.dart'; +import 'package:papyrus/themes/app_theme.dart'; +import 'package:papyrus/widgets/add_book/book_import_sheet_sections.dart'; +import 'package:papyrus/widgets/add_book/book_import_batch_item.dart'; + +import '../helpers/test_helpers.dart'; + +void main() { + for (final width in [320.0, 390.0, 1280.0]) { + for (final scale in [2.0, 4.0]) { + for (final editing in [false, true]) { + testWidgets('${editing ? 'editing' : 'details'} actions remain reachable at $width and ${scale}x', ( + tester, + ) async { + final size = Size(width, 900); + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final book = buildTestBook(id: 'book', title: 'A book with a longer title', author: 'An Author'); + await tester.pumpWidget( + createTestPage( + dataStore: createTestDataStore(books: [book]), + screenSize: size, + page: Theme( + data: AppTheme.light, + child: MediaQuery( + data: MediaQueryData(size: size, textScaler: TextScaler.linear(scale)), + child: Scaffold( + body: editing ? const BookEditPage(id: 'book') : const BookDetailsPage(id: 'book'), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + if (editing) { + final title = find.byWidgetPredicate( + (widget) => widget is TextFormField && widget.controller?.text == book.title, + ); + await tester.scrollUntilVisible(title, 300, scrollable: find.byType(Scrollable).first); + await tester.pumpAndSettle(); + await tester.enterText(title, 'Changed title'); + await tester.pumpAndSettle(); + final save = find.widgetWithText(FilledButton, 'Save'); + expect(save.hitTestable(), findsOneWidget); + } else { + final edit = find.byTooltip('Edit book'); + await tester.ensureVisible(edit); + await tester.pumpAndSettle(); + expect(edit.hitTestable(), findsOneWidget); + } + }); + } + testWidgets('file import can browse and remove at $width and ${scale}x', (tester) async { + tester.view.physicalSize = Size(width, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + var browsed = false; + var removed = false; + final file = SelectedBookFile(name: 'An accessible book.epub', bytes: Uint8List.fromList([1])); + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light, + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith(textScaler: TextScaler.linear(scale)), + child: child!, + ), + home: Scaffold( + body: BookImportSelectingSection( + files: [file], + readableFiles: [file], + isPicking: false, + onBrowse: () => browsed = true, + onDroppedFiles: (_, {feedback}) {}, + onRemoveFile: (_) => removed = true, + onClearSelection: () {}, + onStartImport: () {}, + onClose: () {}, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.scrollUntilVisible( + find.text('Add files'), + 200, + scrollable: find.descendant(of: find.byType(ListView), matching: find.byType(Scrollable)), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Add files')); + expect(browsed, isTrue); + await tester.scrollUntilVisible( + find.byTooltip('Remove ${file.name}'), + 200, + scrollable: find.descendant(of: find.byType(ListView), matching: find.byType(Scrollable)), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byTooltip('Remove ${file.name}')); + expect(removed, isTrue); + }); + } + } +} diff --git a/app/test/widgets/app_motion_control_test.dart b/app/test/widgets/app_motion_control_test.dart index bab5049a..458a6413 100644 --- a/app/test/widgets/app_motion_control_test.dart +++ b/app/test/widgets/app_motion_control_test.dart @@ -1,7 +1,12 @@ +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:papyrus/themes/app_theme.dart'; +import 'package:papyrus/themes/app_motion.dart'; import 'package:papyrus/widgets/settings/settings_row.dart'; import 'package:papyrus/widgets/shared/app_motion_control.dart'; @@ -9,6 +14,69 @@ void main() { final themes = {'light': AppTheme.light, 'dark': AppTheme.dark, 'eink': AppTheme.eink}; ToggleableStateMixin toggleState(WidgetTester tester) => tester.allStates.whereType().single; + testWidgets('changing reduced motion keeps settings focus visible and keyboard activation working', (tester) async { + final reducedMotion = ValueNotifier(false); + final key = GlobalKey(); + addTearDown(reducedMotion.dispose); + + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.dark, + home: ValueListenableBuilder( + valueListenable: reducedMotion, + builder: (context, reduced, _) => AppMotionScope( + reduceAnimations: reduced, + child: Scaffold( + body: RepaintBoundary( + key: key, + child: Material( + child: SettingsToggleRow( + label: 'Reduce animations', + value: reduced, + onChanged: (value) => reducedMotion.value = value, + ), + ), + ), + ), + ), + ), + ), + ); + + Future pixels() 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(); + }))!; + } + + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + final focus = Focus.of(tester.element(find.text('Reduce animations'))); + expect(focus.hasPrimaryFocus, isTrue); + + for (final expected in [true, false, true]) { + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pumpAndSettle(); + expect(reducedMotion.value, expected); + expect(focus.hasPrimaryFocus, isTrue); + final focused = await pixels(); + focus.unfocus(); + await tester.pumpAndSettle(); + expect( + listEquals(focused, await pixels()), + isFalse, + reason: 'Focus must remain visible with reduced motion $expected', + ); + focus.requestFocus(); + await tester.pumpAndSettle(); + } + }); + testWidgets('chip motion refreshes across theme switches and retains keyboard focus', (tester) async { final theme = ValueNotifier(AppTheme.light); final selected = ValueNotifier(false); diff --git a/app/test/widgets/library/book_card_test.dart b/app/test/widgets/library/book_card_test.dart index ac006278..045145f8 100644 --- a/app/test/widgets/library/book_card_test.dart +++ b/app/test/widgets/library/book_card_test.dart @@ -1,6 +1,7 @@ import 'dart:ui' show PointerDeviceKind, SemanticsAction, Tristate; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:papyrus/acquisition/acquisition_models.dart'; import 'package:papyrus/models/book.dart'; @@ -386,13 +387,40 @@ void main() { } }); - testWidgets('shows a nonfavorite heart only while the mouse hovers over the book', (tester) async { + testWidgets('keyboard focus reveals the original hover controls and activates favorite', (tester) async { + final pointer = await tester.createGesture(kind: PointerDeviceKind.mouse); + await pointer.addPointer(location: const Offset(790, 590)); + addTearDown(pointer.removePointer); + var favoriteTaps = 0; + await tester.pumpWidget( + buildCard( + screenSize: const Size(1200, 800), + onTap: () {}, + onToggleFavorite: (_) => favoriteTaps++, + onEnterSelectionMode: () {}, + ), + ); + final favorite = find.byIcon(Icons.favorite_border); + final visibility = find.ancestor(of: favorite, matching: find.byType(AnimatedOpacity)); + expect(tester.widget(visibility).opacity, 0); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + expect(tester.widget(visibility).opacity, 1); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pumpAndSettle(); + expect(favoriteTaps, 1); + }); + + testWidgets('keeps the favorite action accessible while visually hidden without mouse hover', (tester) async { + final semantics = tester.ensureSemantics(); var favoriteTaps = 0; final pointer = await tester.createGesture(kind: PointerDeviceKind.mouse); await pointer.addPointer(location: const Offset(790, 590)); addTearDown(pointer.removePointer); await tester.pumpWidget(buildCard(onToggleFavorite: (_) => favoriteTaps++)); - expect(find.byIcon(Icons.favorite_border), findsNothing); + expect(find.semantics.byLabel('Add to favorites'), findsOne); + expect(find.byIcon(Icons.favorite_border), findsOneWidget); await pointer.moveTo(tester.getCenter(find.byType(BookCard))); await tester.pumpAndSettle(); expect(find.byIcon(Icons.favorite_border), findsOneWidget); @@ -400,7 +428,8 @@ void main() { expect(favoriteTaps, 1); await pointer.moveTo(const Offset(790, 590)); await tester.pumpAndSettle(); - expect(find.byIcon(Icons.favorite_border), findsNothing); + expect(find.byIcon(Icons.favorite_border), findsOneWidget); + semantics.dispose(); }); testWidgets('keeps a favorited heart visible after the mouse leaves', (tester) async { diff --git a/app/test/widgets/library/library_filter_chips_test.dart b/app/test/widgets/library/library_filter_chips_test.dart index e57ad1c9..18d769da 100644 --- a/app/test/widgets/library/library_filter_chips_test.dart +++ b/app/test/widgets/library/library_filter_chips_test.dart @@ -40,6 +40,44 @@ void main() { await tester.pumpWidget(buildChips()); } + testWidgets('filter launcher has one button role and scales its label', (tester) async { + final semantics = tester.ensureSemantics(); + try { + await tester.binding.setSurfaceSize(const Size(320, 720)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget( + createTestApp( + libraryProvider: libraryProvider, + child: const MediaQuery( + data: MediaQueryData(size: Size(320, 720), textScaler: TextScaler.linear(4)), + child: LibraryFilterChips(), + ), + ), + ); + await tester.pumpAndSettle(); + final launcher = find.bySemanticsLabel('View mode: Grid'); + expect(launcher, findsOneWidget); + expect( + tester.getSemantics(launcher), + isSemantics( + isButton: true, + hasCheckedState: false, + hasTapAction: true, + isFocusable: true, + ), + ); + final label = find.text('Grid'); + expect(MediaQuery.textScalerOf(tester.element(label)).scale(14), 56); + expect(tester.getSize(find.byType(LibraryFilterChips)).height, greaterThan(48)); + await tester.tap(label); + await tester.pumpAndSettle(); + expect(find.text('View mode'), findsOneWidget); + expect(tester.takeException(), isNull); + } finally { + semantics.dispose(); + } + }); + testWidgets('renders the current filter, sort, and view categories', (tester) async { await pumpChips(tester); expect(find.text('Status'), findsOneWidget); diff --git a/app/test/widgets/profile/accessibility_settings_section_test.dart b/app/test/widgets/profile/accessibility_settings_section_test.dart new file mode 100644 index 00000000..a247ec17 --- /dev/null +++ b/app/test/widgets/profile/accessibility_settings_section_test.dart @@ -0,0 +1,122 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/providers/preferences_provider.dart'; +import 'package:papyrus/themes/app_accessibility.dart'; +import 'package:papyrus/themes/app_theme.dart'; +import 'package:papyrus/widgets/profile/accessibility_settings_section.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() async { + final loader = FontLoader(AppTheme.dyslexiaFontFamily); + loader.addFont(rootBundle.load('fonts/OpenDyslexic/OpenDyslexic-Regular.otf')); + await loader.load(); + }); + + test('bundles regular, bold, italic and bold italic fonts with their license', () async { + final manifest = jsonDecode(await rootBundle.loadString('FontManifest.json')) as List; + final family = manifest.cast>().singleWhere( + (entry) => entry['family'] == AppTheme.dyslexiaFontFamily, + ); + final fonts = (family['fonts'] as List).cast>(); + expect(fonts.map((font) => (font['weight'] ?? 400, font['style'] ?? 'normal')).toSet(), { + (400, 'normal'), + (700, 'normal'), + (400, 'italic'), + (700, 'italic'), + }); + + for (final font in fonts) { + final data = await rootBundle.load(font['asset'] as String); + expect(ascii.decode(data.buffer.asUint8List(data.offsetInBytes, 4)), 'OTTO'); + } + + expect(await rootBundle.loadString('fonts/OpenDyslexic/OFL.txt'), contains('SIL OPEN FONT LICENSE')); + }); + + for (final (mode, theme) in [ + ('light', AppTheme.light), + ('dark', AppTheme.dark), + ('eink', AppTheme.eink), + ('high contrast light', AppTheme.highContrastLight), + ('high contrast dark', AppTheme.highContrastDark), + ]) { + for (final width in [320.0, 390.0, 1280.0]) { + testWidgets('$mode at $width changes preferences with large text and persists them', (tester) async { + tester.view.physicalSize = Size(width, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + SharedPreferences.setMockInitialValues({'theme_mode': mode}); + final storage = await SharedPreferences.getInstance(); + final preferences = PreferencesProvider(storage); + addTearDown(preferences.dispose); + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: preferences, + child: Consumer( + builder: (context, preferences, child) => MaterialApp( + theme: AppTheme.withTypography(theme, dyslexiaFont: preferences.dyslexiaFont), + themeAnimationDuration: Duration.zero, + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith(textScaler: const TextScaler.linear(2)), + child: AppAccessibilityScope(preferences: preferences, child: child!), + ), + home: Scaffold( + body: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: AccessibilitySettingsSection(isDesktop: width >= 1024), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Interface font')); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.text('OpenDyslexic').last); + await tester.tap(find.text('OpenDyslexic').last); + await tester.pumpAndSettle(); + expect(preferences.dyslexiaFont, isTrue); + final preview = find.text('The quick brown fox jumps over the lazy dog.'); + final paragraph = tester.renderObject( + find.descendant(of: preview, matching: find.byType(RichText)), + ); + expect(paragraph.text.style?.fontFamily, AppTheme.dyslexiaFontFamily); + + await tester.ensureVisible(find.text('Interface text enlargement')); + await tester.tap(find.text('Interface text enlargement')); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.text('+100%')); + await tester.tap(find.text('+100%')); + await tester.pumpAndSettle(); + expect(preferences.interfaceTextEnlargement, 2); + expect(MediaQuery.textScalerOf(tester.element(preview)).scale(16), 64); + await tester.ensureVisible(find.text('Bold interface text')); + await tester.tap(find.text('Bold interface text')); + await tester.pumpAndSettle(); + expect(preferences.boldInterfaceText, isTrue); + await tester.ensureVisible(find.text('Increase contrast')); + await tester.tap(find.text('Increase contrast')); + await tester.pumpAndSettle(); + expect(preferences.increaseContrast, mode != 'eink'); + await storage.reload(); + final restored = PreferencesProvider(storage); + addTearDown(restored.dispose); + expect(restored.interfaceTextEnlargement, 2); + expect(restored.boldInterfaceText, isTrue); + expect(restored.dyslexiaFont, isTrue); + expect(restored.defaultFont, 'Georgia'); + }); + } + } +} diff --git a/app/test/widgets/shell/desktop_sidebar_test.dart b/app/test/widgets/shell/desktop_sidebar_test.dart new file mode 100644 index 00000000..c044fd10 --- /dev/null +++ b/app/test/widgets/shell/desktop_sidebar_test.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/providers/sidebar_provider.dart'; +import 'package:papyrus/widgets/shell/desktop_sidebar.dart'; +import 'package:provider/provider.dart'; + +void main() { + testWidgets('logo stays centered in the collapsed rail without moving', (tester) async { + await tester.pumpWidget( + ChangeNotifierProvider( + create: (_) => SidebarProvider(), + child: MaterialApp( + home: Scaffold( + body: Row( + children: [DesktopSidebar(items: const [], currentPath: '/', onNavigate: (_) {})], + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + final logo = find.byType(SvgPicture); + final expandedBounds = tester.getRect(logo); + await tester.tap(find.bySemanticsLabel('Collapse sidebar')); + await tester.pumpAndSettle(); + final collapsedBounds = tester.getRect(logo); + expect(collapsedBounds, expandedBounds); + expect(collapsedBounds.center.dx, tester.getRect(find.byType(DesktopSidebar)).center.dx); + await tester.tap(find.bySemanticsLabel('Expand sidebar')); + await tester.pumpAndSettle(); + expect(tester.getRect(logo), expandedBounds); + expect(tester.takeException(), isNull); + }); +} diff --git a/app/test/widgets/shell/navigation_counts_test.dart b/app/test/widgets/shell/navigation_counts_test.dart index 555b9f3d..12e1eac1 100644 --- a/app/test/widgets/shell/navigation_counts_test.dart +++ b/app/test/widgets/shell/navigation_counts_test.dart @@ -8,6 +8,7 @@ import 'package:papyrus/opds/opds_models.dart'; import 'package:papyrus/providers/sidebar_provider.dart'; import 'package:papyrus/widgets/shell/adaptive_app_shell.dart'; import 'package:papyrus/widgets/shell/nav_item_count.dart'; +import 'package:papyrus/widgets/profile/profile_picker_sheet.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -32,13 +33,31 @@ void main() { routes: [ ShellRoute( builder: (_, _, child) => AdaptiveAppShell(child: child), - routes: [GoRoute(path: '/library/catalogs', builder: (_, _) => const Text('Page content'))], + routes: [ + GoRoute( + path: '/library/catalogs', + builder: (context, _) => Center( + child: TextButton( + onPressed: () => showProfilePickerSheet( + context, + title: 'Interface font', + items: const [('System default', 'system'), ('OpenDyslexic', 'dyslexia')], + selected: 'system', + onSelected: (_) {}, + ), + child: const Text('Choose font'), + ), + ), + ), + ], ), ], ); addTearDown(router.dispose); + final semantics = tester.ensureSemantics(); + await tester.pumpWidget( MultiProvider( providers: [ @@ -52,6 +71,27 @@ void main() { await tester.pumpAndSettle(); + expect(find.semantics.byLabel(RegExp('Settings')), findsOneWidget); + final launcher = find.text('Choose font'); + final launcherFocus = Focus.of(tester.element(launcher)); + launcherFocus.requestFocus(); + await tester.pump(); + await tester.tap(launcher); + await tester.pumpAndSettle(); + expect(find.semantics.byLabel(RegExp('Settings')), findsNothing); + expect( + tester.getSemantics(find.bySemanticsLabel('System default')), + isSemantics( + hasCheckedState: true, + isChecked: true, + isInMutuallyExclusiveGroup: true, + ), + ); + await tester.tap(find.text('OpenDyslexic')); + await tester.pumpAndSettle(); + expect(find.semantics.byLabel(RegExp('Settings')), findsOneWidget); + expect(launcherFocus.hasFocus, isTrue); + if (width < 840) { tester.state(find.byType(Scaffold).first).openDrawer(); await tester.pumpAndSettle(); @@ -81,6 +121,7 @@ void main() { expect(find.byType(NavItemCount), findsNothing); expect(find.text('0'), findsNothing); expect(tester.takeException(), isNull); + semantics.dispose(); }); } }