diff --git a/app/integration_test/app_updates_test.dart b/app/integration_test/app_updates_test.dart new file mode 100644 index 00000000..665f7b08 --- /dev/null +++ b/app/integration_test/app_updates_test.dart @@ -0,0 +1,131 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:papyrus/themes/app_theme.dart'; +import 'package:papyrus/updates/update_controller.dart'; +import 'package:papyrus/updates/update_release.dart'; +import 'package:papyrus/widgets/profile/about_settings_section.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('update settings navigation and states on mobile', (tester) async { + for (final (name, theme, scale) in [ + ('light', AppTheme.light, 1.0), + ('dark', AppTheme.dark, 1.0), + ('large-text', AppTheme.dark, 2.0), + ('eink', AppTheme.eink, 1.0), + ]) { + SharedPreferences.setMockInitialValues({}); + var result = Completer>(); + final installation = Completer(); + final updates = UpdateController( + preferences: await SharedPreferences.getInstance(), + appInfo: () async => + PackageInfo(appName: 'Papyrus', packageName: 'papyrus', version: '0.3.0', buildNumber: '7'), + channelProvider: () async => UpdateChannel.windows, + manifest: () => result.future, + installer: (_, progress) async { + progress(0.5); + return installation.future; + }, + ); + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: updates, + child: MaterialApp( + key: ValueKey(name), + debugShowCheckedModeBanner: false, + theme: theme, + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith(textScaler: TextScaler.linear(scale)), + child: child!, + ), + home: const Scaffold(body: SafeArea(child: AboutSettingsSection())), + ), + ), + ); + + await tester.pumpAndSettle(); + await tester.tap(find.text('App updates')); + await tester.pumpAndSettle(); + expect(find.byType(AlertDialog), findsNothing); + expect(find.text('Not checked yet'), findsOneWidget); + await binding.takeScreenshot('updates-$name-unchecked'); + await tester.tap(find.text('Check automatically')); + await tester.pumpAndSettle(); + expect(updates.automatic, isFalse); + + if (name == 'dark') { + await tester.tap(find.text('Check for updates')); + await tester.pump(const Duration(milliseconds: 300)); + await binding.takeScreenshot('updates-dark-checking'); + result.completeError(StateError('Offline')); + await tester.pumpAndSettle(); + expect(find.text('Couldn’t check for updates'), findsOneWidget); + await tester.pump(kThemeChangeDuration); + await binding.takeScreenshot('updates-dark-error'); + final retryButton = find.byKey(const ValueKey('check-updates')); + final retryBounds = tester.getRect(retryButton); + final toggleBounds = tester.getRect(find.byType(Switch)); + result = Completer>(); + await tester.tap(find.text('Retry')); + await tester.pump(const Duration(milliseconds: 100)); + expect(find.text('Retry'), findsOneWidget); + expect(find.text('Please try again.'), findsNothing); + expect(find.byType(LinearProgressIndicator), findsNothing); + expect(tester.getRect(retryButton), retryBounds); + expect(tester.getRect(find.byType(Switch)), toggleBounds); + result.completeError(StateError('Still offline')); + await tester.pumpAndSettle(); + await tester.pump(const Duration(milliseconds: 350)); + expect(find.byType(LinearProgressIndicator), findsNothing); + expect(tester.getRect(retryButton), retryBounds); + await binding.takeScreenshot('updates-dark-quick-retry'); + result = Completer>(); + await tester.tap(find.text('Retry')); + await tester.pump(); + result.complete(_release(7)); + await tester.pumpAndSettle(); + expect(find.text('You’re up to date'), findsOneWidget); + await binding.takeScreenshot('updates-dark-current'); + result = Completer>(); + await tester.tap(find.text('Check for updates')); + await tester.pump(); + result.complete(_release(8)); + await tester.pumpAndSettle(); + await binding.takeScreenshot('updates-dark-available'); + await tester.tap(find.text('Update')); + await tester.pump(const Duration(milliseconds: 300)); + await binding.takeScreenshot('updates-dark-progress'); + installation.complete('Close Papyrus normally to install the update.'); + await tester.pumpAndSettle(); + await binding.takeScreenshot('updates-dark-staged'); + } + + expect(tester.takeException(), isNull); + + await tester.tap(find.byType(BackButton)); + await tester.pumpAndSettle(); + expect(find.text('Version'), findsOneWidget); + expect(updates.viewingSettings, isFalse); + await tester.pumpWidget(const SizedBox.shrink()); + updates.dispose(); + } + }); +} + +Map _release(int build) => { + 'tag': 'v0.3.0+$build', + 'version': '0.3.0', + 'build_number': build, + 'assets': { + 'installer': {'name': 'papyrus-v0.3.0+$build-windows-x64-setup.exe', 'sha256': 'a' * 64}, + }, +}; diff --git a/app/integration_test/mobile_settings_test.dart b/app/integration_test/mobile_settings_test.dart new file mode 100644 index 00000000..a9583244 --- /dev/null +++ b/app/integration_test/mobile_settings_test.dart @@ -0,0 +1,117 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:papyrus/main.dart' as app; +import 'package:papyrus/pages/profile_page.dart'; +import 'package:papyrus/pages/licenses_page.dart'; +import 'package:papyrus/providers/preferences_provider.dart'; +import 'package:papyrus/widgets/settings/settings_row.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('mobile settings groups, saved preferences, and native Back', (tester) async { + SharedPreferences.setMockInitialValues({'offline_mode': true, 'theme_mode': 'light'}); + await app.main(); + await tester.pumpAndSettle(); + final router = GoRouter.of(tester.element(find.byType(Scaffold).first)); + router.go('/profile'); + await tester.pumpAndSettle(); + final preferences = tester.element(find.byType(ProfilePage)).read(); + await binding.takeScreenshot('settings-index-light'); + + for (final (title, control) in [ + ('Account', 'Display name'), + ('Appearance', 'Theme'), + ('Storage', 'Clear local library'), + ('Accessibility', 'Reduce animations'), + ('About', 'Version'), + ]) { + await tester.ensureVisible(find.widgetWithText(ListTile, title)); + await tester.tap(find.widgetWithText(ListTile, title)); + await tester.pumpAndSettle(); + expect(find.text(control), findsOneWidget); + final route = ModalRoute.of(tester.element(find.byType(ProfilePage).last))! as TransitionRoute; + expect(route.transitionDuration, Duration.zero); + expect(route.reverseTransitionDuration, Duration.zero); + await binding.takeScreenshot('settings-${title.toLowerCase()}-light'); + + if (title == 'About') { + await tester.tap(find.widgetWithText(SettingsRow, 'Licenses')); + + for (var frame = 0; frame < 300; frame++) { + await tester.pump(const Duration(milliseconds: 16)); + + if (find.byType(LicensesPage).evaluate().isNotEmpty) { + break; + } + } + + expect(find.byType(LicensesPage), findsOneWidget); + expect(find.byType(ListTile), findsWidgets); + expect(find.text('Loading licenses…'), findsNothing); + await binding.takeScreenshot('settings-licenses-first-frame'); + await tester.tap(find.byType(BackButton)); + await tester.pumpAndSettle(); + expect(find.text('Version'), findsOneWidget); + } + + await tester.tap(find.byType(BackButton)); + await tester.pumpAndSettle(); + expect(router.routeInformationProvider.value.uri.path, '/profile'); + } + + await tester.tap(find.widgetWithText(ListTile, 'Appearance')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(SettingsRow, 'Theme')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Dark')); + await tester.pumpAndSettle(); + expect(preferences.themeModePref, 'dark'); + await binding.takeScreenshot('settings-appearance-dark'); + + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + + expect(router.routeInformationProvider.value.uri.path, '/profile'); + expect(find.text('Dark theme, page layout'), findsOneWidget); + tester.state(find.byType(Scrollable).first).position.jumpTo(0); + await tester.pumpAndSettle(); + await binding.takeScreenshot('settings-index-dark'); + preferences.interfaceTextEnlargement = 2; + await tester.pumpAndSettle(); + await binding.takeScreenshot('settings-index-large-text'); + await tester.ensureVisible(find.widgetWithText(ListTile, 'Accessibility')); + await tester.tap(find.widgetWithText(ListTile, 'Accessibility')); + await tester.pumpAndSettle(); + await binding.takeScreenshot('settings-accessibility-large-text'); + expect(tester.takeException(), isNull); + await tester.tap(find.byType(BackButton)); + await tester.pumpAndSettle(); + preferences.interfaceTextEnlargement = 1; + preferences.reduceAnimations = true; + await tester.pumpAndSettle(); + router.go('/profile/appearance'); + await tester.pumpAndSettle(); + final route = ModalRoute.of(tester.element(find.byType(ProfilePage).last))! as TransitionRoute; + expect(route.transitionDuration, Duration.zero); + await tester.tap(find.byType(BackButton)); + await tester.pumpAndSettle(); + expect(router.routeInformationProvider.value.uri.path, '/profile'); + preferences.reduceAnimations = false; + preferences.themeModePref = 'eink'; + await tester.pumpAndSettle(); + tester.state(find.byType(Scrollable).first).position.jumpTo(0); + await tester.pumpAndSettle(); + await binding.takeScreenshot('settings-index-eink'); + await tester.tap(find.widgetWithText(ListTile, 'Appearance')); + await tester.pump(); + expect(find.text('Theme'), findsOneWidget); + await tester.pumpAndSettle(); + await binding.takeScreenshot('settings-appearance-eink'); + expect(tester.takeException(), isNull); + }); +} diff --git a/app/lib/config/app_router.dart b/app/lib/config/app_router.dart index 5faba8c3..e23e5167 100644 --- a/app/lib/config/app_router.dart +++ b/app/lib/config/app_router.dart @@ -20,6 +20,7 @@ import 'package:papyrus/pages/profile_page.dart'; import 'package:papyrus/pages/licenses_page.dart'; import 'package:papyrus/pages/support_page.dart'; import 'package:papyrus/services/support_api_client.dart'; +import 'package:papyrus/services/license_packages.dart'; import 'package:papyrus/pages/register_page.dart'; import 'package:papyrus/pages/reader_page.dart'; import 'package:papyrus/pages/search_options_page.dart'; @@ -289,9 +290,20 @@ class AppRouter { path: '/profile', pageBuilder: (context, state) => NoTransitionPage(key: state.pageKey, child: const ProfilePage()), routes: [ + for (final section in ProfileSection.values) + GoRoute( + path: section.path, + pageBuilder: (context, state) => NoTransitionPage( + key: state.pageKey, + child: ProfilePage(section: section), + ), + ), GoRoute( path: 'licenses', - pageBuilder: (context, state) => NoTransitionPage(key: state.pageKey, child: const LicensesPage()), + pageBuilder: (context, state) => NoTransitionPage( + key: state.pageKey, + child: LicensesPage(initialLicenses: state.extra as LicensePackages?), + ), ), GoRoute( path: 'support', diff --git a/app/lib/pages/app_updates_page.dart b/app/lib/pages/app_updates_page.dart new file mode 100644 index 00000000..08c41ea0 --- /dev/null +++ b/app/lib/pages/app_updates_page.dart @@ -0,0 +1,315 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:papyrus/services/about_links.dart'; +import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/updates/update_controller.dart'; +import 'package:papyrus/updates/update_release.dart'; +import 'package:papyrus/widgets/settings/settings_row.dart'; +import 'package:papyrus/widgets/library/library_page_header.dart'; +import 'package:papyrus/widgets/shared/app_page_bar.dart'; +import 'package:papyrus/widgets/shared/app_progress_indicator.dart'; + +class AppUpdatesPage extends StatefulWidget { + const AppUpdatesPage({super.key, required this.controller}); + + final UpdateController controller; + + @override + State createState() => _AppUpdatesPageState(); +} + +typedef _UpdatePresentation = ({ + String title, + String? detail, + String action, + DateTime? lastChecked, + bool externalLink, +}); + +class _AppUpdatesPageState extends State { + static const _feedbackDelay = Duration(milliseconds: 300); + static const _checkFailedTitle = 'Couldn’t check for updates'; + + late final Future _installedVersion = widget.controller.appInfo(); + late _UpdatePresentation _presentation; + Timer? _feedbackTimer; + bool _showCheckFeedback = false; + + @override + void initState() { + super.initState(); + _presentation = _currentPresentation(); + widget.controller.addListener(_handleUpdate); + + if (widget.controller.checking) { + _scheduleFeedback(); + } + } + + @override + void dispose() { + widget.controller.removeListener(_handleUpdate); + _feedbackTimer?.cancel(); + super.dispose(); + } + + void _scheduleFeedback() { + _feedbackTimer ??= Timer(_feedbackDelay, () { + if (mounted && widget.controller.checking) { + setState(() => _showCheckFeedback = true); + } + }); + } + + void _handleUpdate() { + // Keep the previous result's geometry until a new check result arrives. + if (widget.controller.checking) { + _scheduleFeedback(); + } else { + _feedbackTimer?.cancel(); + _feedbackTimer = null; + _showCheckFeedback = false; + _presentation = _currentPresentation(); + } + + setState(() {}); + } + + _UpdatePresentation _currentPresentation() { + final controller = widget.controller; + + return ( + title: _statusTitle(controller), + detail: _statusDetail(controller), + action: _actionLabel(controller), + lastChecked: controller.lastChecked, + externalLink: controller.external && !controller.available && !controller.busy, + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final controller = widget.controller; + final lastChecked = _presentation.lastChecked; + final compact = MediaQuery.sizeOf(context).width < Breakpoints.desktopSmall; + + final content = SingleChildScrollView( + padding: EdgeInsets.all(compact ? Spacing.md : Spacing.lg), + child: Align( + alignment: AlignmentDirectional.topStart, + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: compact ? 600 : 480), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('Installed version', style: theme.textTheme.bodyMedium), + const SizedBox(height: Spacing.xs), + FutureBuilder( + future: _installedVersion, + builder: (context, snapshot) => Text( + switch (snapshot) { + AsyncSnapshot(data: final PackageInfo info) => AboutLinks.versionLabel(info), + AsyncSnapshot(hasError: true) => 'Unavailable', + _ => 'Loading…', + }, + style: theme.textTheme.titleLarge, + ), + ), + const SizedBox(height: Spacing.md), + Semantics( + liveRegion: true, + // Reserve wrapped error text so a failed check never moves the action. + child: IndexedStack( + index: 0, + alignment: AlignmentDirectional.topStart, + children: [ + Text(_presentation.title, style: theme.textTheme.titleLarge), + Text(_checkFailedTitle, style: theme.textTheme.titleLarge), + ], + ), + ), + if (lastChecked != null) ...[ + const SizedBox(height: Spacing.sm), + Text( + _lastCheckedLabel(context, lastChecked), + style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + ], + if (_presentation.detail case final String detail) ...[ + const SizedBox(height: Spacing.sm), + Text(detail, style: theme.textTheme.bodyMedium), + ], + Padding( + padding: const EdgeInsets.symmetric(vertical: Spacing.sm), + child: SizedBox( + height: Spacing.xs, + child: controller.busy && (!controller.checking || _showCheckFeedback) + ? AppLinearProgressIndicator( + value: controller.progress, + semanticsLabel: controller.checking ? 'Checking for updates' : 'Update progress', + ) + : null, + ), + ), + if (!controller.staged) + Align( + alignment: AlignmentDirectional.centerStart, + child: FilledButton( + key: const ValueKey('check-updates'), + onPressed: controller.busy ? null : () => _performAction(context, controller), + style: controller.checking + ? FilledButton.styleFrom( + disabledBackgroundColor: theme.colorScheme.primary, + disabledForegroundColor: theme.colorScheme.onPrimary, + ) + : null, + child: IndexedStack( + index: controller.checking && _showCheckFeedback ? 1 : 0, + alignment: Alignment.center, + children: [Text(_presentation.action), const Text('Checking…')], + ), + ), + ), + if (_presentation.externalLink) + Align( + alignment: AlignmentDirectional.centerStart, + child: TextButton( + onPressed: () => _openExternalUpdate(context, controller), + child: Text(controller.channel == UpdateChannel.apt ? 'Repository setup' : 'Downloads'), + ), + ), + const SizedBox(height: Spacing.xl), + const Divider(height: 1), + const SizedBox(height: Spacing.md), + SettingsToggleRow( + label: 'Check automatically', + value: controller.automatic, + onChanged: controller.setAutomatic, + padding: const EdgeInsets.symmetric(vertical: Spacing.xs), + ), + Text( + 'Check for new versions in the background.', + style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + ], + ), + ), + ), + ); + + return Scaffold( + appBar: compact + ? AppPageBar(context: context, title: const Text('App updates'), leading: const BackButton()) + : null, + body: SafeArea( + child: compact + ? content + : Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const LibraryPageHeader(title: 'App updates', leading: BackButton()), + Expanded(child: content), + ], + ), + ), + ); + } + + String _lastCheckedLabel(BuildContext context, DateTime checked) { + final local = checked.toLocal(); + final localizations = MaterialLocalizations.of(context); + final date = localizations.formatMediumDate(local); + final time = localizations.formatTimeOfDay( + TimeOfDay.fromDateTime(local), + alwaysUse24HourFormat: MediaQuery.alwaysUse24HourFormatOf(context), + ); + + return 'Last checked $date at $time'; + } + + String _statusTitle(UpdateController controller) => switch (controller.status) { + UpdateStatus.unchecked => controller.lastChecked == null ? 'Not checked yet' : 'Ready to check for updates', + UpdateStatus.checking => 'Checking for updates…', + UpdateStatus.upToDate => 'You’re up to date', + UpdateStatus.available => + controller.release == null + ? 'Update available' + : 'Version ${controller.release!.version} (${controller.release!.buildNumber}) is available', + UpdateStatus.installing => 'Updating Papyrus', + UpdateStatus.staged => 'Ready to finish updating', + UpdateStatus.handedOff => 'Update started', + UpdateStatus.checkFailed => _checkFailedTitle, + UpdateStatus.installFailed => 'Couldn’t finish the update', + }; + + String? _statusDetail(UpdateController controller) { + if (controller.status == UpdateStatus.checkFailed || controller.status == UpdateStatus.installFailed) { + return null; + } + + if (controller.status == UpdateStatus.installing || + controller.status == UpdateStatus.staged || + controller.status == UpdateStatus.handedOff) { + return controller.message; + } + + if (controller.checking || (!controller.available && !controller.external)) { + return null; + } + + return switch (controller.channel) { + UpdateChannel.apt => + 'Install updates through your system’s software updater. Set up the Papyrus repository if needed.', + UpdateChannel.manual => 'Install updates by downloading the latest Papyrus package.', + UpdateChannel.windows => 'The update will install after you close Papyrus normally.', + UpdateChannel.appImage => 'The update takes effect when you close and reopen Papyrus.', + UpdateChannel.web => 'Reload when you have finished reading to use the latest version.', + _ => 'Your device will guide you through installation.', + }; + } + + String _actionLabel(UpdateController controller) { + if (controller.checking) return 'Checking…'; + if (controller.busy) return 'Updating…'; + if (controller.status == UpdateStatus.checkFailed || controller.status == UpdateStatus.installFailed) { + return 'Retry'; + } + + if (controller.available && controller.status != UpdateStatus.handedOff) { + return switch (controller.channel) { + UpdateChannel.web => 'Reload', + UpdateChannel.apt => 'Repository setup', + UpdateChannel.manual => 'Downloads', + _ => 'Update', + }; + } + + return 'Check for updates'; + } + + void _performAction(BuildContext context, UpdateController controller) { + if (!controller.available || controller.status == UpdateStatus.handedOff) { + controller.check(); + return; + } + + switch (controller.channel) { + case UpdateChannel.apt: + case UpdateChannel.manual: + _openExternalUpdate(context, controller); + default: + controller.install(); + } + } + + void _openExternalUpdate(BuildContext context, UpdateController controller) { + final uri = controller.channel == UpdateChannel.apt + ? Uri.https('github.com', '/PapyrusReader/client/blob/master/docs/INSTALL.md') + : AboutLinks.releases; + + AboutLinks.open(context, uri); + } +} diff --git a/app/lib/pages/licenses_page.dart b/app/lib/pages/licenses_page.dart index a0ada2fb..c446607b 100644 --- a/app/lib/pages/licenses_page.dart +++ b/app/lib/pages/licenses_page.dart @@ -1,31 +1,56 @@ +import 'dart:async'; + import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:papyrus/services/license_packages.dart'; import 'package:papyrus/themes/app_motion.dart'; import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/widgets/library/library_page_header.dart'; import 'package:papyrus/widgets/shared/app_motion_control.dart'; class LicensesPage extends StatefulWidget { - const LicensesPage({super.key}); + const LicensesPage({super.key, this.initialLicenses}); + + final LicensePackages? initialLicenses; @override State createState() => _LicensesPageState(); } class _LicensesPageState extends State { - late Future>> _licenses = _loadLicenses(); + late Future _licenses = widget.initialLicenses == null + ? _loadLicenses() + : SynchronousFuture(widget.initialLicenses!); + Timer? _loadingTimer; + bool _showLoading = false; String? _selectedPackage; - Future>> _loadLicenses() async { - final packages = >{}; + @override + void dispose() { + _loadingTimer?.cancel(); + super.dispose(); + } - await for (final entry in LicenseRegistry.licenses) { - for (final package in entry.packages) { - (packages[package] ??= []).add(entry); + Future _loadLicenses() async { + Future? minimumFeedback; + _showLoading = false; + _loadingTimer = Timer(const Duration(milliseconds: 300), () { + if (mounted) { + minimumFeedback = Future.delayed(const Duration(milliseconds: 500)); + setState(() => _showLoading = true); } - } + }); - return packages; + try { + return await loadLicensePackages(); + } finally { + _loadingTimer?.cancel(); + + // Fast loads stay immediate. Once visible, feedback remains readable. + if (minimumFeedback != null) { + await minimumFeedback; + } + } } @override @@ -39,7 +64,9 @@ class _LicensesPageState extends State { if (snapshot.hasError) { return Center( child: TextButton( - onPressed: () => setState(() => _licenses = _loadLicenses()), + onPressed: () => setState(() { + _licenses = _loadLicenses(); + }), child: const Text('Could not load licenses. Retry'), ), ); @@ -48,7 +75,7 @@ class _LicensesPageState extends State { final packages = snapshot.data; if (packages == null) { - return const Center(child: Text('Loading licenses…')); + return _showLoading ? const Center(child: Text('Loading licenses…')) : const SizedBox.shrink(); } if (packages.isEmpty) { @@ -63,7 +90,7 @@ class _LicensesPageState extends State { builder: (context, constraints) { final wide = constraints.maxWidth >= Breakpoints.tablet; - final list = ListView.builder( + final listView = ListView.builder( key: const PageStorageKey('license-packages'), padding: const EdgeInsets.all(Spacing.md), itemCount: names.length, @@ -103,6 +130,13 @@ class _LicensesPageState extends State { }, ); + // ListTile ink paints on its nearest Material, outside the scroll viewport. + final list = Material( + type: MaterialType.transparency, + clipBehavior: Clip.hardEdge, + child: listView, + ); + if (!wide) { return list; } @@ -141,7 +175,6 @@ class _LicenseScaffold extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ LibraryPageHeader(title: title, leading: const BackButton(), compact: compact), - if (compact) const Divider(height: 1), Expanded(child: child), ], ), diff --git a/app/lib/pages/profile_page.dart b/app/lib/pages/profile_page.dart index 793561cd..0008bff5 100644 --- a/app/lib/pages/profile_page.dart +++ b/app/lib/pages/profile_page.dart @@ -4,39 +4,42 @@ 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/providers/preferences_provider.dart'; import 'package:papyrus/themes/app_motion.dart'; import 'package:papyrus/themes/design_tokens.dart'; 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/notifications_settings_section.dart'; -import 'package:papyrus/widgets/profile/privacy_settings_section.dart'; -import 'package:papyrus/widgets/profile/profile_menu_item.dart'; import 'package:papyrus/widgets/profile/storage_sync_settings_section.dart'; import 'package:provider/provider.dart'; -enum _ProfileSection { - account, - appearance, - storageSync, - accessibility, - about, +enum ProfileSection { + account('account', 'Account', Icons.person_outline), + appearance('appearance', 'Appearance', Icons.palette_outlined), + storageSync('storage', 'Storage', Icons.cloud_outlined), + accessibility('accessibility', 'Accessibility', Icons.accessibility_outlined), + about('about', 'About', Icons.info_outline); + + const ProfileSection(this.path, this.title, this.icon); + + final String path; + final String title; + final IconData icon; } -/// User profile page with account settings, preferences, and app configuration. -/// -/// Mobile: AppBar with title, inline settings sections. -/// Desktop: Sidebar nav + content panel (similar to GitHub Settings). +/// Compact settings index with pushed detail pages, or a wide sidebar and panel. class ProfilePage extends StatefulWidget { - const ProfilePage({super.key}); + const ProfilePage({super.key, this.section}); + + final ProfileSection? section; @override State createState() => _ProfilePageState(); } class _ProfilePageState extends State { - _ProfileSection _selectedSection = _ProfileSection.account; + late ProfileSection _selectedSection = widget.section ?? ProfileSection.account; @override Widget build(BuildContext context) { @@ -47,33 +50,87 @@ class _ProfilePageState extends State { return _buildDesktopLayout(context); } + if (widget.section != null) { + return _buildMobileDetail(context); + } + return _buildMobileLayout(context); } Widget _buildMobileLayout(BuildContext context) { + final auth = context.watch(); + final preferences = context.watch(); + return Scaffold( appBar: AppPageBar(context: context, title: const Text('Settings')), body: SafeArea( child: ListView( - padding: const EdgeInsets.all(Spacing.md), + key: const PageStorageKey('settings-index'), + padding: const EdgeInsets.symmetric(vertical: Spacing.sm), children: [ - const AccountSettingsSection(isDesktop: false), - const AppearanceSettingsSection(isDesktop: false), - const NotificationsSettingsSection(isDesktop: false), - const StorageSyncSettingsSection(isDesktop: false), - StorageSyncSettingsSection.buildMobileAcquisitionSection(context), - const PrivacySettingsSection(isDesktop: false), - const AccessibilitySettingsSection(isDesktop: false), - const AboutSettingsSection(isDesktop: false), - const Divider(height: 1), - ProfileMenuItem( - icon: Icons.logout, - label: 'Log out', - isDestructive: true, - showChevron: false, + _buildMobileItem( + context, + ProfileSection.account, + subtitle: auth.isOfflineMode ? 'Offline profile' : auth.profileDisplayName, + ), + const Divider(height: Spacing.lg), + _buildMobileItem( + context, + ProfileSection.appearance, + subtitle: '${AppearanceSettingsSection.getThemeLabel(preferences.themeModePref)} theme, page layout', + ), + _buildMobileItem(context, ProfileSection.accessibility, subtitle: 'Text size, contrast, motion'), + const Divider(height: Spacing.lg), + _buildMobileItem( + context, + ProfileSection.storageSync, + subtitle: auth.isOfflineMode ? 'On-device library, backups' : 'Sync, servers, downloads', + ), + const ListTile( + enabled: false, + leading: Icon(Icons.notifications_outlined), + title: Text('Notifications'), + subtitle: Text('Not available yet'), + ), + const ListTile( + enabled: false, + leading: Icon(Icons.shield_outlined), + title: Text('Privacy'), + subtitle: Text('Not available yet'), + ), + const Divider(height: Spacing.lg), + _buildMobileItem(context, ProfileSection.about, subtitle: 'Version, support, licenses'), + const Divider(height: Spacing.lg), + ListTile( + leading: Icon(Icons.logout, color: Theme.of(context).colorScheme.error), + title: Text('Log out', style: TextStyle(color: Theme.of(context).colorScheme.error)), onTap: () => _showLogoutConfirmation(context), ), - const SizedBox(height: Spacing.lg), + ], + ), + ), + ); + } + + Widget _buildMobileItem(BuildContext context, ProfileSection section, {required String subtitle}) { + return ListTile( + leading: Icon(section.icon), + title: Text(section.title), + subtitle: Text(subtitle), + trailing: const Icon(Icons.chevron_right), + onTap: () => context.push('/profile/${section.path}'), + ); + } + + Widget _buildMobileDetail(BuildContext context) { + return Scaffold( + appBar: AppPageBar(context: context, title: Text(_selectedSection.title), leading: const BackButton()), + body: SafeArea( + child: ListView( + key: PageStorageKey('settings-${_selectedSection.path}'), + padding: const EdgeInsets.fromLTRB(Spacing.md, Spacing.sm, Spacing.md, Spacing.lg), + children: [ + _buildSectionContent(context, isDesktop: false), ], ), ), @@ -112,13 +169,13 @@ class _ProfilePageState extends State { context, icon: Icons.person_outline, label: 'Account', - section: _ProfileSection.account, + section: ProfileSection.account, ), _buildNavItem( context, icon: Icons.palette_outlined, label: 'Appearance', - section: _ProfileSection.appearance, + section: ProfileSection.appearance, ), _buildNavItem( context, @@ -130,7 +187,7 @@ class _ProfilePageState extends State { context, icon: Icons.cloud_outlined, label: 'Storage', - section: _ProfileSection.storageSync, + section: ProfileSection.storageSync, ), _buildNavItem( context, @@ -142,9 +199,9 @@ class _ProfilePageState extends State { context, icon: Icons.accessibility_outlined, label: 'Accessibility', - section: _ProfileSection.accessibility, + section: ProfileSection.accessibility, ), - _buildNavItem(context, icon: Icons.info_outline, label: 'About', section: _ProfileSection.about), + _buildNavItem(context, icon: Icons.info_outline, label: 'About', section: ProfileSection.about), const SizedBox(height: Spacing.sm), Divider(height: 1, color: colorScheme.outlineVariant), const SizedBox(height: Spacing.sm), @@ -169,7 +226,7 @@ class _ProfilePageState extends State { BuildContext context, { required IconData icon, required String label, - _ProfileSection? section, + ProfileSection? section, bool enabled = true, bool isDestructive = false, VoidCallback? onTap, @@ -232,7 +289,7 @@ class _ProfilePageState extends State { final textTheme = Theme.of(context).textTheme; return SingleChildScrollView( - padding: const EdgeInsets.all(Spacing.xl), + padding: const EdgeInsets.fromLTRB(Spacing.lg, Spacing.md, Spacing.lg, Spacing.lg), child: Align( alignment: Alignment.topLeft, child: ConstrainedBox( @@ -250,34 +307,16 @@ class _ProfilePageState extends State { ); } - String get _sectionTitle { - switch (_selectedSection) { - case _ProfileSection.account: - return 'Account'; - case _ProfileSection.appearance: - return 'Appearance'; - case _ProfileSection.storageSync: - return 'Storage'; - case _ProfileSection.accessibility: - return 'Accessibility'; - case _ProfileSection.about: - return 'About'; - } - } + String get _sectionTitle => _selectedSection.title; - Widget _buildSectionContent(BuildContext context) { - switch (_selectedSection) { - case _ProfileSection.account: - return const AccountSettingsSection(isDesktop: true); - case _ProfileSection.appearance: - return const AppearanceSettingsSection(isDesktop: true); - case _ProfileSection.storageSync: - return const StorageSyncSettingsSection(isDesktop: true); - case _ProfileSection.accessibility: - return const AccessibilitySettingsSection(isDesktop: true); - case _ProfileSection.about: - return const AboutSettingsSection(isDesktop: true); - } + Widget _buildSectionContent(BuildContext context, {bool isDesktop = true}) { + return switch (_selectedSection) { + ProfileSection.account => AccountSettingsSection(isDesktop: isDesktop, showHeader: false), + ProfileSection.appearance => AppearanceSettingsSection(isDesktop: isDesktop, showHeader: false), + ProfileSection.storageSync => StorageSyncSettingsSection(isDesktop: isDesktop, showHeader: false), + ProfileSection.accessibility => AccessibilitySettingsSection(isDesktop: isDesktop, showHeader: false), + ProfileSection.about => AboutSettingsSection(isDesktop: isDesktop, showHeader: false), + }; } Future _handleLogout(BuildContext context) async { diff --git a/app/lib/pages/support_page.dart b/app/lib/pages/support_page.dart index e00e410d..960109fa 100644 --- a/app/lib/pages/support_page.dart +++ b/app/lib/pages/support_page.dart @@ -1,5 +1,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:papyrus/providers/preferences_provider.dart'; import 'package:papyrus/services/about_links.dart'; import 'package:papyrus/services/app_info.dart'; import 'package:papyrus/services/support_api_client.dart'; @@ -146,6 +148,7 @@ class _SupportPageState extends State { final issue = _issue; final textTheme = Theme.of(context).textTheme; final compact = MediaQuery.sizeOf(context).width < Breakpoints.desktopSmall; + final centered = context.select((preferences) => preferences.pageAlignment == 'center'); return Scaffold( body: SafeArea( @@ -153,13 +156,17 @@ class _SupportPageState extends State { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ LibraryPageHeader(title: 'Support', leading: const BackButton(), compact: compact), - if (compact) const Divider(height: 1), Expanded( child: SingleChildScrollView( controller: _scrollController, - padding: const EdgeInsets.all(Spacing.lg), + padding: EdgeInsets.fromLTRB( + Spacing.lg, + compact ? Spacing.sm : Spacing.lg, + Spacing.lg, + Spacing.lg, + ), child: Align( - alignment: Alignment.topCenter, + alignment: centered ? Alignment.topCenter : AlignmentDirectional.topStart, child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 640), child: issue != null diff --git a/app/lib/powersync/storage_sync_controller.dart b/app/lib/powersync/storage_sync_controller.dart index d2a8a49b..691a05fd 100644 --- a/app/lib/powersync/storage_sync_controller.dart +++ b/app/lib/powersync/storage_sync_controller.dart @@ -84,6 +84,18 @@ class StorageSyncController { return syncSettings.fileStorageLabel(usedBytes: fileStorageUsedBytes); } + double? get fileStorageFraction { + final usage = isAuthenticated ? mediaStorageUsage : null; + final usedBytes = usage?.usedBytes ?? fileStorageUsedBytes; + final quotaBytes = usage?.quotaBytes ?? syncSettings.fileStorageQuotaBytes; + + if (quotaBytes == null || quotaBytes <= 0) { + return null; + } + + return (usedBytes / quotaBytes).clamp(0.0, 1.0); + } + String get statusLabel { if (isGuest) { return 'Guest local'; diff --git a/app/lib/providers/preferences_provider.dart b/app/lib/providers/preferences_provider.dart index b8ca7b95..a134bb23 100644 --- a/app/lib/providers/preferences_provider.dart +++ b/app/lib/providers/preferences_provider.dart @@ -87,7 +87,7 @@ class PreferencesProvider extends ChangeNotifier { notifyListeners(); } - /// Page content alignment. Currently applied only by Statistics. + /// Alignment of pages with constrained content widths. String get pageAlignment => _prefs.get(_keyPageAlignment) == 'center' ? 'center' : 'left'; set pageAlignment(String value) { diff --git a/app/lib/services/license_packages.dart b/app/lib/services/license_packages.dart new file mode 100644 index 00000000..06736d40 --- /dev/null +++ b/app/lib/services/license_packages.dart @@ -0,0 +1,15 @@ +import 'package:flutter/foundation.dart'; + +typedef LicensePackages = Map>; + +Future loadLicensePackages() async { + final packages = >{}; + + await for (final entry in LicenseRegistry.licenses) { + for (final package in entry.packages) { + (packages[package] ??= []).add(entry); + } + } + + return packages; +} diff --git a/app/lib/themes/app_theme.dart b/app/lib/themes/app_theme.dart index 560a84c4..d11b39d8 100644 --- a/app/lib/themes/app_theme.dart +++ b/app/lib/themes/app_theme.dart @@ -341,9 +341,8 @@ class AppTheme { static final _switchTheme = SwitchThemeData( overlayColor: WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.hovered) && - !states.contains(WidgetState.focused) && - !states.contains(WidgetState.pressed)) { + if (states.contains(WidgetState.pressed) || + (states.contains(WidgetState.hovered) && !states.contains(WidgetState.focused))) { return Colors.transparent; } diff --git a/app/lib/updates/update_controller.dart b/app/lib/updates/update_controller.dart index 68681117..92f6e8cc 100644 --- a/app/lib/updates/update_controller.dart +++ b/app/lib/updates/update_controller.dart @@ -9,6 +9,18 @@ import 'package:papyrus/updates/update_platform.dart' as platform; import 'package:papyrus/updates/update_release.dart'; import 'package:shared_preferences/shared_preferences.dart'; +enum UpdateStatus { + unchecked, + checking, + upToDate, + available, + installing, + staged, + handedOff, + checkFailed, + installFailed, +} + class UpdateController extends ChangeNotifier { static const checkFailureMessage = 'Could not check for updates. Try again.'; @@ -20,6 +32,7 @@ class UpdateController extends ChangeNotifier { final Future Function(UpdateAsset?, void Function(double?)) installer; UpdateChannel? channel; UpdateRelease? release; + UpdateStatus status = UpdateStatus.unchecked; bool busy = false; bool checking = false; bool available = false; @@ -39,6 +52,11 @@ class UpdateController extends ChangeNotifier { this.installer = platform.installUpdate, }); + DateTime? get lastChecked { + final timestamp = preferences.getInt('updates.lastCheck'); + return timestamp == null ? null : DateTime.fromMillisecondsSinceEpoch(timestamp); + } + bool get automatic => preferences.getBool('updates.automatic') ?? true; bool get external => channel == UpdateChannel.apt || channel == UpdateChannel.manual; @@ -78,6 +96,7 @@ class UpdateController extends ChangeNotifier { busy = true; checking = true; + status = UpdateStatus.checking; _notify(); try { @@ -103,12 +122,14 @@ class UpdateController extends ChangeNotifier { } } + status = available ? UpdateStatus.available : UpdateStatus.upToDate; message = available ? 'A newer version of Papyrus is available.' : 'Papyrus is up to date.'; await preferences.setInt('updates.lastCheck', DateTime.now().millisecondsSinceEpoch); } catch (_) { available = false; release = null; message = checkFailureMessage; + status = UpdateStatus.checkFailed; } finally { checking = false; busy = false; @@ -130,6 +151,7 @@ class UpdateController extends ChangeNotifier { busy = true; progress = null; + status = UpdateStatus.installing; message = 'Preparing the update…'; _notify(); @@ -152,8 +174,10 @@ class UpdateController extends ChangeNotifier { }); staged = channel == UpdateChannel.windows || channel == UpdateChannel.appImage; + status = staged ? UpdateStatus.staged : UpdateStatus.handedOff; } catch (_) { message = 'Could not finish the update. Please try again.'; + status = UpdateStatus.installFailed; } finally { busy = false; progress = null; diff --git a/app/lib/updates/update_settings.dart b/app/lib/updates/update_settings.dart index 8f1faadd..e9257c91 100644 --- a/app/lib/updates/update_settings.dart +++ b/app/lib/updates/update_settings.dart @@ -1,11 +1,9 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; -import 'package:papyrus/services/about_links.dart'; -import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/pages/app_updates_page.dart'; import 'package:papyrus/themes/app_motion.dart'; import 'package:papyrus/updates/update_controller.dart'; import 'package:papyrus/updates/update_release.dart'; -import 'package:papyrus/widgets/settings/settings_row.dart'; Future showUpdateSettings(BuildContext context, UpdateController controller) async { if (controller.viewingSettings) { @@ -15,90 +13,13 @@ Future showUpdateSettings(BuildContext context, UpdateController controlle controller.setViewingSettings(true); try { - await showDialog( - context: context, - animationStyle: AppMotion.animationStyle(context), - builder: (context) => ListenableBuilder( - listenable: controller, - builder: (context, child) => AlertDialog( - title: const Text('App updates'), - contentPadding: const EdgeInsets.fromLTRB(Spacing.lg, Spacing.md, Spacing.lg, Spacing.sm), - content: SizedBox( - width: 420, - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(controller.message), - const SizedBox(height: Spacing.sm), - Theme( - data: Theme.of(context).copyWith( - splashFactory: NoSplash.splashFactory, - highlightColor: Colors.transparent, - ), - child: SettingsToggleRow( - label: 'Check automatically', - value: controller.automatic, - onChanged: controller.setAutomatic, - padding: const EdgeInsets.symmetric(vertical: Spacing.xs), - ), - ), - if (controller.channel == UpdateChannel.apt) ...[ - const SizedBox(height: Spacing.md), - const Text( - 'Install updates through your system’s software updater. Enable the Papyrus repository once to receive future releases.', - ), - TextButton( - onPressed: () => AboutLinks.open( - context, - Uri.https('github.com', '/PapyrusReader/client/blob/master/docs/INSTALL.md'), - ), - child: const Text('Repository setup'), - ), - ], - if (controller.channel == UpdateChannel.manual) ...[ - const SizedBox(height: Spacing.md), - const Text( - 'This portable or development build uses manual installation. Install a supported package to receive integrated updates.', - ), - TextButton( - onPressed: () => AboutLinks.open(context, AboutLinks.releases), - child: const Text('Downloads'), - ), - ], - if (controller.available && !controller.external && !controller.staged) ...[ - const SizedBox(height: Spacing.md), - Text(switch (controller.channel) { - UpdateChannel.windows => 'The update will install after you close Papyrus normally.', - UpdateChannel.appImage => 'The update takes effect when you close and reopen Papyrus.', - UpdateChannel.web => 'Reload when you have finished reading to use the latest version.', - _ => 'Your device will guide you through installation.', - }), - ], - ], - ), - ), - ), - actions: [ - TextButton(onPressed: () => Navigator.pop(context), child: const Text('Close')), - if (controller.available && !controller.external && !controller.staged) - FilledButton( - onPressed: controller.busy ? null : controller.install, - child: Text(controller.channel == UpdateChannel.web ? 'Reload' : 'Update'), - ) - else - FilledButton( - onPressed: controller.busy || controller.staged ? null : controller.check, - key: const ValueKey('check-updates'), - child: IndexedStack( - index: controller.checking ? 1 : 0, - alignment: Alignment.center, - children: const [Text('Check now'), Text('Checking…')], - ), - ), - ], - ), + final page = AppUpdatesPage(controller: controller); + + await Navigator.of(context).push( + PageRouteBuilder( + transitionDuration: Duration.zero, + reverseTransitionDuration: Duration.zero, + pageBuilder: (_, _, _) => page, ), ); } finally { diff --git a/app/lib/widgets/profile/about_settings_section.dart b/app/lib/widgets/profile/about_settings_section.dart index d419234f..efb3acd7 100644 --- a/app/lib/widgets/profile/about_settings_section.dart +++ b/app/lib/widgets/profile/about_settings_section.dart @@ -6,14 +6,16 @@ import 'package:go_router/go_router.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:papyrus/services/about_links.dart'; import 'package:papyrus/services/app_info.dart'; +import 'package:papyrus/services/license_packages.dart'; import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/widgets/settings/settings_row.dart'; import 'package:papyrus/widgets/settings/settings_section.dart'; class AboutSettingsSection extends StatefulWidget { final bool isDesktop; + final bool showHeader; - const AboutSettingsSection({super.key, this.isDesktop = false}); + const AboutSettingsSection({super.key, this.isDesktop = false, this.showHeader = true}); @override State createState() => _AboutSettingsSectionState(); @@ -21,6 +23,33 @@ class AboutSettingsSection extends StatefulWidget { class _AboutSettingsSectionState extends State { late final Future _packageInfo = loadAppInfo(); + late final Future _licenses; + bool _openingLicenses = false; + + @override + void initState() { + super.initState(); + _licenses = loadLicensePackages().then((packages) => packages, onError: (Object _) => null); + } + + Future _openLicenses() async { + if (_openingLicenses) { + return; + } + + _openingLicenses = true; + final route = ModalRoute.of(context); + + try { + final packages = await _licenses; + + if (mounted && (route?.isCurrent ?? true)) { + await context.push('/profile/licenses', extra: packages); + } + } finally { + _openingLicenses = false; + } + } @override Widget build(BuildContext context) { @@ -49,7 +78,7 @@ class _AboutSettingsSectionState extends State { value: updates.available ? 'Update available' : 'Check for updates', onTap: () => showUpdateSettings(context, updates), ), - SettingsRow(label: 'Licenses', onTap: () => context.push('/profile/licenses')), + SettingsRow(label: 'Licenses', onTap: _openLicenses), SettingsRow(label: 'Support', onTap: () => context.push('/profile/support')), SettingsRow( label: "What's new", @@ -70,7 +99,7 @@ class _AboutSettingsSectionState extends State { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const SettingsSectionHeader(title: 'About'), + if (widget.showHeader) const SettingsSectionHeader(title: 'About'), ...rows, ], ); diff --git a/app/lib/widgets/profile/accessibility_settings_section.dart b/app/lib/widgets/profile/accessibility_settings_section.dart index 7a6d47ab..6cac9f06 100644 --- a/app/lib/widgets/profile/accessibility_settings_section.dart +++ b/app/lib/widgets/profile/accessibility_settings_section.dart @@ -8,8 +8,9 @@ import 'package:provider/provider.dart'; class AccessibilitySettingsSection extends StatelessWidget { final bool isDesktop; + final bool showHeader; - const AccessibilitySettingsSection({super.key, this.isDesktop = false}); + const AccessibilitySettingsSection({super.key, this.isDesktop = false, this.showHeader = true}); static String enlargementLabel(double value) => value == 1 ? 'System default' : '+${((value - 1) * 100).round()}%'; @@ -93,7 +94,7 @@ class AccessibilitySettingsSection extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const SettingsSectionHeader(title: 'Accessibility'), + if (showHeader) const SettingsSectionHeader(title: 'Accessibility'), ...children, ], ); diff --git a/app/lib/widgets/profile/account_settings_section.dart b/app/lib/widgets/profile/account_settings_section.dart index 0b4b557a..5c6cbf87 100644 --- a/app/lib/widgets/profile/account_settings_section.dart +++ b/app/lib/widgets/profile/account_settings_section.dart @@ -10,8 +10,9 @@ import 'package:provider/provider.dart'; /// Account settings section for ProfilePage. class AccountSettingsSection extends StatelessWidget { final bool isDesktop; + final bool showHeader; - const AccountSettingsSection({super.key, this.isDesktop = false}); + const AccountSettingsSection({super.key, this.isDesktop = false, this.showHeader = true}); @override Widget build(BuildContext context) { @@ -26,10 +27,11 @@ class AccountSettingsSection extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const SettingsSectionHeader( - title: 'Account', - padding: EdgeInsets.only(bottom: Spacing.md), - ), + if (showHeader) + 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), diff --git a/app/lib/widgets/profile/appearance_settings_section.dart b/app/lib/widgets/profile/appearance_settings_section.dart index 52a3db35..26f9dcff 100644 --- a/app/lib/widgets/profile/appearance_settings_section.dart +++ b/app/lib/widgets/profile/appearance_settings_section.dart @@ -9,8 +9,9 @@ import 'package:provider/provider.dart'; /// Appearance and theme settings section for ProfilePage. class AppearanceSettingsSection extends StatelessWidget { final bool isDesktop; + final bool showHeader; - const AppearanceSettingsSection({super.key, this.isDesktop = false}); + const AppearanceSettingsSection({super.key, this.isDesktop = false, this.showHeader = true}); static String getThemeLabel(String theme) { switch (theme) { @@ -41,7 +42,7 @@ class AppearanceSettingsSection extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const SettingsSectionHeader(title: 'Appearance'), + if (showHeader) const SettingsSectionHeader(title: 'Appearance'), SettingsRow(label: 'Theme', value: getThemeLabel(prefs.themeModePref), onTap: () => _showThemePicker(context)), _buildPageAlignment(context), ], diff --git a/app/lib/widgets/profile/storage_sync_settings_section.dart b/app/lib/widgets/profile/storage_sync_settings_section.dart index 45d3c4a1..201e0dc0 100644 --- a/app/lib/widgets/profile/storage_sync_settings_section.dart +++ b/app/lib/widgets/profile/storage_sync_settings_section.dart @@ -3,16 +3,13 @@ import 'package:papyrus/widgets/shared/app_bottom_sheet.dart'; import 'dart:async'; 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'; -import 'package:papyrus/providers/acquisition_availability_provider.dart'; import 'package:papyrus/providers/auth_provider.dart'; -import 'package:papyrus/providers/preferences_provider.dart'; import 'package:papyrus/providers/sync_settings_provider.dart'; import 'package:papyrus/services/book_import_service_stub.dart' if (dart.library.js_interop) 'package:papyrus/services/book_import_service.dart'; @@ -22,13 +19,14 @@ import 'package:papyrus/widgets/settings/settings_row.dart'; import 'package:papyrus/widgets/settings/settings_section.dart'; import 'package:provider/provider.dart'; -/// Storage, synchronization, and acquisition settings section. +/// Storage and synchronization settings section. /// /// Supports both mobile ListView presentation and desktop card layout. class StorageSyncSettingsSection extends StatelessWidget { final bool isDesktop; + final bool showHeader; - const StorageSyncSettingsSection({super.key, this.isDesktop = false}); + const StorageSyncSettingsSection({super.key, this.isDesktop = false, this.showHeader = true}); @override Widget build(BuildContext context) { @@ -46,17 +44,8 @@ class StorageSyncSettingsSection extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const SettingsSectionHeader(title: 'Storage'), + if (showHeader) const SettingsSectionHeader(title: 'Storage'), const SettingsRow(label: 'Library', value: 'Stored on this device'), - Padding( - padding: const EdgeInsets.symmetric(horizontal: Spacing.sm, vertical: Spacing.xs), - child: Text( - 'Nothing is sent to Papyrus servers while offline mode is on.', - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), - ), - ), SettingsRow( label: 'Backup', value: 'Export or import a backup', @@ -70,14 +59,14 @@ class StorageSyncSettingsSection extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const SettingsSectionHeader(title: 'Storage'), + if (showHeader) const SettingsSectionHeader(title: 'Storage'), SettingsRow( label: 'Data sync', value: controller.dataSyncLabel, onTap: () => _showManageSyncServersSheet(context), ), SettingsRow(label: 'Current status', value: controller.statusLabel), - SettingsRow(label: 'File storage', value: controller.fileStorageLabel), + _buildStorageUsage(context, controller), if (controller.hasFailedMediaUploads) SettingsRow( label: 'Media uploads', @@ -111,7 +100,6 @@ class StorageSyncSettingsSection extends StatelessWidget { children: [ _buildInfoRow(context, label: 'Active server', value: controller.dataSyncLabel), _buildInfoRow(context, label: 'Status', value: controller.statusLabel), - _buildInfoRow(context, label: 'File storage', value: controller.fileStorageLabel), if (controller.hasFailedMediaUploads) _buildInfoRow(context, label: 'Media uploads', value: controller.failedMediaUploadLabel), const SizedBox(height: Spacing.sm), @@ -122,49 +110,83 @@ class StorageSyncSettingsSection extends StatelessWidget { style: textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), ), ), - const SizedBox(height: Spacing.sm), - Wrap( - spacing: Spacing.sm, - runSpacing: Spacing.sm, - alignment: WrapAlignment.start, - children: [ - if (controller.canReconnect) - OutlinedButton.icon( - onPressed: () => _handleReconnectSync(context), - icon: const Icon(Icons.sync, size: IconSizes.small), - label: const Text('Reconnect'), - ), - OutlinedButton.icon( - onPressed: () => _showManageSyncServersSheet(context), - icon: const Icon(Icons.dns_outlined, size: IconSizes.small), - label: const Text('Manage servers'), - ), - if (controller.hasFailedMediaUploads) - OutlinedButton.icon( - onPressed: () => _retryFailedMediaUploads(context), - icon: const Icon(Icons.refresh, size: IconSizes.small), - label: const Text('Retry uploads'), - ), - if (controller.canClearAuthenticatedCache) + const SizedBox(height: Spacing.md), + _buildStorageUsage(context, controller), + const SizedBox(height: Spacing.md), + OutlinedButtonTheme( + data: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + minimumSize: const Size(0, ComponentSizes.buttonHeightMobile), + padding: const EdgeInsets.symmetric(horizontal: Spacing.md, vertical: Spacing.sm), + ).merge(Theme.of(context).outlinedButtonTheme.style), + ), + child: Wrap( + spacing: Spacing.sm, + runSpacing: Spacing.sm, + alignment: WrapAlignment.start, + children: [ + if (controller.canReconnect) + OutlinedButton.icon( + onPressed: () => _handleReconnectSync(context), + icon: const Icon(Icons.sync, size: IconSizes.small), + label: const Text('Reconnect'), + ), OutlinedButton.icon( - onPressed: () => _confirmClearAuthenticatedCache(context), - icon: const Icon(Icons.cleaning_services_outlined, size: IconSizes.small), - label: const Text('Clear local copy'), + onPressed: () => _showManageSyncServersSheet(context), + icon: const Icon(Icons.dns_outlined, size: IconSizes.small), + label: const Text('Manage servers'), ), - ], + if (controller.hasFailedMediaUploads) + OutlinedButton.icon( + onPressed: () => _retryFailedMediaUploads(context), + icon: const Icon(Icons.refresh, size: IconSizes.small), + label: const Text('Retry uploads'), + ), + if (controller.canClearAuthenticatedCache) + OutlinedButton.icon( + onPressed: () => _confirmClearAuthenticatedCache(context), + icon: const Icon(Icons.cleaning_services_outlined, size: IconSizes.small), + label: const Text('Clear local copy'), + ), + ], + ), ), ], ), - const SizedBox(height: Spacing.lg), - SettingsCard( - key: const Key('profile-acquisition-card'), - title: 'Acquisition', - children: [_buildAcquisitionDescription(context), ..._buildAcquisitionSettings(context)], - ), ], ); } + Widget _buildStorageUsage(BuildContext context, StorageSyncController controller) { + final theme = Theme.of(context); + final fraction = controller.fileStorageFraction; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: Spacing.sm, vertical: Spacing.sm), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('File storage', style: theme.textTheme.bodyMedium), + if (fraction != null) ...[ + const SizedBox(height: Spacing.sm), + LinearProgressIndicator( + value: fraction, + minHeight: Spacing.sm, + color: theme.colorScheme.primary, + backgroundColor: theme.colorScheme.surfaceContainerHighest, + semanticsLabel: 'File storage', + ), + ], + const SizedBox(height: Spacing.sm), + Text( + controller.fileStorageLabel, + style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + ], + ), + ); + } + Widget _buildOfflineStorageSyncContent(BuildContext context) { final textTheme = Theme.of(context).textTheme; final mutedStyle = textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant); @@ -180,7 +202,7 @@ class StorageSyncSettingsSection extends StatelessWidget { Text('Your library is stored on this device.', style: textTheme.bodyLarge), const SizedBox(height: Spacing.sm), Text( - 'Nothing is sent to Papyrus servers while offline mode is on. Export a backup before changing devices or clearing app data.', + 'Export a backup before changing devices or clearing app data.', style: mutedStyle, ), ], @@ -212,77 +234,6 @@ class StorageSyncSettingsSection extends StatelessWidget { ); } - static Widget buildMobileAcquisitionSection(BuildContext context) { - final auth = context.watch(); - - if (auth.isOfflineMode) { - return const SizedBox.shrink(); - } - - return Column( - key: const Key('profile-acquisition-section'), - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SettingsSectionHeader(title: 'Acquisition'), - _buildAcquisitionDescription(context), - ..._buildAcquisitionSettings(context), - ], - ); - } - - static Widget _buildAcquisitionDescription(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: Spacing.sm, vertical: Spacing.xs), - child: Text( - 'Search external sources and send releases to your connected clients.', - style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), - ), - ); - } - - static List _buildAcquisitionSettings(BuildContext context) { - final prefs = context.watch(); - final availability = context.watch(); - final acquisitionAvailable = availability.state == AcquisitionAvailabilityState.available; - - return [ - MergeSemantics( - child: Semantics( - label: 'Enable acquisition', - enabled: true, - toggled: prefs.acquisitionEnabled, - onTap: () => prefs.acquisitionEnabled = !prefs.acquisitionEnabled, - child: ExcludeSemantics( - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => prefs.acquisitionEnabled = !prefs.acquisitionEnabled, - child: SettingsToggleRow( - label: 'Enable acquisition', - value: prefs.acquisitionEnabled, - onChanged: (value) => prefs.acquisitionEnabled = value, - ), - ), - ), - ), - ), - SettingsRow( - label: 'Server support', - value: _acquisitionAvailabilityLabel(availability.state), - showChevron: false, - ), - if (prefs.acquisitionEnabled && acquisitionAvailable) - SettingsRow(label: 'Manage integrations', onTap: () => context.push('/acquisition')), - ]; - } - - static String _acquisitionAvailabilityLabel(AcquisitionAvailabilityState state) { - return switch (state) { - AcquisitionAvailabilityState.available => 'Available on this server', - AcquisitionAvailabilityState.unavailable => 'Unavailable on this server', - AcquisitionAvailabilityState.unknown || AcquisitionAvailabilityState.loading => 'Checking server support…', - }; - } - StorageSyncController _storageSyncController(BuildContext context) { return StorageSyncController( authProvider: context.watch(), diff --git a/app/lib/widgets/settings/settings_row.dart b/app/lib/widgets/settings/settings_row.dart index f3578838..56056604 100644 --- a/app/lib/widgets/settings/settings_row.dart +++ b/app/lib/widgets/settings/settings_row.dart @@ -164,6 +164,8 @@ class SettingsToggleRow extends StatelessWidget { enabled: enabled, builder: (focusNode) => InkWell( hoverColor: Colors.transparent, + splashFactory: NoSplash.splashFactory, + highlightColor: Colors.transparent, focusNode: focusNode, canRequestFocus: enabled, onTap: toggle, diff --git a/app/test/pages/licenses_page_test.dart b/app/test/pages/licenses_page_test.dart index 0b76ca05..17322c39 100644 --- a/app/test/pages/licenses_page_test.dart +++ b/app/test/pages/licenses_page_test.dart @@ -1,5 +1,9 @@ +import 'dart:async'; +import 'dart:ui' as ui; + import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:papyrus/pages/licenses_page.dart'; import 'package:papyrus/themes/app_theme.dart'; @@ -17,6 +21,76 @@ void main() { }); tearDown(LicenseRegistry.reset); + testWidgets('prepared licenses render on the first frame', (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.dark, + home: const LicensesPage( + initialLicenses: { + 'alpha': [ + LicenseEntryWithLineBreaks(['alpha'], 'Alpha license text'), + ], + }, + ), + ), + ); + + expect(find.widgetWithText(ListTile, 'alpha'), findsOneWidget); + expect(find.text('Loading licenses…'), findsNothing); + }); + + testWidgets('fast loads never flash loading feedback', (tester) async { + await tester.pumpWidget(MaterialApp(theme: AppTheme.dark, home: const LicensesPage())); + expect(find.text('Loading licenses…'), findsNothing); + await tester.pump(); + expect(find.widgetWithText(ListTile, 'alpha'), findsOneWidget); + await tester.pump(const Duration(milliseconds: 800)); + expect(find.text('Loading licenses…'), findsNothing); + }); + + for (final fails in [false, true]) { + testWidgets('slow load feedback stays readable before completion, fails=$fails', (tester) async { + final result = Completer(); + LicenseRegistry.reset(); + LicenseRegistry.addLicense(() => Stream.fromFuture(result.future)); + await tester.pumpWidget(MaterialApp(theme: AppTheme.dark, home: const LicensesPage())); + await tester.pump(const Duration(milliseconds: 299)); + expect(find.text('Loading licenses…'), findsNothing); + await tester.pump(const Duration(milliseconds: 1)); + expect(find.text('Loading licenses…'), findsOneWidget); + + if (fails) { + result.completeError(StateError('License load failed')); + } else { + result.complete(const LicenseEntryWithLineBreaks(['alpha'], 'Alpha license text')); + } + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 499)); + expect(find.text('Loading licenses…'), findsOneWidget); + await tester.pump(const Duration(milliseconds: 1)); + await tester.pump(); + expect(find.text('Loading licenses…'), findsNothing); + expect( + fails ? find.text('Could not load licenses. Retry') : find.widgetWithText(ListTile, 'alpha'), + findsOneWidget, + ); + + if (fails) { + LicenseRegistry.reset(); + LicenseRegistry.addLicense( + () => Stream.value(const LicenseEntryWithLineBreaks(['alpha'], 'Alpha license text')), + ); + + await tester.tap(find.text('Could not load licenses. Retry')); + await tester.pump(); + expect(find.text('Loading licenses…'), findsNothing); + await tester.pumpAndSettle(); + expect(find.widgetWithText(ListTile, 'alpha'), findsOneWidget); + } + }); + } + testWidgets('wide licenses show all notices and switch without animation', (tester) async { tester.view.physicalSize = const Size(1200, 800); tester.view.devicePixelRatio = 1; @@ -34,6 +108,51 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('scrolling the selected license never paints over the header', (tester) async { + tester.view.physicalSize = const Size(1200, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final captureKey = GlobalKey(); + + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.dark, + home: RepaintBoundary( + key: captureKey, + child: LicensesPage( + initialLicenses: { + for (var index = 0; index < 40; index++) + 'package-${index.toString().padLeft(2, '0')}': [ + const LicenseEntryWithLineBreaks(['package'], 'License text'), + ], + }, + ), + ), + ), + ); + + await tester.pumpAndSettle(); + final headerBottom = tester.getBottomLeft(find.byType(LibraryPageHeader)).dy.floor(); + + Future> headerPixels() async { + final boundary = captureKey.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final image = await boundary.toImage(); + final bytes = await image.toByteData(format: ui.ImageByteFormat.rawRgba); + final pixels = bytes!.buffer.asUint8List(0, image.width * headerBottom * 4).toList(); + image.dispose(); + return pixels; + } + + final before = await tester.runAsync(headerPixels); + final list = find.byKey(const PageStorageKey('license-packages')); + await tester.drag(list, const Offset(0, -50)); + await tester.pumpAndSettle(); + final after = await tester.runAsync(headerPixels); + expect(listEquals(before, after), isTrue, reason: 'Selection ink must stay below the header while scrolling.'); + expect(tester.takeException(), isNull); + }); + testWidgets('compact license navigation and back are immediate', (tester) async { tester.view.physicalSize = const Size(390, 844); tester.view.devicePixelRatio = 1; diff --git a/app/test/pages/profile_page_test.dart b/app/test/pages/profile_page_test.dart index cb0390f1..ba691fcc 100644 --- a/app/test/pages/profile_page_test.dart +++ b/app/test/pages/profile_page_test.dart @@ -4,6 +4,8 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:papyrus/themes/app_theme.dart'; import 'package:papyrus/auth/auth_api_client.dart'; import 'package:papyrus/auth/auth_models.dart'; import 'package:papyrus/auth/auth_repository.dart'; @@ -18,8 +20,6 @@ import 'package:papyrus/providers/acquisition_availability_provider.dart'; import 'package:papyrus/providers/auth_provider.dart'; 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'; @@ -117,6 +117,25 @@ void main() { }); Widget buildApp({required Size screenSize, TextScaler textScaler = TextScaler.noScaling}) { + final router = GoRouter( + initialLocation: '/profile', + routes: [ + GoRoute( + path: '/profile', + builder: (_, _) => const ProfilePage(), + routes: [ + for (final section in ProfileSection.values) + GoRoute( + path: section.path, + builder: (_, _) => ProfilePage(section: section), + ), + ], + ), + ], + ); + + addTearDown(router.dispose); + return MultiProvider( providers: [ ChangeNotifierProvider.value(value: authProvider), @@ -128,95 +147,115 @@ void main() { ChangeNotifierProvider.value(value: prefsProvider), ChangeNotifierProvider.value(value: availabilityProvider), ], - child: MaterialApp( - home: MediaQuery( - data: MediaQueryData(size: screenSize, textScaler: textScaler), - child: const ProfilePage(), + child: MaterialApp.router( + theme: AppTheme.light, + routerConfig: router, + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith(size: screenSize, textScaler: textScaler), + child: child!, ), ), ); } - testWidgets('mobile layout renders all sections and displays logout dialog', (tester) async { + testWidgets('mobile index opens focused pages and Back restores the index', (tester) async { await tester.binding.setSurfaceSize(const Size(400, 900)); addTearDown(() => tester.binding.setSurfaceSize(null)); await tester.pumpWidget(buildApp(screenSize: const Size(400, 900))); await tester.pumpAndSettle(); 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, - scrollable: find.byType(Scrollable).first, - ); - expect(find.text('Change password'), findsOneWidget); - await tester.scrollUntilVisible( - find.widgetWithText(SettingsSectionHeader, 'Appearance'), - 300, - scrollable: find.byType(Scrollable).first, - ); - expect(find.text('Theme'), findsOneWidget); - 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); - 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, - scrollable: find.byType(Scrollable).first, - ); - expect(find.text('Analytics'), findsOneWidget); - 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); + expect(find.byType(TextFormField), findsNothing); + expect(find.text('Theme'), findsNothing); + expect(find.text('Version'), findsNothing); + + for (final label in ['Notifications', 'Privacy']) { + final item = tester.widget(find.widgetWithText(ListTile, label)); + expect(item.enabled, isFalse); + expect(item.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, - scrollable: find.byType(Scrollable).first, - ); - expect(find.text('Version'), findsOneWidget); - await tester.scrollUntilVisible( - find.widgetWithText(ProfileMenuItem, 'Log out'), - 300, - scrollable: find.byType(Scrollable).first, - ); - await tester.tap(find.widgetWithText(ProfileMenuItem, 'Log out')); + + for (final (title, control) in [ + ('Account', 'Change password'), + ('Appearance', 'Theme'), + ('Storage', 'Clear local library'), + ('Accessibility', 'Reduce animations'), + ('About', 'Version'), + ]) { + await tester.ensureVisible(find.widgetWithText(ListTile, title)); + await tester.tap(find.widgetWithText(ListTile, title)); + await tester.pumpAndSettle(); + expect(find.text(title), findsOneWidget); + expect(find.text(control), findsOneWidget); + expect(find.widgetWithText(ListTile, 'About'), findsNothing); + await tester.tap(find.byType(BackButton)); + await tester.pumpAndSettle(); + expect(find.text('Settings'), findsOneWidget); + } + + await tester.ensureVisible(find.widgetWithText(ListTile, 'Log out')); + await tester.tap(find.widgetWithText(ListTile, 'Log out')); await tester.pumpAndSettle(); expect(find.text('Are you sure you want to log out?'), findsOneWidget); await tester.tap(find.text('Cancel')); await tester.pumpAndSettle(); - expect(find.text('Are you sure you want to log out?'), findsNothing); + expect(find.text('Settings'), findsOneWidget); + }); + + testWidgets('mobile appearance persists a choice across Back and reopening', (tester) async { + await tester.binding.setSurfaceSize(const Size(400, 900)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget(buildApp(screenSize: const Size(400, 900))); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(ListTile, 'Appearance')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(SettingsRow, 'Theme')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Dark')); + await tester.pumpAndSettle(); + expect(PreferencesProvider(prefs).themeModePref, 'dark'); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + expect(find.text('Dark theme, page layout'), findsOneWidget); + await tester.tap(find.widgetWithText(ListTile, 'Appearance')); + await tester.pumpAndSettle(); + expect(find.text('Dark'), findsOneWidget); + }); + + testWidgets('narrow mobile with enlarged text retains index scroll position on Back', (tester) async { + const size = Size(320, 568); + await tester.binding.setSurfaceSize(size); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget(buildApp(screenSize: size, textScaler: TextScaler.linear(2))); + await tester.pumpAndSettle(); + await tester.scrollUntilVisible(find.widgetWithText(ListTile, 'About'), 200); + final before = tester.state(find.byType(Scrollable)).position.pixels; + await tester.tap(find.widgetWithText(ListTile, 'About')); + await tester.pumpAndSettle(); + expect(find.text('Version'), findsOneWidget); + expect(tester.takeException(), isNull); + await tester.tap(find.byType(BackButton)); + await tester.pumpAndSettle(); + expect(tester.state(find.byType(Scrollable)).position.pixels, before); + expect(tester.takeException(), isNull); + }); + + testWidgets('every detail page opens directly and returns to Settings at 320px with enlarged text', (tester) async { + const size = Size(320, 568); + await tester.binding.setSurfaceSize(size); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget(buildApp(screenSize: size, textScaler: TextScaler.linear(2))); + await tester.pumpAndSettle(); + final router = GoRouter.of(tester.element(find.byType(ProfilePage))); + + for (final section in ProfileSection.values) { + router.go('/profile/${section.path}'); + await tester.pumpAndSettle(); + expect(find.text(section.title), findsOneWidget); + expect(tester.takeException(), isNull); + await tester.tap(find.byType(BackButton)); + await tester.pumpAndSettle(); + expect(router.routeInformationProvider.value.uri.path, '/profile'); + } }); testWidgets('desktop settings scrolls when the pointer is over empty space beside the form', (tester) async { diff --git a/app/test/pages/profile_storage_sync_test.dart b/app/test/pages/profile_storage_sync_test.dart index 8adb6484..4ce2959b 100644 --- a/app/test/pages/profile_storage_sync_test.dart +++ b/app/test/pages/profile_storage_sync_test.dart @@ -1,7 +1,5 @@ -import 'dart:async'; -import 'dart:ui' show SemanticsAction, Tristate; - import 'package:flutter/material.dart'; +import 'package:papyrus/themes/app_theme.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:papyrus/acquisition/acquisition_models.dart'; import 'package:papyrus/auth/auth_api_client.dart'; @@ -224,9 +222,10 @@ void main() { ChangeNotifierProvider(create: (_) => PreferencesProvider(prefs)), ], child: MaterialApp( + theme: AppTheme.light, home: MediaQuery( data: MediaQueryData(size: screenSize), - child: pageBuilder?.call(const ProfilePage()) ?? const ProfilePage(), + child: pageBuilder?.call(const ProfilePage()) ?? const ProfilePage(section: ProfileSection.storageSync), ), ), ); @@ -279,12 +278,11 @@ void main() { 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, 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); expect(find.text('Clear local library'), findsOneWidget); - expect(find.textContaining('Nothing is sent to Papyrus servers'), findsOneWidget); + expect(find.textContaining('Nothing is sent to Papyrus servers'), findsNothing); expect(find.text('Guest local'), findsNothing); expect(find.text('papyrus-guest.db'), findsNothing); expect(find.text('Metadata sync off'), findsNothing); @@ -306,14 +304,12 @@ void main() { expect(find.text('Enable acquisition'), findsNothing); }); - testWidgets('authenticated mobile profile separates supported acquisition settings from storage', (tester) async { + testWidgets('authenticated mobile storage omits acquisition settings', (tester) async { final auth = await buildAuthProvider(signedIn: true); - final service = _FakePowerSyncService( currentMode: LibraryDatabaseMode.authenticated, currentSyncState: const SyncState(connected: true), ); - final availability = AcquisitionAvailabilityProvider(loadCapabilities: (_) async => _capabilities(enabled: true)); await availability.refresh(Uri.parse('https://api.test')); @@ -322,92 +318,9 @@ void main() { ); await tester.pumpAndSettle(); - 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); - expect(find.descendant(of: acquisitionSection, matching: find.text('Acquisition')), findsOneWidget); - - expect( - find.descendant( - of: acquisitionSection, - matching: find.text('Search external sources and send releases to your connected clients.'), - ), - findsOneWidget, - ); - - expect(find.descendant(of: acquisitionSection, matching: find.text('Available on this server')), findsOneWidget); - expect(find.descendant(of: acquisitionSection, matching: find.text('Manage integrations')), findsNothing); - final semantics = tester.getSemantics(find.bySemanticsLabel('Enable acquisition')); - expect(semantics.label, 'Enable acquisition'); - expect(semantics.flagsCollection.isEnabled, Tristate.isTrue); - expect(semantics.flagsCollection.isToggled, Tristate.isFalse); - expect(semantics.getSemanticsData().hasAction(SemanticsAction.tap), isTrue); - await tester.tap(find.bySemanticsLabel('Enable acquisition')); - await tester.pumpAndSettle(); - expect(tester.getSemantics(find.bySemanticsLabel('Enable acquisition')).flagsCollection.isToggled, Tristate.isTrue); - expect(find.descendant(of: acquisitionSection, matching: find.text('Manage integrations')), findsOneWidget); - }); - - testWidgets('authenticated mobile profile reports pending acquisition availability', (tester) async { - final completer = Completer(); - final auth = await buildAuthProvider(signedIn: true); - - final service = _FakePowerSyncService( - currentMode: LibraryDatabaseMode.authenticated, - currentSyncState: const SyncState(connected: true), - ); - - final availability = AcquisitionAvailabilityProvider(loadCapabilities: (_) => completer.future); - final refresh = availability.refresh(Uri.parse('https://api.test')); - - await tester.pumpWidget( - await buildPage(authProvider: auth, powerSyncService: service, acquisitionAvailabilityProvider: availability), - ); - - await tester.pump(); - 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)); - await refresh; - await tester.pumpAndSettle(); - expect(find.text('Available on this server'), findsOneWidget); - }); - - testWidgets('authenticated mobile profile hides acquisition management when server is unavailable', (tester) async { - final auth = await buildAuthProvider(signedIn: true); - - final service = _FakePowerSyncService( - currentMode: LibraryDatabaseMode.authenticated, - currentSyncState: const SyncState(connected: true), - ); - - final availability = AcquisitionAvailabilityProvider(loadCapabilities: (_) async => _capabilities(enabled: false)); - await availability.refresh(Uri.parse('https://api.test')); - - await tester.pumpWidget( - await buildPage(authProvider: auth, powerSyncService: service, acquisitionAvailabilityProvider: availability), - ); - - await tester.pumpAndSettle(); - 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); - expect(find.text('Enable acquisition'), findsOneWidget); - expect(find.text('Unavailable on this server'), findsOneWidget); - expect(find.text('Manage integrations'), findsNothing); - await tester.tap(find.bySemanticsLabel('Enable acquisition')); - await tester.pumpAndSettle(); + expect(find.text('File storage'), findsOneWidget); + expect(find.text('Acquisition'), findsNothing); + expect(find.text('Enable acquisition'), findsNothing); expect(find.text('Manage integrations'), findsNothing); }); @@ -424,7 +337,7 @@ void main() { await tester.pump(); expect(find.text('Library storage'), findsOneWidget); expect(find.text('Your library is stored on this device.'), findsOneWidget); - expect(find.textContaining('Nothing is sent to Papyrus servers'), findsOneWidget); + expect(find.textContaining('Nothing is sent to Papyrus servers'), findsNothing); expect(find.text('Export backup'), findsOneWidget); expect(find.text('Import backup'), findsOneWidget); expect(find.text('Clear local library'), findsOneWidget); @@ -443,6 +356,8 @@ void main() { }); testWidgets('authenticated storage sync UI shows data sync and hides implementation details', (tester) async { + await tester.binding.setSurfaceSize(const Size(1200, 900)); + addTearDown(() => tester.binding.setSurfaceSize(null)); final auth = await buildAuthProvider(signedIn: true); final dataStore = dataStoreWithBooks([ @@ -474,15 +389,23 @@ void main() { expect(find.text('Data sync'), findsOneWidget); expect(find.text('Official server'), findsWidgets); expect(find.text('350 MB used, 674 MB available of 1 GB'), findsOneWidget); + expect(tester.widget(find.byType(LinearProgressIndicator)).value, 350 / 1024); + final reconnectButton = find.widgetWithText(OutlinedButton, 'Reconnect'); + final manageButton = find.widgetWithText(OutlinedButton, 'Manage servers'); + expect( + tester.getSize(reconnectButton).width, + lessThan(tester.getSize(find.byKey(const Key('profile-data-sync-card'))).width / 2), + ); + expect(tester.getTopLeft(reconnectButton).dy, tester.getTopLeft(manageButton).dy); expect(find.text('Connected'), findsWidgets); expect(find.text('Reconnect'), findsOneWidget); expect(find.text('Manage servers'), findsOneWidget); expect(find.byKey(const Key('profile-data-sync-card')), findsOneWidget); - expect(find.byKey(const Key('profile-acquisition-card')), findsOneWidget); - expect(find.text('Acquisition'), findsOneWidget); - expect(find.text('Enable acquisition'), findsOneWidget); - expect(find.text('Available on this server'), findsOneWidget); - expect(find.text('Search external sources and send releases to your connected clients.'), findsOneWidget); + expect(find.byKey(const Key('profile-acquisition-card')), findsNothing); + expect(find.text('Acquisition'), findsNothing); + expect(find.text('Enable acquisition'), findsNothing); + expect(find.text('Available on this server'), findsNothing); + expect(find.text('Search external sources and send releases to your connected clients.'), findsNothing); expect(find.text('Manage integrations'), findsNothing); expect(find.text('Clear local copy'), findsOneWidget); expect(find.text('Clear account local cache'), findsNothing); @@ -499,38 +422,6 @@ void main() { await tester.tap(find.text('Reconnect')); await tester.pump(); expect(service.reconnectCalls, 1); - await tester.tap(find.bySemanticsLabel('Enable acquisition')); - await tester.pumpAndSettle(); - expect(find.text('Manage integrations'), findsOneWidget); - }); - - testWidgets('authenticated desktop profile reports unsupported acquisition separately', (tester) async { - final auth = await buildAuthProvider(signedIn: true); - - final service = _FakePowerSyncService( - currentMode: LibraryDatabaseMode.authenticated, - currentSyncState: const SyncState(connected: true), - ); - - final availability = AcquisitionAvailabilityProvider(loadCapabilities: (_) async => _capabilities(enabled: false)); - await availability.refresh(Uri.parse('https://api.test')); - - await tester.pumpWidget( - await buildPage( - authProvider: auth, - powerSyncService: service, - screenSize: const Size(1200, 900), - acquisitionAvailabilityProvider: availability, - ), - ); - - await tester.pumpAndSettle(); - await tester.tap(find.text('Storage').first); - await tester.pumpAndSettle(); - expect(find.byKey(const Key('profile-data-sync-card')), findsOneWidget); - expect(find.byKey(const Key('profile-acquisition-card')), findsOneWidget); - expect(find.text('Unavailable on this server'), findsOneWidget); - expect(find.text('Manage integrations'), findsNothing); }); testWidgets('manage servers lists official and custom servers for switching', (tester) async { @@ -562,7 +453,6 @@ void main() { ); await tester.pumpAndSettle(); - 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(); diff --git a/app/test/pages/support_navigation_test.dart b/app/test/pages/support_navigation_test.dart index de9b1294..fd307b38 100644 --- a/app/test/pages/support_navigation_test.dart +++ b/app/test/pages/support_navigation_test.dart @@ -12,6 +12,7 @@ import 'package:papyrus/opds/opds_catalog_store.dart'; import 'package:papyrus/opds/opds_catalogs.dart'; import 'package:papyrus/pages/support_page.dart'; import 'package:papyrus/providers/sidebar_provider.dart'; +import 'package:papyrus/providers/preferences_provider.dart'; import 'package:papyrus/services/support_api_client.dart'; import 'package:papyrus/themes/app_theme.dart'; import 'package:papyrus/widgets/shell/adaptive_app_shell.dart'; @@ -57,6 +58,7 @@ void main() { final completion = Completer(); final clients = <_TrackingClient>[]; var submissions = 0; + final preferences = await SharedPreferences.getInstance(); final catalogs = OpdsCatalogs( OpdsCatalogStore(await SharedPreferences.getInstance(), secrets: MemorySecrets()), @@ -110,6 +112,7 @@ void main() { providers: [ ChangeNotifierProvider(create: (_) => DataStore()), ChangeNotifierProvider(create: (_) => SidebarProvider()), + ChangeNotifierProvider(create: (_) => PreferencesProvider(preferences)), ChangeNotifierProvider.value(value: catalogs), ], child: MaterialApp.router(theme: AppTheme.eink, routerConfig: router), diff --git a/app/test/pages/support_page_test.dart b/app/test/pages/support_page_test.dart index 4d1d8146..f4105754 100644 --- a/app/test/pages/support_page_test.dart +++ b/app/test/pages/support_page_test.dart @@ -9,13 +9,21 @@ import 'package:http/testing.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:papyrus/auth/papyrus_api_config.dart'; import 'package:papyrus/pages/support_page.dart'; +import 'package:papyrus/providers/preferences_provider.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:papyrus/services/support_api_client.dart'; import 'package:papyrus/themes/app_theme.dart'; import '../helpers/support_file_picker.dart'; void main() { - setUp(() { + late PreferencesProvider preferences; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + preferences = PreferencesProvider(await SharedPreferences.getInstance()); + addTearDown(preferences.dispose); PackageInfo.setMockInitialValues( appName: 'Papyrus', packageName: 'papyrus', @@ -25,6 +33,45 @@ void main() { ); }); + for (final width in [390.0, 1280.0]) { + testWidgets('page alignment updates without losing the draft at width $width', (tester) async { + tester.view.physicalSize = Size(width, 1100); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final client = SupportApiClient( + config: PapyrusApiConfig(serverBaseUri: Uri.parse('https://api.example')), + httpClient: MockClient((request) async => http.Response('', 503)), + ); + + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.dark, + home: ChangeNotifierProvider.value( + value: preferences, + child: SupportPage(client: client), + ), + ), + ); + + await tester.pumpAndSettle(); + final leftBounds = tester.getRect(find.byType(Form)); + expect(leftBounds.left, 24); + expect(leftBounds.width, width > 688 ? 640 : width - 48); + await tester.enterText(find.byType(TextFormField).first, 'Reader issue'); + preferences.pageAlignment = 'center'; + await tester.pumpAndSettle(); + final centeredBounds = tester.getRect(find.byType(Form)); + expect(centeredBounds.center.dx, width / 2); + expect(centeredBounds.width, leftBounds.width); + expect(find.text('Reader issue'), findsOneWidget); + preferences.pageAlignment = 'left'; + await tester.pumpAndSettle(); + expect(tester.getRect(find.byType(Form)), leftBounds); + expect(find.text('Reader issue'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + } + testWidgets('validation, failure recovery, and single successful submission', (tester) async { tester.view.physicalSize = const Size(1000, 1100); tester.view.devicePixelRatio = 1; @@ -48,7 +95,10 @@ void main() { await tester.pumpWidget( MaterialApp( theme: AppTheme.eink, - home: SupportPage(client: client), + home: ChangeNotifierProvider.value( + value: preferences, + child: SupportPage(client: client), + ), ), ); await tester.pumpAndSettle(); @@ -98,7 +148,10 @@ void main() { data: MediaQuery.of(context).copyWith(textScaler: const TextScaler.linear(1.5)), child: child!, ), - home: SupportPage(client: client), + home: ChangeNotifierProvider.value( + value: preferences, + child: SupportPage(client: client), + ), ), ); await tester.pumpAndSettle(); @@ -139,7 +192,10 @@ void main() { await tester.pumpWidget( MaterialApp( theme: AppTheme.dark, - home: SupportPage(client: client), + home: ChangeNotifierProvider.value( + value: preferences, + child: SupportPage(client: client), + ), ), ); await tester.pumpAndSettle(); diff --git a/app/test/powersync/storage_sync_controller_test.dart b/app/test/powersync/storage_sync_controller_test.dart index a0f71a14..98cd7150 100644 --- a/app/test/powersync/storage_sync_controller_test.dart +++ b/app/test/powersync/storage_sync_controller_test.dart @@ -1,4 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/media/media_models.dart'; import 'package:papyrus/auth/auth_api_client.dart'; import 'package:papyrus/auth/auth_models.dart'; import 'package:papyrus/auth/auth_repository.dart'; @@ -91,4 +92,35 @@ void main() { expect(controller.hasFailedMediaUploads, isTrue); expect(controller.failedMediaUploadLabel, '2 failed'); }); + + test('storage fraction uses server usage and handles capacity limits', () async { + final prefs = await SharedPreferences.getInstance(); + final auth = AuthProvider(prefs, repository: _FakeAuthRepository(), bootstrapOnCreate: false); + await auth.bootstrap(); + final settings = SyncSettingsProvider( + prefs, + officialConfig: PapyrusApiConfig(serverBaseUri: Uri.parse('https://api.test')), + ); + + StorageSyncController controller({MediaStorageUsage? usage, int usedBytes = 0}) { + return StorageSyncController( + authProvider: auth, + powerSyncService: _FakePowerSyncService(), + syncSettings: settings, + syncState: const SyncState(), + fileStorageUsedBytes: usedBytes, + mediaStorageUsage: usage, + ); + } + + expect(controller(usedBytes: 536870912).fileStorageFraction, 0.5); + expect(controller().fileStorageFraction, 0); + + for (final (used, quota, expected) in [(25, 100, 0.25), (120, 100, 1.0), (0, 0, null)]) { + final state = controller( + usage: MediaStorageUsage(usedBytes: used, quotaBytes: quota, availableBytes: 0), + ); + expect(state.fileStorageFraction, expected); + } + }); } diff --git a/app/test/updates/update_controller_test.dart b/app/test/updates/update_controller_test.dart index 437d0851..a7e33edd 100644 --- a/app/test/updates/update_controller_test.dart +++ b/app/test/updates/update_controller_test.dart @@ -187,6 +187,33 @@ void main() { expect(updates.busy, isFalse); }); + test('last successful check survives failure and does not claim freshness after restart', () async { + var fails = false; + final updates = controller( + manifest: () async { + if (fails) { + throw StateError('Offline'); + } + + return metadata(build: 6); + }, + ); + + expect(updates.status, UpdateStatus.unchecked); + expect(updates.lastChecked, isNull); + await updates.check(); + expect(updates.status, UpdateStatus.upToDate); + final checked = updates.lastChecked; + expect(checked, isNotNull); + fails = true; + await updates.check(); + expect(updates.status, UpdateStatus.checkFailed); + expect(updates.lastChecked, checked); + final restarted = controller(); + expect(restarted.lastChecked, checked); + expect(restarted.status, UpdateStatus.unchecked); + }); + test('invalid release identity fails closed', () { for (final data in [ {...metadata(), 'tag': 'v0.2.0+8'}, diff --git a/app/test/updates/update_settings_test.dart b/app/test/updates/update_settings_test.dart index b15bbd60..d6776aaa 100644 --- a/app/test/updates/update_settings_test.dart +++ b/app/test/updates/update_settings_test.dart @@ -7,6 +7,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:papyrus/updates/update_controller.dart'; +import 'package:papyrus/pages/app_updates_page.dart'; +import 'package:papyrus/widgets/shared/app_progress_indicator.dart'; import 'package:papyrus/themes/app_theme.dart'; import 'package:papyrus/updates/update_release.dart'; import 'package:papyrus/updates/update_settings.dart'; @@ -14,12 +16,14 @@ import 'package:shared_preferences/shared_preferences.dart'; void main() { for (final eink in [false, true]) { - testWidgets('automatic toggle stays aligned and has no pressed ink, eink=$eink', (tester) async { + testWidgets('automatic toggle has no pressed ink and Back releases the settings guard, eink=$eink', (tester) async { SharedPreferences.setMockInitialValues({'updates.automatic': false}); var checks = 0; final preferences = await SharedPreferences.getInstance(); final updates = UpdateController( preferences: preferences, + appInfo: () async => + PackageInfo(appName: 'Papyrus', packageName: 'papyrus', version: '0.2.0', buildNumber: '6'), channelProvider: () async { checks++; return UpdateChannel.windows; @@ -27,10 +31,7 @@ void main() { ); addTearDown(updates.dispose); - const status = UpdateController.checkFailureMessage; - updates.message = status; final boundaryKey = GlobalKey(); - await tester.pumpWidget( RepaintBoundary( key: boundaryKey, @@ -50,9 +51,14 @@ void main() { await tester.tap(find.text('Open updates')); await tester.pumpAndSettle(); - final dialog = tester.getRect(find.byType(AlertDialog)); + expect(find.byType(AlertDialog), findsNothing); + final route = ModalRoute.of(tester.element(find.byType(AppUpdatesPage)))! as TransitionRoute; + expect(route.transitionDuration, Duration.zero); + expect(route.reverseTransitionDuration, Duration.zero); + expect(find.text('0.2.0 (6)'), findsOneWidget); + expect(find.text('Not checked yet'), findsOneWidget); + expect(updates.viewingSettings, isTrue); final label = tester.getTopLeft(find.text('Check automatically')); - expect(label.dx, tester.getTopLeft(find.text(status).hitTestable()).dx); final before = await capture(tester, boundaryKey); final gesture = await tester.startGesture(tester.getCenter(find.byType(Switch))); await tester.pump(const Duration(milliseconds: 100)); @@ -60,25 +66,27 @@ void main() { await gesture.up(); await tester.pumpAndSettle(); expect(preferences.getBool('updates.automatic'), isTrue); - expect(tester.widget(find.byType(Switch)).value, isTrue); - expect(find.text(status).hitTestable(), findsOneWidget); - expect(tester.getRect(find.byType(AlertDialog)), dialog); expect(tester.getTopLeft(find.text('Check automatically')), label); expect(checks, 0); - await tester.tap(find.text('Check automatically')); + await tester.tap(find.byType(BackButton)); await tester.pumpAndSettle(); - expect(preferences.getBool('updates.automatic'), isFalse); - expect(tester.getRect(find.byType(AlertDialog)), dialog); - expect(checks, 0); + expect(find.text('Open updates'), findsOneWidget); + expect(updates.viewingSettings, isFalse); }); } - for (final (width, scale, eink) in [(800.0, 1.0, false), (360.0, 1.0, false), (360.0, 2.0, true)]) { - testWidgets('Check now keeps status and controls in place at width $width, scale $scale', (tester) async { + for (final (width, scale, eink) in [ + (1280.0, 1.0, false), + (360.0, 1.0, false), + (320.0, 2.0, false), + (360.0, 2.0, true), + ]) { + testWidgets('check, failure, retry and success stay usable at width $width, scale $scale, eink=$eink', ( + tester, + ) async { tester.view.physicalSize = Size(width, 900); tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.reset); SharedPreferences.setMockInitialValues({}); var result = Completer(); final updates = UpdateController( @@ -97,49 +105,33 @@ void main() { data: MediaQuery.of(context).copyWith(textScaler: TextScaler.linear(scale)), child: child!, ), - home: Scaffold( - body: Builder( - builder: (context) => TextButton( - onPressed: () => showUpdateSettings(context, updates), - child: const Text('Open updates'), - ), - ), - ), + home: AppUpdatesPage(controller: updates), ), ); - await tester.tap(find.text('Open updates')); await tester.pumpAndSettle(); final checkButton = find.byKey(const ValueKey('check-updates')); - var dialog = tester.getRect(find.byType(AlertDialog)); - var toggle = tester.getRect(find.byType(Switch)); - var check = tester.getRect(checkButton); - - void expectStableLayout() { - expect(tester.getRect(find.byType(AlertDialog)), dialog); - expect(tester.getRect(find.byType(Switch)), toggle); - expect(tester.getRect(checkButton), check); - expect(tester.takeException(), isNull); - } for (final fails in [true, false]) { - final message = updates.message; - dialog = tester.getRect(find.byType(AlertDialog)); - toggle = tester.getRect(find.byType(Switch)); - check = tester.getRect(checkButton); - final statusBottom = tester.getBottomLeft(find.text(message)).dy; - final toggleTop = tester - .getTopLeft(find.ancestor(of: find.byType(Switch), matching: find.byType(Theme)).first) - .dy; - expect(toggleTop - statusBottom, 8); - await tester.tap(find.text('Check now')); + await tester.ensureVisible(checkButton); + final buttonBounds = tester.getRect(checkButton); + final buttonOffset = buttonBounds.top - tester.getTopLeft(find.text('Installed version')).dy; + final toggleBounds = tester.getRect(find.byType(Switch)); + final previousStatus = fails ? 'Not checked yet' : 'Couldn’t check for updates'; + await tester.tap(checkButton); await tester.pump(); - expect(find.text('Checking…'), findsOneWidget); - expect(find.text(message), findsOneWidget); + expect(find.text(previousStatus), findsOneWidget); + expect(find.byType(AppLinearProgressIndicator), findsNothing); expect(tester.widget(checkButton).onPressed, isNull); - expectStableLayout(); - await tester.pump(const Duration(milliseconds: 200)); - expectStableLayout(); + expect(tester.getRect(checkButton), buttonBounds); + expect(tester.getRect(find.byType(Switch)), toggleBounds); + await tester.pump(const Duration(milliseconds: 350)); + expect(find.text('Checking…'), findsOneWidget); + expect(find.text(previousStatus), findsOneWidget); + expect(find.byType(AppLinearProgressIndicator), findsOneWidget); + expect(tester.getRect(checkButton), buttonBounds); + expect(tester.getRect(find.byType(Switch)), toggleBounds); + expect(tester.takeException(), isNull); if (fails) { result.completeError(StateError('Offline')); @@ -148,18 +140,165 @@ void main() { } await tester.pumpAndSettle(); - expect( - find.text(fails ? UpdateController.checkFailureMessage : 'Papyrus is up to date.').hitTestable(), - findsOneWidget, - ); - expect(find.text('Check now'), findsOneWidget); + expect(find.text(fails ? 'Couldn’t check for updates' : 'You’re up to date'), findsOneWidget); + expect(find.text(fails ? 'Retry' : 'Check for updates'), findsOneWidget); + expect(find.textContaining('Last checked'), fails ? findsNothing : findsOneWidget); expect(tester.widget(checkButton).onPressed, isNotNull); + final action = tester.element(find.text(fails ? 'Retry' : 'Check for updates')); + expect(DefaultTextStyle.of(action).style.color, Theme.of(action).colorScheme.onPrimary); expect(tester.takeException(), isNull); result = Completer(); + + if (fails) { + expect( + tester.getRect(checkButton).top - tester.getTopLeft(find.text('Installed version')).dy, + buttonOffset, + ); + + await tester.ensureVisible(checkButton); + final retryBounds = tester.getRect(checkButton); + final retryToggleBounds = tester.getRect(find.byType(Switch)); + await tester.tap(checkButton); + await tester.pump(const Duration(milliseconds: 100)); + expect(find.text('Retry'), findsOneWidget); + expect(find.text('Couldn’t check for updates'), findsOneWidget); + expect(find.text('Please try again.'), findsNothing); + expect(find.byType(AppLinearProgressIndicator), findsNothing); + expect(tester.getRect(checkButton), retryBounds); + expect(tester.getRect(find.byType(Switch)), retryToggleBounds); + final retry = tester.element(find.text('Retry')); + expect(DefaultTextStyle.of(retry).style.color, Theme.of(retry).colorScheme.onPrimary); + result.completeError(StateError('Still offline')); + await tester.pumpAndSettle(); + await tester.pump(const Duration(milliseconds: 350)); + expect(find.byType(AppLinearProgressIndicator), findsNothing); + expect(find.text('Retry'), findsOneWidget); + expect(tester.getRect(checkButton), retryBounds); + expect(tester.getRect(find.byType(Switch)), retryToggleBounds); + result = Completer(); + } } }); } + testWidgets('download progress, retry and staged guidance preserve the automatic setting', (tester) async { + SharedPreferences.setMockInitialValues({}); + var attempt = 0; + final download = Completer(); + final updates = UpdateController( + preferences: await SharedPreferences.getInstance(), + appInfo: () async => PackageInfo(appName: 'Papyrus', packageName: 'papyrus', version: '0.2.0', buildNumber: '6'), + channelProvider: () async => UpdateChannel.windows, + manifest: () async => { + 'tag': 'v0.3.0+7', + 'version': '0.3.0', + 'build_number': 7, + 'assets': { + 'installer': {'name': 'papyrus-v0.3.0+7-windows-x64-setup.exe', 'sha256': 'a' * 64}, + }, + }, + installer: (_, progress) async { + if (attempt++ == 0) { + throw StateError('Disk full'); + } + + progress(0.5); + return download.future; + }, + ); + + addTearDown(updates.dispose); + await updates.check(); + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.eink, + home: AppUpdatesPage(controller: updates), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Version 0.3.0 (7) is available'), findsOneWidget); + await tester.tap(find.text('Update')); + await tester.pumpAndSettle(); + expect(find.text('Couldn’t finish the update'), findsOneWidget); + await tester.tap(find.text('Retry')); + await tester.pump(); + expect(find.text('Downloading update: 50%'), findsOneWidget); + expect(tester.widget(find.byType(LinearProgressIndicator)).value, 0.5); + expect(tester.widget(find.byType(FilledButton)).onPressed, isNull); + download.complete('Close Papyrus to update'); + await tester.pumpAndSettle(); + expect(find.text('Ready to finish updating'), findsOneWidget); + expect(find.text('Close Papyrus to update'), findsOneWidget); + expect(find.byType(FilledButton), findsNothing); + expect(find.text('Check automatically'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + for (final (channel, label) in [ + (UpdateChannel.web, 'Reload'), + (UpdateChannel.apt, 'Repository setup'), + (UpdateChannel.manual, 'Downloads'), + ]) { + testWidgets('$channel offers the correct platform action', (tester) async { + SharedPreferences.setMockInitialValues({}); + final updates = UpdateController( + preferences: await SharedPreferences.getInstance(), + appInfo: () async => + PackageInfo(appName: 'Papyrus', packageName: 'papyrus', version: '0.2.0', buildNumber: '6'), + channelProvider: () async => channel, + manifest: () async => {'tag': 'v0.3.0+7', 'version': '0.3.0', 'build_number': 7}, + ); + + addTearDown(updates.dispose); + await updates.check(); + await tester.pumpWidget(MaterialApp(home: AppUpdatesPage(controller: updates))); + await tester.pumpAndSettle(); + expect(find.widgetWithText(FilledButton, label), findsOneWidget); + expect(find.text('Update'), findsNothing); + }); + } + + testWidgets('Back during a check allows reopening without losing the result', (tester) async { + SharedPreferences.setMockInitialValues({}); + final result = Completer(); + final updates = UpdateController( + preferences: await SharedPreferences.getInstance(), + appInfo: () async => PackageInfo(appName: 'Papyrus', packageName: 'papyrus', version: '0.2.0', buildNumber: '6'), + channelProvider: () async => UpdateChannel.play, + playCheck: () => result.future, + ); + + addTearDown(updates.dispose); + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.eink, + home: Builder( + builder: (context) => Scaffold( + body: TextButton( + onPressed: () => showUpdateSettings(context, updates), + child: const Text('Open updates'), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open updates')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Check for updates')); + await tester.pump(); + await tester.tap(find.byType(BackButton)); + await tester.pumpAndSettle(); + expect(updates.viewingSettings, isFalse); + result.complete(null); + await tester.pumpAndSettle(); + await tester.tap(find.text('Open updates')); + await tester.pumpAndSettle(); + expect(find.text('You’re up to date'), findsOneWidget); + expect(find.textContaining('Last checked'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + for (final scale in [1.0, 2.0]) { testWidgets('notice and dialog work on a narrow e-ink screen at text scale $scale', (tester) async { tester.view.physicalSize = const Size(360, 780); diff --git a/app/test/widgets/profile/about_settings_section_test.dart b/app/test/widgets/profile/about_settings_section_test.dart index 399c7502..6f5bd655 100644 --- a/app/test/widgets/profile/about_settings_section_test.dart +++ b/app/test/widgets/profile/about_settings_section_test.dart @@ -1,8 +1,13 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; import 'package:package_info_plus/package_info_plus.dart'; +import 'package:papyrus/pages/licenses_page.dart'; +import 'package:papyrus/services/license_packages.dart'; import 'package:papyrus/widgets/profile/about_settings_section.dart'; void main() { @@ -84,4 +89,50 @@ void main() { await tester.pumpAndSettle(); expect(find.textContaining('Could not open'), findsOneWidget); }); + + testWidgets('opening licenses keeps About visible until the list is ready', (tester) async { + final license = Completer(); + LicenseRegistry.reset(); + LicenseRegistry.addLicense(() => Stream.fromFuture(license.future)); + addTearDown(LicenseRegistry.reset); + final router = GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (context, state) => const Scaffold(body: AboutSettingsSection()), + ), + GoRoute( + path: '/profile/licenses', + pageBuilder: (context, state) => NoTransitionPage( + child: LicensesPage(initialLicenses: state.extra as LicensePackages?), + ), + ), + ], + ); + + addTearDown(router.dispose); + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Licenses')); + await tester.pump(const Duration(milliseconds: 100)); + expect(find.byType(AboutSettingsSection), findsOneWidget); + expect(find.byType(LicensesPage), findsNothing); + license.complete(const LicenseEntryWithLineBreaks(['alpha'], 'Alpha license text')); + + for (var frame = 0; frame < 3; frame++) { + await tester.pump(); + + if (find.byType(LicensesPage).evaluate().isNotEmpty) { + expect(find.widgetWithText(ListTile, 'alpha'), findsOneWidget); + expect(find.text('Loading licenses…'), findsNothing); + } + } + + expect(find.byType(LicensesPage), findsOneWidget); + await tester.pageBack(); + await tester.pumpAndSettle(); + await tester.tap(find.text('Licenses')); + await tester.pumpAndSettle(); + expect(find.widgetWithText(ListTile, 'alpha'), findsOneWidget); + }); } diff --git a/docs/about-and-support.md b/docs/about-and-support.md index ce13d922..9efeb401 100644 --- a/docs/about-and-support.md +++ b/docs/about-and-support.md @@ -1,9 +1,20 @@ # About and support +On phones, Settings opens a grouped index. Account, Appearance, Storage, +Accessibility, and About each open their own page. Desktop Settings retains its +sidebar. Settings navigation is immediate, including Back. + +About → App updates opens a dedicated page with the installed version, latest +check result, automatic-check preference, and the action appropriate to the +installation channel. Fast retries preserve the previous result while checking, +and longer checks show progress without moving the action. + About reads the installed package version and build number. What's new opens the matching `v+` release in `PapyrusReader/client`, using the release notes generated by the release workflow. Development versions without a published tag do not have release notes yet. Licenses are bundled with the app and work offline. +About prepares the license list before opening it to avoid a blank first frame. +Direct license routes retain delayed loading feedback and retry on failure. Support submits a bug report or feature request to the selected Papyrus server. No GitHub account is required. Its text, attachments, and app @@ -14,3 +25,6 @@ keep the current form text and selected files, and uncertain submissions are not Attach up to five images or videos, 10 MB per file. PNG, JPEG, GIF, WebP, MP4, MOV, and WebM are supported. Files can be removed before submission and upload only when Submit is pressed. Images and videos are embedded in the GitHub issue. + +The Support form follows Appearance → Page alignment (Left or Center). Changing +alignment preserves the draft and selected attachments. diff --git a/docs/images/settings/licenses-scrolled.png b/docs/images/settings/licenses-scrolled.png new file mode 100644 index 00000000..db9ee57a Binary files /dev/null and b/docs/images/settings/licenses-scrolled.png differ diff --git a/docs/images/settings/mobile-appearance.png b/docs/images/settings/mobile-appearance.png new file mode 100644 index 00000000..00258a9b Binary files /dev/null and b/docs/images/settings/mobile-appearance.png differ diff --git a/docs/images/settings/mobile-index.png b/docs/images/settings/mobile-index.png new file mode 100644 index 00000000..e92ce628 Binary files /dev/null and b/docs/images/settings/mobile-index.png differ diff --git a/docs/images/settings/storage-wide.png b/docs/images/settings/storage-wide.png new file mode 100644 index 00000000..ef9763a4 Binary files /dev/null and b/docs/images/settings/storage-wide.png differ diff --git a/docs/images/settings/updates-retry.png b/docs/images/settings/updates-retry.png new file mode 100644 index 00000000..172fb198 Binary files /dev/null and b/docs/images/settings/updates-retry.png differ