From 15888e94aebf011dbc803a5e40237b7471a7c541 Mon Sep 17 00:00:00 2001 From: Karolis Strazdas Date: Fri, 6 Nov 2026 12:54:54 +0200 Subject: [PATCH 1/2] feat: group settings and refine supporting pages --- app/integration_test/app_updates_test.dart | 131 ++++++++ .../mobile_settings_test.dart | 117 +++++++ app/lib/config/app_router.dart | 14 +- app/lib/pages/app_updates_page.dart | 315 ++++++++++++++++++ app/lib/pages/licenses_page.dart | 59 +++- app/lib/pages/profile_page.dart | 169 ++++++---- app/lib/pages/support_page.dart | 13 +- .../powersync/storage_sync_controller.dart | 12 + app/lib/providers/preferences_provider.dart | 2 +- app/lib/services/license_packages.dart | 15 + app/lib/themes/app_theme.dart | 5 +- app/lib/updates/update_controller.dart | 24 ++ app/lib/updates/update_settings.dart | 95 +----- .../profile/about_settings_section.dart | 35 +- .../accessibility_settings_section.dart | 5 +- .../profile/account_settings_section.dart | 12 +- .../profile/appearance_settings_section.dart | 5 +- .../storage_sync_settings_section.dart | 199 +++++------ app/lib/widgets/settings/settings_row.dart | 2 + app/test/pages/licenses_page_test.dart | 119 +++++++ app/test/pages/profile_page_test.dart | 191 ++++++----- app/test/pages/profile_storage_sync_test.dart | 158 ++------- app/test/pages/support_navigation_test.dart | 3 + app/test/pages/support_page_test.dart | 64 +++- .../storage_sync_controller_test.dart | 32 ++ app/test/updates/update_controller_test.dart | 27 ++ app/test/updates/update_settings_test.dart | 251 ++++++++++---- .../profile/about_settings_section_test.dart | 51 +++ 28 files changed, 1546 insertions(+), 579 deletions(-) create mode 100644 app/integration_test/app_updates_test.dart create mode 100644 app/integration_test/mobile_settings_test.dart create mode 100644 app/lib/pages/app_updates_page.dart create mode 100644 app/lib/services/license_packages.dart 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); + }); } From 1bc85c2eae7831cced7b18be936d3f699203dafb Mon Sep 17 00:00:00 2001 From: Karolis Strazdas Date: Fri, 6 Nov 2026 12:58:10 +0200 Subject: [PATCH 2/2] docs: describe grouped settings and add UI evidence --- docs/about-and-support.md | 14 ++++++++++++++ docs/images/settings/licenses-scrolled.png | Bin 0 -> 113891 bytes docs/images/settings/mobile-appearance.png | Bin 0 -> 43059 bytes docs/images/settings/mobile-index.png | Bin 0 -> 89792 bytes docs/images/settings/storage-wide.png | Bin 0 -> 68574 bytes docs/images/settings/updates-retry.png | Bin 0 -> 55595 bytes 6 files changed, 14 insertions(+) create mode 100644 docs/images/settings/licenses-scrolled.png create mode 100644 docs/images/settings/mobile-appearance.png create mode 100644 docs/images/settings/mobile-index.png create mode 100644 docs/images/settings/storage-wide.png create mode 100644 docs/images/settings/updates-retry.png 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 0000000000000000000000000000000000000000..db9ee57a7dad8dd508829f0de134ec176f9f7412 GIT binary patch literal 113891 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 literal 0 HcmV?d00001 diff --git a/docs/images/settings/mobile-appearance.png b/docs/images/settings/mobile-appearance.png new file mode 100644 index 0000000000000000000000000000000000000000..00258a9bddf5c9cfa536cefdd0f938ae665ed17f GIT binary patch literal 43059 zcmeFZcQ~7G*gvd8m!hqfZ>iB?)NIY#T5Z+V+9R2Q zkO(beC80(rG2YPcGv43vy#Kt%@xFgO9FE+%uIs$c`x^IopXcXuUcwFZG?>qEo?~EO zVAg!{@EHTcv2F&26ANcf(Mt^dd!y+E_O9yc2JY$_>RujRV8iFO_Kpu-eV%*R`Wr|w zFbKs2ek|8+GPuy%Xp-~U?*jL=Ym(vW@v-OX?p0oS=zYfc`%-*EZvMwsjo2HEat9%= z4nofLg4n|p6z-k*61OlnoBi_((O)7w`vxp}CMaM9J#&vw9`qcvb>gdHgQZocNNSE4 zKlQ^&4Bz>=)wGgQt^E?!nLU+_Nxd%f#)E^%@9!7y7G%U|G_ z?5&NwvKBrIZl4cv#+dOn2$-1le7(?kp^(T!So&2*uo++h(<&D|iaO-u+02?*l%9{> z>3?&adt7z5BBb#p04(?GS;kw=fTfO@8-mHrUC+PWIgwzd9{MZ}8nq1NAm~R{y%w~| zy!oc)T~z|hb6E{R_cq+H(jCc@R#mmXI75atQi@DMu!c4@CqGOY^r1|9+5r*maetHw!$5>nENs1{^z)XUCSE$YM#K^^^9R>IUbT8IFNY%&gGAzi>3u zbkfyj5T;+BVPHIViQxqO>KOf(;TR_a#~B#HTp3RO+s1+Itun)p5nF+UUrV+FFc(H&o8?@$o5v9h?-NJ$(GHIQ^UQ4HsYE=L!-M zP$*O!Dkbg(c9ys+FE1}~M^ZvkQjFe0%*P+(Yx_bBy$e?|U};{PUfI6egf*B6c+<_}#x9YH=vqmh-QOa6~l|D&kc ze~I3eyhERf{}TOgRsV`ANgS@>e_PYP8|J8#zGGC*DM|e2?xR;J{x)D>P-D=1c;D#7 zF*4z_jgc`fY*jAv*2ZKG)45l#-a4(E+uT{i0pJ{1;B$g8c2cpfMC9|0ukUO$e8tQ@ zvOLSyUXeMQvJ%+m5Ssci(`Az3olgHn>FUL?fg5OQ_o=Uuu8z3%qOGu@Z(bU!{)z$dI`^H}D^hr{k(s+~J_T#E60 z#l`+;1+%&Hx*WCrG6vBcKLaPL|ev- zFCTd2zB6CX>)J2pJ+im_Y(mz^mOawAw)q?CWTz3U)IdeR&;793kkh<6p~R^b z^3D0QU{rgqr!kbK;zdiXxNbVhC95NBTyQI)&?tsgb=SZ|+_I&gyy`|{$V6w&d@PrmmcVm8Ph%Q1Kz9&3E~y#WBI=Abcd_m+yVq7+ss5#4VDkw`wMCZAabnYt&_QIQS-he= z!hW|udEp7~vAw=~dxG$+ikJSsL&D;k%GeX36G7;-Ev(O=lqi!~Sc9C=Xc17Xjoasg zwA+N)4(mUBUXANf*TzZjrhio-*5|sD@`rLYXC6mpd!l8Lw*{>mxTxtm$#`IhK zLTFsO?ulDoKoLWy{kCf><~pHM2Yl-rw^_sN}|pV#&w z%mD#)PcWzao;7TC+qDcPtWls-9UrbrO`z~7&zo>cZITgchCC1`wx95g?twg!3)!78 zi`s7 zL*lZ~(fYBaEADo1%DC7lC=X!gv$0~fpGX5G!1l)*$~zMZT0r&tb7S?j>-Kqw0E;3} z`l)ix5qI=9l_c|?J9t8~{OpW?!s~~7l<3=WxviXXZk%}Ml|B2NP;T_rGR>~%6Ux`I z(5Qr`5Lj&D(MLcxpW1*c6E~AYEjZC@@7=BBMGTAGdXo1*<8~U=`XZ)9xGzPX@ZGVq zrGvweJt|qP~c%&yJ{RSP1}D(>9+@!@_nNBl#{O0x3ygW4WP@iYa1H5+S^+xYsG9OW;A!(J%wgRv#(MlW1 zXy04vs%y?meV)}5H#n?(gF5xMTvsz6!quk&_9AsguaC|6386HHS`}0T!9S0kZa36d z70U=%3s3Xwy?PruvQ%gD{dn!#`@#u+scHhGu(F?LpgY64)6xsW&l|W~zPr{XA^AC2 z)phcVd42Ui=6C^0IIf@SUfL+0m$4aKqEQJVSZx1(9X&ymGqv`Kj2B6|Etn3rLsr{Q z7MhmCXp7exO%7@MZuSGm{V=5oxjmmcH(ZB^RJl`~Z2BsX5*r@-=4byU>$*F z`#AL``1C1S=jx=~(6|;8nvsfT-ie6T37hnqMV`+*<760BU}a%QBiFT*5Cydi%+*cLjq*Yxen-j?L|;=Kiau3U z=B3S#qk z@c|cjWW-dPkur{eZ;lUQG~_yZdJ^+MK})>@eR@FaWNtx1v2qFuvB493mej?8Yd*aDFqI?q~j`_1@CPO*P91v&_ zkU^d5Q`uHB$%E{ToyDnlcMX&n-o>zyY;ls*CD{+@FMixEchpYg@4~;%iAZYIQ&}~_ zHr35_PL>EdWN3LJd2*>jJ%xl`Zo8=fdExzs)7FI2(<{Ma#ld~bb*=2()?az&r!y8j zVrU1{&!mgvJH>fd)91QBu_-QmQ@59?!b*Ssyp=Szyx!OFkNXHxmn=+4ne*rlXOGyE z^txZ9u@y*=Ro!-Z0~deV3rik_qks$QTdJ43dTxJxCzeBVR9$!Ki>4GRe+S_VcUNh5 zO9D1B;QQXCBpHOysJ~lzt@vnDWuRnR7M!~f^p@MXFO7?YG~F^Q{^Vz0%KFcONy=F1 zz|`J9?vq6c71!2A`xoXWW-?dmIoz|Ax7m9v+j4bN5EEE)DW^6C$iiiJT(&S)Y){#i#AsqDmT;gliXK znFi6$`Dv&X$Te{DyNiQJf1h#Z6%0UMvvLdBhCp|T;}kh_+E4Sz}{d3j5jkphgeBY3^Emj75Cj;$&s^tUFs0j$E_*ov%Q&}kAy~;j)2Oj36_+-Vs zbR^DMasupM3%hxWB7;{+hjreT)Pe)I_DI&aiub&her=1D9!;&xvoH6ypvpQVZ;o^@ z7(B6Wz9`<-KIGM~Wh}#KWqw zy#H$ZCo^1}_&iXtio_1_z}1IeBNQfiOw>G$X4My}v>cVHwr?;g&5EL24eoJOX7gL} z--rPRu1pSM;863j=eG4l7G^8KI~$AO&iuU2fi`aAnlzlX?=QxZlB|Y3lLoAPc|NYO zzxVfPdbImi4RK_DITu<%*4?pGUbS2NlCyXqkmZ%eOZrG6%lpi}Ji+qu zR3wR{=vj{`G%74oPYkPNbDe~*VB#f?b3Lw%gW#&Lwwg>u3)#UdgSH$T^%3k6iQ(+^ zbv?}v*PmuRW|rSAYr0es+vwR|ZU*@<*w!P?OR4J{jC*1MIqG37 ztK00&o5HuxI51f#db=Q5FyiKwxBO9FRc^-FdV3bj4{uqjctgGxKa6HW_L!b55<6<=SJ|O*&%-Jy%XSlEG^!l}SlY0UOMjkFDaTZ$9q>8-qcLz3%gRa`Esi_jC&#(Z9iz{0#Y+q&o9Lh% zF(;PVvEHXDGM{l^%Avwb@5BI1y>d5&V{)Fe>HoGN{peBPYt1247PNU;mrF_=@bg|clrm7?HRKw`uxyiYCS zNCqQs>}07It+jAix9iuHn8Q4VN8K^%idmx%s%!l4VW$=C$BxUaX7bmizu!sIMWvU?-NDk|O0sqHc&P(1i=2FhJz?CnL zBEIOa{&Vc)pcS9ziJ=KHvny7Qo7mD2NL{Ww(8{ zG`Z8D>OXyX`@VV;$mDl)@cvMGKwNiJRIZB>(g0UiBGS%Z68*X((Ao zM@O_Aj8uF%y@)*epdC>iSoV2fCI~jJ9_rEmyQBYOH6OD7!?KUvo{7_GimgRx?YxZh zw0i&g><-B*o^_-3yzEryQ;Ne?rUGGOVQphf{jGrr?V`emjehFeSMT~S=g$*kGZk?;VQL zQ>@KT9L=h^ATh9McIMbg6k6@<6f=qz#4qQPIbLpfnz@H#wtl@As@&7}I7lD~2&+}v z8;%2g72nZRH7~o&dwxifHdYl;Y*`g6pfKxv>}oohd8FiKr9EI}$vjT{P8QRGJ(B=B z%G-U29$#grHEiQ#Vh1`pas7FW)+0;CU-bET@l}~9>v7I|qY+gdB`MtlasgZr3hl@G zPCX*y(}F+0HYmu())6yQzpGTcx2dv*LJ)D-VUW@EFjue|!kce0{lmgJ&d(4o>l_T(Jf z@iXzvA(gB;_P&|5mZujBGgJOS>}?F`%ScPanLxJ;%CZxx*`9g?CJ4LhORhDzqxSftu&oL2_J_86p2yZQ z4tCpm%7LG*R3jnC55U~(E5W}*xFnSqzMYuKk$#CIh2NDma=Gtmoxm<}?SQyXx7{_W z)Mlx*sU+)>>+M!@isPvx2=ks#G6W^85AxfvBuqZ+L%#yZ{oVML54$r<1*Ed;&h9G; z3f&}uK(U9aYUfRFDx!AaI-{Arq8Yctt_74?0uJ`~9GeuF?fra$6tFz=b90NU8``rr z0MLif0>xvRfvhSEHqLuEF0UGQX~J%El=)3FJaeEmkN@wdDR=brM+X~IE~tI>I1`_L zUJRC?E0-IHR@r|djwYXD?z!T6@zctJ=Zc5W$mDa%2QwxXIGgxJ)-MO= zH@K_Gqg`-~Fi9#%*0SD?{0|lYJHPSz7w=M89`Hw|*YGB7&z84yirR$~yVa|(u9FWe zw8&&Aw;i_Nw1U)cO$JX2ikOxRSG@71?u05+@)p67Ku$ZsU*yKAAlOq3;+HG!D=pr* z_KOvGparB#ezmtX$vqd`V{bIKm*(~1&ZHZpKDWpDrU0y8#Fa<9lPGEYRwb4>&;z)nrXKrupu5Y zgh*fd4L_~@n!CE6?4sP${<{bx4}ff6S!x-}Du%*(H9Ea_m#y{MgE=6S3iNiA%2vNH|!m z3UbcynPYGJF8YQ74lksFq)gq+m|^Z^q_?0kmo1aM0dCc*uE)#u710jH1<6X@oj>+! zW};&>;{-cyMynK%RIt=nVi%IcErn-VBcW|ny#aSNFAT-0M^gEiBf8v z5wJh5*W>bM`!*Kw8bRGpi0*!cf z<-5cmOyVDdO7~51P#4ob%$9Iph<2SNs8$oc8c66l<+Pa6k+GB`5+|r1!|jKTUP>tl z%m~(Vd!&+V=7R8^v!1<;@k9U~+b0E%eOzu1XCqEWh{uNUz|=TNk@I5B6o*d>M!I3% z#msE#wFC3y^0%gOPP)D?wjJd)yril%)dxbSDGH|O)g6)2dh+Iw%8RjR4Ev=x?yjmb zudy=Q&I%Wb4iMi!D7vmN?(Wftfh1vpTzsZhLA91E9M-$~kE3?2)`mAHeEJ4bZ3NV{pmQ^L7QN*5O2=51H&t8sY-fLxInsxEQb?!_1 zf4cUf|Nh{YM@;_u`ic3OO;xh9buI&TSdl6&&f${0B&mh?C1$*16iX9s9i>a^!CkFn z7ruh@yZH#^PST3+FwaP>M-25&`pI$i6Mbao>aIJGu0D=-v?%SLASV~2O$JR&x*hx~ zI-)X51`&nSX%e<{K2(!}x4I;_muzb%DWYgN_F(vx2xo4%BdThz8A1_%(nZ}dB!%Oi zLyPMe+uRH{F0c_zip_3zQ9rn*cK7)&%ouHg?(g4c2=Tq#F7|>;t1S?}sWP4#m40Sp zNu;!04dcI=33I*g`ZkB+X_5%!Nak)-3re+hWt27$F%puvBJ3tb@58YOfm+V(fxg>% zAXfIQ{wdy#Nw=;@m&)Z<yH2_Q0f5pmWSy9-3I>DUGb} zDNWAF=z2d~af@1Q%)jLB%!W6Od$iT2;$ep708}$(6E6QqsNR!s!i{dS;ftSE)N#Qe zh3|$7la1@l!@(nkBz|E?*o{;z1MvpFgBvf^6wbvjev>wU+ap&#)NhY+rF%_B7D$%? zT(V!->s6`pi_Iiz2MURgEX@d@^c%mBOUH9X40V$83Y-hJ^MxIn8e4P+5ReW3>ivZe zH=?!c0;_|Vm8KT4iR%QGuA48v@k$uXofvwjC3WlIodD8S6?&RykD@q!Cp&?F3m0!7 zIrtyZsN5bRU2S&`J}|pD<8e9v*tbQ-CBm+Ou>i%~YXxE!!CVgj7+7RLi8BEg*#zHM z_$3Y)Gm$72E~;ssc>j=@eIoYx#7mtc0^tl_nb@)8K9&!Z zqK|0Wmvj!e{`_A)SSW-c#QgXz_9Gd!m)~Y(j+o(>YG>&z?9kqU;84)-H|m?7lSnG4DMYIYa@il5N| zp7?uH@pNsX$1X+u6<4!a<2+)G)jpr3^U!BMnw|Ws@OT{dZ;!|M>6Eng%jO$@#py#h z^}j>-f4K^s4{e+r-Z{i$dG~tW98Is9>lu%&Z#tnFj6Y?AuDuJL{Uqb=xSMPDeK<|{fU&?S)WYzPD*$it}O zH0XZq4GX6qZ_a|(Zs$EY2-=m(J3FUU3ns>5G*gobue%+3nTMN8h&hD9nS7U`ChLha z3T5HGR9d*nV_Ir<6M*)MyBV$Yj3?kvP|WV}Q$3F@Cqq3wwKg6TW)^@X$rj>QQoY;U zq(K)&>OH%WmS36=wR9nR_-}ZF&4Kfds0%j=C