diff --git a/app/lib/pages/profile_page.dart b/app/lib/pages/profile_page.dart index 0173ae3..0f3a38a 100644 --- a/app/lib/pages/profile_page.dart +++ b/app/lib/pages/profile_page.dart @@ -3,22 +3,22 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; 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/powersync/powersync_service.dart'; -import 'package:papyrus/powersync/storage_sync_controller.dart'; import 'package:papyrus/providers/auth_provider.dart'; -import 'package:papyrus/providers/acquisition_availability_provider.dart'; -import 'package:papyrus/providers/preferences_provider.dart'; -import 'package:papyrus/providers/sync_settings_provider.dart'; -import 'package:papyrus/powersync/sync_state.dart'; -import 'package:papyrus/services/book_import_service_stub.dart' - if (dart.library.js_interop) 'package:papyrus/services/book_import_service.dart'; +import 'package:papyrus/themes/app_motion.dart'; import 'package:papyrus/themes/design_tokens.dart'; -import 'package:papyrus/widgets/settings/settings_row.dart'; -import 'package:papyrus/widgets/settings/settings_section.dart'; +import 'package:papyrus/widgets/profile/about_settings_section.dart'; +import 'package:papyrus/widgets/profile/accessibility_settings_section.dart'; +import 'package:papyrus/widgets/profile/account_settings_section.dart'; +import 'package:papyrus/widgets/profile/appearance_settings_section.dart'; +import 'package:papyrus/widgets/profile/developer_settings_section.dart'; +import 'package:papyrus/widgets/profile/library_settings_section.dart'; +import 'package:papyrus/widgets/profile/notifications_settings_section.dart'; +import 'package:papyrus/widgets/profile/privacy_settings_section.dart'; +import 'package:papyrus/widgets/profile/profile_header.dart'; +import 'package:papyrus/widgets/profile/profile_menu_item.dart'; +import 'package:papyrus/widgets/profile/reading_settings_section.dart'; +import 'package:papyrus/widgets/profile/storage_sync_settings_section.dart'; import 'package:provider/provider.dart'; -import 'package:papyrus/themes/app_motion.dart'; enum _ProfileSection { account, @@ -68,20 +68,20 @@ class _ProfilePageState extends State { padding: const EdgeInsets.all(Spacing.md), children: [ _buildMobileHeader(context), - _buildMobileAccountSection(context), - _buildMobileAppearanceSection(context), - _buildMobileReadingSection(context), - _buildMobileLibrarySection(context), - _buildMobileNotificationsSection(context), - _buildMobileStorageSyncSection(context), - _buildMobileAcquisitionSection(context), - _buildMobilePrivacyDataSection(context), - _buildMobileAccessibilitySection(context), - if (kDebugMode) _buildMobileDeveloperSection(context), - _buildMobileAboutSection(context), + const SizedBox(height: Spacing.md), + const AccountSettingsSection(isDesktop: false), + const AppearanceSettingsSection(isDesktop: false), + const ReadingSettingsSection(isDesktop: false), + const LibrarySettingsSection(isDesktop: false), + const NotificationsSettingsSection(isDesktop: false), + const StorageSyncSettingsSection(isDesktop: false), + StorageSyncSettingsSection.buildMobileAcquisitionSection(context), + const PrivacySettingsSection(isDesktop: false), + const AccessibilitySettingsSection(isDesktop: false), + if (kDebugMode) const DeveloperSettingsSection(isDesktop: false), + const AboutSettingsSection(isDesktop: false), const Divider(height: 1), - _buildMenuItem( - context, + ProfileMenuItem( icon: Icons.logout, label: 'Log out', isDestructive: true, @@ -95,252 +95,18 @@ class _ProfilePageState extends State { ); } - Widget _buildMobileAccountSection(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SettingsSectionHeader(title: 'Account'), - SettingsRow(label: 'Change password', onTap: () {}), - SettingsRow(label: 'Linked accounts', onTap: () {}), - SettingsRow(label: 'Two-factor authentication', onTap: () {}), - SettingsRow(label: 'Active sessions', onTap: () {}), - SettingsRow(label: 'Delete account', onTap: () {}), - ], - ); - } - - Widget _buildMobileAppearanceSection(BuildContext context) { - final prefs = context.watch(); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SettingsSectionHeader(title: 'Appearance'), - SettingsRow(label: 'Theme', value: _getThemeLabel(prefs.themeModePref), onTap: () => _showThemePicker(context)), - ], - ); - } - - Widget _buildMobileReadingSection(BuildContext context) { - final prefs = context.watch(); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SettingsSectionHeader(title: 'Reading'), - SettingsRow(label: 'Default font', value: prefs.defaultFont, onTap: () => _showFontPicker(context)), - SettingsRow( - label: 'Line spacing', - value: _capitalize(prefs.lineSpacing), - onTap: () => _showLineSpacingPicker(context), - ), - SettingsRow( - label: 'Reading mode', - value: _capitalize(prefs.readingMode), - onTap: () => _showReadingModePicker(context), - ), - SettingsToggleRow( - label: 'Page turn animation', - value: prefs.pageTurnAnimation, - onChanged: (value) => prefs.pageTurnAnimation = value, - ), - SettingsRow(label: 'Reading profiles', onTap: () {}), - ], - ); - } - - Widget _buildMobileLibrarySection(BuildContext context) { - final prefs = context.watch(); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SettingsSectionHeader(title: 'Library'), - SettingsRow( - label: 'Default view', - value: _capitalize(prefs.defaultViewMode), - onTap: () => _showViewModePicker(context), - ), - SettingsRow( - label: 'Default sort', - value: _getSortOrderLabel(prefs.defaultSortOrder), - onTap: () => _showSortOrderPicker(context), - ), - SettingsRow( - label: 'Metadata source', - value: prefs.metadataSource, - onTap: () => _showMetadataSourcePicker(context), - ), - SettingsRow( - label: 'Export format', - value: prefs.annotationExportFormat, - onTap: () => _showExportFormatPicker(context), - ), - ], - ); - } - - Widget _buildMobileNotificationsSection(BuildContext context) { - final prefs = context.watch(); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SettingsSectionHeader(title: 'Notifications'), - SettingsToggleRow( - label: 'Goal reminders', - value: prefs.goalReminders, - onChanged: (value) => prefs.goalReminders = value, - ), - SettingsToggleRow( - label: 'Streak alerts', - value: prefs.streakAlerts, - onChanged: (value) => prefs.streakAlerts = value, - ), - SettingsToggleRow( - label: 'Sync status', - value: prefs.syncStatusNotifications, - onChanged: (value) => prefs.syncStatusNotifications = value, - ), - ], - ); - } - - Widget _buildMobileStorageSyncSection(BuildContext context) { - final controller = _storageSyncController(context); - - if (controller.isGuest) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - 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', - onTap: () => _showOfflineBackupActions(context), - ), - SettingsRow(label: 'Clear local library', onTap: () => _confirmClearLocalLibrary(context)), - ], - ); - } - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - 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), - if (controller.hasFailedMediaUploads) - SettingsRow( - label: 'Media uploads', - value: controller.failedMediaUploadLabel, - onTap: () => _retryFailedMediaUploads(context), - ), - SettingsRow(label: 'Manage servers', onTap: () => _showManageSyncServersSheet(context)), - if (controller.canReconnect) SettingsRow(label: 'Reconnect', onTap: () => _handleReconnectSync(context)), - if (controller.canClearGuestLibrary) - SettingsRow(label: 'Clear local library', onTap: () => _confirmClearLocalLibrary(context)), - if (controller.canClearAuthenticatedCache) - SettingsRow(label: 'Clear local copy', onTap: () => _confirmClearAuthenticatedCache(context)), - ], - ); - } - - Widget _buildMobileAcquisitionSection(BuildContext context) { - if (_storageSyncController(context).isGuest) return const SizedBox.shrink(); - - return Column( - key: const Key('profile-acquisition-section'), - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SettingsSectionHeader(title: 'Acquisition'), - _buildAcquisitionDescription(context), - ..._buildAcquisitionSettings(context), - ], - ); - } - - Widget _buildMobilePrivacyDataSection(BuildContext context) { - final prefs = context.watch(); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SettingsSectionHeader(title: 'Privacy & data'), - SettingsToggleRow( - label: 'Analytics', - value: prefs.analyticsOptIn, - onChanged: (value) => prefs.analyticsOptIn = value, - ), - SettingsRow(label: 'Privacy policy', onTap: () {}), - SettingsRow(label: 'Export all data', onTap: () {}), - SettingsRow(label: 'Import data', onTap: () {}), - SettingsRow(label: 'Clear local data', onTap: () {}), - ], - ); - } - - Widget _buildMobileAccessibilitySection(BuildContext context) { - final prefs = context.watch(); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SettingsSectionHeader(title: 'Accessibility'), - SettingsToggleRow( - label: 'Reduce animations', - value: prefs.reduceAnimations, - onChanged: (value) => prefs.reduceAnimations = value, - ), - SettingsToggleRow( - label: 'Dyslexia-friendly font', - value: prefs.dyslexiaFont, - onChanged: (value) => prefs.dyslexiaFont = value, - ), - ], - ); - } - - Widget _buildMobileAboutSection(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SettingsSectionHeader(title: 'About'), - SettingsRow(label: 'Version', value: '1.0.0', showChevron: false), - SettingsRow(label: 'Licenses', onTap: () => _showLicenses(context)), - SettingsRow(label: 'Support', onTap: () {}), - SettingsRow(label: "What's new", onTap: () {}), - SettingsRow(label: 'GitHub repository', onTap: () {}), - ], - ); - } - - Widget _buildMobileDeveloperSection(BuildContext context) { - context.watch(); + Widget _buildMobileHeader(BuildContext context) { + final user = context.watch().user; + final displayName = user?.displayName ?? 'Anonymous User'; + final userEmail = user?.email; + final email = (userEmail != null && userEmail.trim().isNotEmpty) ? userEmail : 'No email provided'; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SettingsSectionHeader(title: 'Developer options'), - SettingsRow(label: 'Reload sample data', onTap: () {}), - SettingsRow(label: 'Reset onboarding', onTap: () {}), - SettingsRow(label: 'Performance overlay', onTap: () {}), - ], + return ProfileHeader( + displayName: displayName, + email: email, + avatarUrl: user?.avatarUrl, + onEditProfile: () => context.go('/profile/edit'), + isDesktopLayout: false, ); } @@ -553,7 +319,7 @@ class _ProfilePageState extends State { case _ProfileSection.storageSync: return 'Storage'; case _ProfileSection.privacyData: - return 'Privacy'; + return 'Privacy & data'; case _ProfileSection.accessibility: return 'Accessibility'; case _ProfileSection.developerOptions: @@ -566,745 +332,30 @@ class _ProfilePageState extends State { Widget _buildSectionContent(BuildContext context) { switch (_selectedSection) { case _ProfileSection.account: - return _buildAccountContent(context); + return const AccountSettingsSection(isDesktop: true); case _ProfileSection.appearance: - return _buildAppearanceContent(context); + return const AppearanceSettingsSection(isDesktop: true); case _ProfileSection.reading: - return _buildReadingContent(context); + return const ReadingSettingsSection(isDesktop: true); case _ProfileSection.library: - return _buildLibraryContent(context); + return const LibrarySettingsSection(isDesktop: true); case _ProfileSection.notifications: - return _buildNotificationsContent(context); + return const NotificationsSettingsSection(isDesktop: true); case _ProfileSection.storageSync: - return _buildStorageSyncContent(context); + return const StorageSyncSettingsSection(isDesktop: true); case _ProfileSection.privacyData: - return _buildPrivacyDataContent(context); + return const PrivacySettingsSection(isDesktop: true); case _ProfileSection.accessibility: - return _buildAccessibilityContent(context); + return const AccessibilitySettingsSection(isDesktop: true); case _ProfileSection.about: - return _buildAboutContent(context); + return const AboutSettingsSection(isDesktop: true); case _ProfileSection.developerOptions: - return _buildDeveloperOptionsContent(context); + return const DeveloperSettingsSection(isDesktop: true); } } - // -- Account ---------------------------------------------------------------- - - Widget _buildAccountContent(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final textTheme = Theme.of(context).textTheme; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SettingsCard( - children: [ - Row( - children: [ - _buildAvatar(context, size: 96), - const SizedBox(width: Spacing.lg), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(_getDisplayName(), style: textTheme.headlineSmall), - const SizedBox(height: Spacing.xs), - Text(_getEmail(), style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)), - const SizedBox(height: Spacing.md), - Align( - alignment: Alignment.centerLeft, - child: OutlinedButton( - onPressed: () => _navigateToEditProfile(context), - child: const Text('Edit profile'), - ), - ), - ], - ), - ), - ], - ), - ], - ), - const SizedBox(height: Spacing.lg), - SettingsCard( - title: 'Security', - children: [ - SettingsRow(label: 'Change password', onTap: () {}), - SettingsRow(label: 'Two-factor authentication', onTap: () {}), - SettingsRow(label: 'Active sessions', onTap: () {}), - ], - ), - const SizedBox(height: Spacing.lg), - SettingsCard( - title: 'Connected accounts', - children: [ - SettingsRow(label: 'Google', value: _isGoogleLinked() ? 'Connected' : 'Not connected', onTap: () {}), - ], - ), - const SizedBox(height: Spacing.lg), - SettingsCard( - title: 'Danger zone', - children: [SettingsRow(label: 'Delete account', onTap: () {})], - ), - ], - ); - } - - // -- Appearance ------------------------------------------------------------- - - Widget _buildAppearanceContent(BuildContext context) { - return SettingsCard(children: [_buildThemeRadioGroup(context)]); - } - - Widget _buildThemeRadioGroup(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final textTheme = Theme.of(context).textTheme; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Theme', style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)), - const SizedBox(height: Spacing.sm), - _buildRadioTile('Light', 'light'), - _buildRadioTile('Dark', 'dark'), - _buildRadioTile('E-ink', 'eink'), - _buildRadioTile('System', 'system'), - ], - ); - } - - Widget _buildRadioTile(String label, String value) { - final prefs = context.watch(); - final isSelected = prefs.themeModePref == value; - - return InkWell( - onTap: () => prefs.themeModePref = value, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: Spacing.sm), - child: Row( - children: [ - Container( - width: 20, - height: 20, - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all( - color: isSelected ? Theme.of(context).colorScheme.primary : Theme.of(context).colorScheme.outline, - width: 2, - ), - ), - child: isSelected - ? Center( - child: Container( - width: 10, - height: 10, - decoration: BoxDecoration(shape: BoxShape.circle, color: Theme.of(context).colorScheme.primary), - ), - ) - : null, - ), - const SizedBox(width: Spacing.md), - Text(label, style: Theme.of(context).textTheme.bodyLarge), - ], - ), - ), - ); - } - - // -- Reading ---------------------------------------------------------------- - - Widget _buildReadingContent(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final textTheme = Theme.of(context).textTheme; - final prefs = context.watch(); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SettingsCard( - title: 'Typography', - children: [ - _buildDropdownField( - context, - label: 'Default font', - value: prefs.defaultFont, - options: [ - 'Georgia', - 'Literata', - 'Bookerly', - 'Merriweather', - 'Noto Serif', - 'Atkinson Hyperlegible', - 'Open Dyslexic', - ], - onChanged: (value) => prefs.defaultFont = value, - ), - const SizedBox(height: Spacing.lg), - Text('Default font size', style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)), - Row( - children: [ - Expanded( - child: Slider( - value: prefs.defaultFontSize, - min: 10, - max: 32, - divisions: 22, - onChanged: (value) => prefs.defaultFontSize = value, - ), - ), - SizedBox(width: 48, child: Text('${prefs.defaultFontSize.toInt()}px', style: textTheme.bodyMedium)), - ], - ), - const SizedBox(height: Spacing.md), - _buildSegmentedField( - context, - label: 'Line spacing', - value: prefs.lineSpacing, - options: const {'compact': 'Compact', 'normal': 'Normal', 'relaxed': 'Relaxed'}, - onChanged: (value) => prefs.lineSpacing = value, - ), - const SizedBox(height: Spacing.md), - _buildSegmentedField( - context, - label: 'Text alignment', - value: prefs.textAlignment, - options: const {'left': 'Left', 'justify': 'Justify'}, - onChanged: (value) => prefs.textAlignment = value, - ), - const SizedBox(height: Spacing.md), - _buildSegmentedField( - context, - label: 'Margins', - value: prefs.margins, - options: const {'small': 'Small', 'medium': 'Medium', 'large': 'Large'}, - onChanged: (value) => prefs.margins = value, - ), - ], - ), - const SizedBox(height: Spacing.lg), - SettingsCard( - title: 'Behavior', - children: [ - _buildSegmentedField( - context, - label: 'Reading mode', - value: prefs.readingMode, - options: const {'paginated': 'Paginated', 'scroll': 'Continuous scroll'}, - onChanged: (value) => prefs.readingMode = value, - ), - const SizedBox(height: Spacing.md), - SettingsToggleRow( - label: 'Page turn animation', - value: prefs.pageTurnAnimation, - onChanged: (value) => prefs.pageTurnAnimation = value, - ), - ], - ), - const SizedBox(height: Spacing.lg), - SettingsCard(title: 'Annotations', children: [_buildHighlightColorField(context)]), - const SizedBox(height: Spacing.lg), - SettingsCard( - children: [SettingsRow(label: 'Reading profiles', onTap: () {})], - ), - ], - ); - } - - Widget _buildHighlightColorField(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final textTheme = Theme.of(context).textTheme; - final prefs = context.watch(); - - const highlightColors = { - 'yellow': Color(0xFFFFF176), - 'green': Color(0xFFA5D6A7), - 'blue': Color(0xFF90CAF9), - 'pink': Color(0xFFF48FB1), - 'orange': Color(0xFFFFCC80), - }; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Default highlight color', style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)), - const SizedBox(height: Spacing.sm), - Row( - children: highlightColors.entries.map((entry) { - final isSelected = prefs.defaultHighlightColor == entry.key; - return Padding( - padding: const EdgeInsets.only(right: Spacing.sm), - child: GestureDetector( - onTap: () => prefs.defaultHighlightColor = entry.key, - child: Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: entry.value, - shape: BoxShape.circle, - border: isSelected - ? Border.all(color: colorScheme.primary, width: 3) - : Border.all(color: colorScheme.outline, width: 1), - ), - child: isSelected ? Icon(Icons.check, size: 18, color: colorScheme.primary) : null, - ), - ), - ); - }).toList(), - ), - ], - ); - } - - // -- Library ---------------------------------------------------------------- - - Widget _buildLibraryContent(BuildContext context) { - final prefs = context.watch(); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SettingsCard( - title: 'Display', - children: [ - _buildSegmentedField( - context, - label: 'Default view mode', - value: prefs.defaultViewMode, - options: const {'grid': 'Grid', 'list': 'List', 'compact': 'Compact'}, - onChanged: (value) => prefs.defaultViewMode = value, - ), - const SizedBox(height: Spacing.md), - _buildDropdownField( - context, - label: 'Default sort order', - value: prefs.defaultSortOrder, - options: const ['title', 'author', 'date_added', 'last_read', 'rating'], - labels: const { - 'title': 'Title', - 'author': 'Author', - 'date_added': 'Date added', - 'last_read': 'Last read', - 'rating': 'Rating', - }, - onChanged: (value) => prefs.defaultSortOrder = value, - ), - ], - ), - const SizedBox(height: Spacing.lg), - SettingsCard( - title: 'Data', - children: [ - _buildSegmentedField( - context, - label: 'Metadata source', - value: prefs.metadataSource, - options: const {'Open Library': 'Open Library', 'Google Books': 'Google Books'}, - onChanged: (value) => prefs.metadataSource = value, - ), - const SizedBox(height: Spacing.md), - _buildDropdownField( - context, - label: 'Annotation export format', - value: prefs.annotationExportFormat, - options: const ['Markdown', 'PDF', 'TXT', 'HTML'], - onChanged: (value) => prefs.annotationExportFormat = value, - ), - ], - ), - ], - ); - } - - // -- Notifications ---------------------------------------------------------- - - Widget _buildNotificationsContent(BuildContext context) { - final prefs = context.watch(); - - return SettingsCard( - children: [ - SettingsToggleRow( - label: 'Goal reminders', - value: prefs.goalReminders, - onChanged: (value) => prefs.goalReminders = value, - ), - SettingsToggleRow( - label: 'Streak alerts', - value: prefs.streakAlerts, - onChanged: (value) => prefs.streakAlerts = value, - ), - SettingsToggleRow( - label: 'Sync status', - value: prefs.syncStatusNotifications, - onChanged: (value) => prefs.syncStatusNotifications = value, - ), - ], - ); - } - - // -- Storage --------------------------------------------------------- - - Widget _buildStorageSyncContent(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final textTheme = Theme.of(context).textTheme; - final controller = _storageSyncController(context); - - if (controller.isGuest) return _buildOfflineStorageSyncContent(context); - - return Column( - children: [ - SettingsCard( - key: const Key('profile-data-sync-card'), - title: 'Data sync', - 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), - Padding( - padding: const EdgeInsets.symmetric(horizontal: Spacing.sm, vertical: Spacing.xs), - child: Text( - controller.syncDetail, - 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) - 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 _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), - ), - ); - } - - 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')), - ]; - } - - 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…', - }; - } - - Widget _buildOfflineStorageSyncContent(BuildContext context) { - final textTheme = Theme.of(context).textTheme; - final mutedStyle = textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant); - - return SettingsCard( - title: 'Library storage', - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: Spacing.sm), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - 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.', - style: mutedStyle, - ), - ], - ), - ), - const SizedBox(height: Spacing.md), - Wrap( - spacing: Spacing.sm, - runSpacing: Spacing.sm, - children: [ - OutlinedButton.icon( - onPressed: () => _showBackupUnavailable(context, 'Backup export'), - icon: const Icon(Icons.file_download_outlined, size: IconSizes.small), - label: const Text('Export backup'), - ), - OutlinedButton.icon( - onPressed: () => _showBackupUnavailable(context, 'Backup import'), - icon: const Icon(Icons.file_upload_outlined, size: IconSizes.small), - label: const Text('Import backup'), - ), - OutlinedButton.icon( - onPressed: () => _confirmClearLocalLibrary(context), - icon: const Icon(Icons.delete_outline, size: IconSizes.small), - label: const Text('Clear local library'), - ), - ], - ), - ], - ); - } - - StorageSyncController _storageSyncController(BuildContext context) { - return StorageSyncController( - authProvider: context.watch(), - powerSyncService: context.read(), - syncSettings: context.watch(), - syncState: context.watch(), - fileStorageUsedBytes: _fileStorageUsedBytes(context.watch()), - mediaStorageUsage: context.watch().storageUsage, - failedMediaUploadCount: _failedMediaUploadCount(context.watch()), - ); - } - - int _fileStorageUsedBytes(DataStore dataStore) { - return dataStore.books.fold(0, (total, book) => total + (book.fileSize ?? 0)); - } - - int _failedMediaUploadCount(MediaUploadQueue queue) { - return queue.pendingTasks.where((task) => task.status == MediaUploadTaskStatus.failed).length; - } - - Widget _buildInfoRow(BuildContext context, {required String label, required String value}) { - final colorScheme = Theme.of(context).colorScheme; - final textTheme = Theme.of(context).textTheme; - - return Padding( - padding: const EdgeInsets.symmetric(horizontal: Spacing.sm, vertical: Spacing.xs), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 160, - child: Text(label, style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)), - ), - Expanded(child: SelectableText(value, style: textTheme.bodyMedium)), - ], - ), - ); - } - - // -- Privacy & data --------------------------------------------------------- - - Widget _buildPrivacyDataContent(BuildContext context) { - final prefs = context.watch(); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SettingsCard( - title: 'Analytics', - children: [ - SettingsToggleRow( - label: 'Send anonymous usage data', - value: prefs.analyticsOptIn, - onChanged: (value) => prefs.analyticsOptIn = value, - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: Spacing.sm), - child: Text( - 'Help improve Papyrus by sharing anonymous usage statistics. ' - 'No personal data or reading content is collected.', - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), - ), - ), - ], - ), - const SizedBox(height: Spacing.lg), - SettingsCard( - title: 'Data management', - children: [ - SettingsRow(label: 'Export all data', onTap: () {}), - SettingsRow(label: 'Import data', onTap: () {}), - SettingsRow(label: 'Clear local data', onTap: () {}), - ], - ), - const SizedBox(height: Spacing.lg), - SettingsCard( - title: 'Legal', - children: [SettingsRow(label: 'Privacy policy', onTap: () {})], - ), - ], - ); - } - - // -- Accessibility ---------------------------------------------------------- - - Widget _buildAccessibilityContent(BuildContext context) { - final prefs = context.watch(); - - return SettingsCard( - children: [ - SettingsToggleRow( - label: 'Reduce animations', - value: prefs.reduceAnimations, - onChanged: (value) => prefs.reduceAnimations = value, - ), - Padding( - padding: const EdgeInsets.only(left: Spacing.sm, right: Spacing.sm, bottom: Spacing.md), - child: Text( - 'Minimizes motion effects throughout the app. ' - 'Separate from e-ink mode.', - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), - ), - ), - SettingsToggleRow( - label: 'Dyslexia-friendly font', - value: prefs.dyslexiaFont, - onChanged: (value) => prefs.dyslexiaFont = value, - ), - Padding( - padding: const EdgeInsets.only(left: Spacing.sm, right: Spacing.sm, bottom: Spacing.md), - child: Text( - 'Use OpenDyslexic font across the app interface.', - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), - ), - ), - ], - ); - } - - // -- About ------------------------------------------------------------------ - - Widget _buildAboutContent(BuildContext context) { - return SettingsCard( - children: [ - SettingsRow(label: 'Version', value: '1.0.0', showChevron: false), - SettingsRow(label: "What's new", onTap: () {}), - SettingsRow(label: 'Licenses', onTap: () => _showLicenses(context)), - SettingsRow(label: 'Support', onTap: () {}), - SettingsRow(label: 'GitHub repository', onTap: () {}), - ], - ); - } - - // -- Developer options ------------------------------------------------------ - - Widget _buildDeveloperOptionsContent(BuildContext context) { - context.watch(); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SettingsCard( - title: 'Debug', - children: [ - SettingsRow(label: 'Reload sample data', onTap: () {}), - SettingsRow(label: 'Reset onboarding', onTap: () {}), - SettingsRow(label: 'Performance overlay', onTap: () {}), - ], - ), - ], - ); - } - // ============================================================================ - // USER DATA - // ============================================================================ - - String _getDisplayName() { - final user = context.watch().user; - return user?.displayName ?? 'Anonymous User'; - } - - String _getEmail() { - final email = context.watch().user?.email; - if (email == null || email.trim().isEmpty) return 'No email provided'; - return email; - } - - String? _getAvatarUrl() { - return context.watch().user?.avatarUrl; - } - - String get _initials { - final name = _getDisplayName(); - final parts = name.split(' '); - if (parts.length >= 2) { - return '${parts[0][0]}${parts[1][0]}'.toUpperCase(); - } - return name.isNotEmpty ? name[0].toUpperCase() : '?'; - } - - bool _isGoogleLinked() { - return false; - } - - // ============================================================================ - // ACTIONS + // LOGOUT // ============================================================================ Future _handleLogout(BuildContext context) async { @@ -1317,14 +368,14 @@ class _ProfilePageState extends State { showDialog( animationStyle: AppMotion.animationStyle(context), context: context, - builder: (context) => AlertDialog( + builder: (dialogContext) => AlertDialog( title: const Text('Log out'), content: const Text('Are you sure you want to log out?'), actions: [ - TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')), + TextButton(onPressed: () => Navigator.pop(dialogContext), child: const Text('Cancel')), FilledButton( onPressed: () { - Navigator.pop(context); + Navigator.pop(dialogContext); _handleLogout(context); }, child: const Text('Log out'), @@ -1333,628 +384,4 @@ class _ProfilePageState extends State { ), ); } - - void _navigateToEditProfile(BuildContext context) { - context.go('/profile/edit'); - } - - void _showLicenses(BuildContext context) { - showLicensePage(context: context, applicationName: 'Papyrus', applicationVersion: '1.0.0'); - } - - void _showManageSyncServersSheet(BuildContext context) { - showModalBottomSheet( - sheetAnimationStyle: AppMotion.animationStyle(context), - context: context, - builder: (sheetContext) => SafeArea( - child: Consumer( - builder: (context, settings, _) { - return ListView( - shrinkWrap: true, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(Spacing.md, Spacing.md, Spacing.md, Spacing.sm), - child: Text('Sync servers', style: Theme.of(context).textTheme.titleMedium), - ), - ListTile( - leading: Icon( - settings.activeServerId == SyncSettingsProvider.officialServerId - ? Icons.radio_button_checked - : Icons.radio_button_unchecked, - ), - title: const Text('Official server'), - subtitle: const Text('Papyrus-hosted data sync and file storage'), - onTap: () { - settings.selectServer(SyncSettingsProvider.officialServerId); - Navigator.pop(sheetContext); - }, - ), - for (final server in settings.customServers) - ListTile( - leading: Icon( - settings.activeServerId == server.id ? Icons.radio_button_checked : Icons.radio_button_unchecked, - ), - title: Text(server.label), - subtitle: Text(server.url), - onTap: () { - settings.selectServer(server.id); - Navigator.pop(sheetContext); - }, - trailing: PopupMenuButton( - popUpAnimationStyle: AppMotion.animationStyle(context), - onSelected: (value) { - if (value == 'edit') { - Navigator.pop(sheetContext); - unawaited(_showCustomServerDialog(context, server: server)); - } else if (value == 'remove') { - settings.removeCustomServer(server.id); - } - }, - itemBuilder: (context) => const [ - PopupMenuItem(value: 'edit', child: Text('Edit')), - PopupMenuItem(value: 'remove', child: Text('Remove')), - ], - ), - ), - ListTile( - leading: const Icon(Icons.add), - title: const Text('Add custom server'), - onTap: () { - Navigator.pop(sheetContext); - unawaited(_showCustomServerDialog(context)); - }, - ), - ], - ); - }, - ), - ), - ); - } - - Future _showCustomServerDialog(BuildContext context, {CustomSyncServer? server}) async { - final settings = context.read(); - final urlController = TextEditingController(text: server?.url ?? ''); - final messenger = ScaffoldMessenger.of(context); - final snackBarAnimationStyle = AppMotion.animationStyle(context); - - try { - await showDialog( - animationStyle: AppMotion.animationStyle(context), - context: context, - builder: (dialogContext) => AlertDialog( - title: Text(server == null ? 'Add custom server' : 'Edit custom server'), - content: TextField( - controller: urlController, - decoration: const InputDecoration(labelText: 'Server URL'), - keyboardType: TextInputType.url, - autofocus: true, - ), - actions: [ - TextButton(onPressed: () => Navigator.pop(dialogContext), child: const Text('Cancel')), - FilledButton( - onPressed: () async { - try { - if (server == null) { - await settings.addCustomServer(urlController.text); - } else { - await settings.updateCustomServer(server.id, urlController.text); - } - if (dialogContext.mounted) Navigator.pop(dialogContext); - } catch (error) { - messenger.showSnackBar( - snackBarAnimationStyle: snackBarAnimationStyle, - SnackBar(content: Text('Could not save server: $error')), - ); - } - }, - child: const Text('Save'), - ), - ], - ), - ); - } finally { - urlController.dispose(); - } - } - - Future _handleReconnectSync(BuildContext context) async { - final messenger = ScaffoldMessenger.of(context); - final snackBarAnimationStyle = AppMotion.animationStyle(context); - try { - await context.read().reconnect(); - messenger.showSnackBar( - snackBarAnimationStyle: snackBarAnimationStyle, - const SnackBar(content: Text('Sync reconnect requested.')), - ); - } catch (error) { - messenger.showSnackBar( - snackBarAnimationStyle: snackBarAnimationStyle, - SnackBar(content: Text('Could not reconnect sync: $error')), - ); - } - } - - Future _retryFailedMediaUploads(BuildContext context) async { - final messenger = ScaffoldMessenger.of(context); - final snackBarAnimationStyle = AppMotion.animationStyle(context); - await context.read().retryFailed(); - messenger.showSnackBar( - snackBarAnimationStyle: snackBarAnimationStyle, - const SnackBar(content: Text('Media uploads will retry on the next sync.')), - ); - } - - void _showOfflineBackupActions(BuildContext context) { - showModalBottomSheet( - sheetAnimationStyle: AppMotion.animationStyle(context), - context: context, - builder: (sheetContext) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - leading: const Icon(Icons.file_download_outlined), - title: const Text('Export backup'), - subtitle: const Text('Save a copy for another device'), - onTap: () { - Navigator.pop(sheetContext); - _showBackupUnavailable(context, 'Backup export'); - }, - ), - ListTile( - leading: const Icon(Icons.file_upload_outlined), - title: const Text('Import backup'), - subtitle: const Text('Restore from a saved copy'), - onTap: () { - Navigator.pop(sheetContext); - _showBackupUnavailable(context, 'Backup import'); - }, - ), - ], - ), - ), - ); - } - - void _showBackupUnavailable(BuildContext context, String action) { - ScaffoldMessenger.of(context).showSnackBar( - snackBarAnimationStyle: AppMotion.animationStyle(context), - SnackBar(content: Text('$action is not available yet.')), - ); - } - - Future _confirmClearLocalLibrary(BuildContext context) async { - final confirmed = await _confirmStorageAction( - context, - title: 'Clear local library', - message: - 'This deletes the library stored on this device. This cannot be undone unless you have exported a backup.', - actionLabel: 'Clear library', - ); - if (!confirmed || !context.mounted) return; - - final messenger = ScaffoldMessenger.of(context); - final snackBarAnimationStyle = AppMotion.animationStyle(context); - try { - await context.read().clearGuestLibrary(); - messenger.showSnackBar( - snackBarAnimationStyle: snackBarAnimationStyle, - const SnackBar(content: Text('Local library cleared.')), - ); - } catch (error) { - messenger.showSnackBar( - snackBarAnimationStyle: snackBarAnimationStyle, - SnackBar(content: Text('Could not clear local library: $error')), - ); - } - } - - Future _confirmClearAuthenticatedCache(BuildContext context) async { - final confirmed = await _confirmStorageAction( - context, - title: 'Clear local copy', - message: - 'This removes synced library data stored on this device. Your library stays on the server and will download again when sync reconnects.', - actionLabel: 'Clear local copy', - ); - if (!confirmed || !context.mounted) return; - - final messenger = ScaffoldMessenger.of(context); - final snackBarAnimationStyle = AppMotion.animationStyle(context); - try { - final scope = context.read().activeScope; - final powerSyncService = context.read(); - final importService = context.read(); - await powerSyncService.clearAuthenticatedCache(); - if (scope != null) { - await importService.clearCoverFiles(scope); - } - messenger.showSnackBar( - snackBarAnimationStyle: snackBarAnimationStyle, - const SnackBar(content: Text('Local copy cleared.')), - ); - } catch (error) { - messenger.showSnackBar( - snackBarAnimationStyle: snackBarAnimationStyle, - SnackBar(content: Text('Could not clear local copy: $error')), - ); - } - } - - Future _confirmStorageAction( - BuildContext context, { - required String title, - required String message, - required String actionLabel, - }) async { - return await showDialog( - animationStyle: AppMotion.animationStyle(context), - context: context, - builder: (dialogContext) => AlertDialog( - title: Text(title), - content: Text(message), - actions: [ - TextButton(onPressed: () => Navigator.pop(dialogContext, false), child: const Text('Cancel')), - FilledButton(onPressed: () => Navigator.pop(dialogContext, true), child: Text(actionLabel)), - ], - ), - ) ?? - false; - } - - // ============================================================================ - // REUSABLE FIELD BUILDERS - // ============================================================================ - - Widget _buildDropdownField( - BuildContext context, { - required String label, - required String value, - required List options, - Map? labels, - required ValueChanged onChanged, - }) { - final colorScheme = Theme.of(context).colorScheme; - final textTheme = Theme.of(context).textTheme; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(label, style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)), - const SizedBox(height: Spacing.sm), - DropdownMenu( - initialSelection: value, - expandedInsets: EdgeInsets.zero, - dropdownMenuEntries: options.map((option) { - final displayLabel = labels?[option] ?? option; - return DropdownMenuEntry(value: option, label: displayLabel); - }).toList(), - onSelected: (selected) { - if (selected != null) onChanged(selected); - }, - ), - ], - ); - } - - Widget _buildSegmentedField( - BuildContext context, { - required String label, - required T value, - required Map options, - required ValueChanged onChanged, - }) { - final colorScheme = Theme.of(context).colorScheme; - final textTheme = Theme.of(context).textTheme; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(label, style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)), - const SizedBox(height: Spacing.sm), - SizedBox( - width: double.infinity, - child: SegmentedButton( - segments: options.entries.map((entry) { - return ButtonSegment(value: entry.key, label: Text(entry.value)); - }).toList(), - selected: {value}, - onSelectionChanged: (selected) { - onChanged(selected.first); - }, - showSelectedIcon: false, - ), - ), - ], - ); - } - - // ============================================================================ - // BOTTOM SHEET PICKERS (mobile) - // ============================================================================ - - String _capitalize(String s) { - if (s.isEmpty) return s; - return s[0].toUpperCase() + s.substring(1); - } - - String _getThemeLabel(String theme) { - switch (theme) { - case 'light': - return 'Light'; - case 'dark': - return 'Dark'; - case 'eink': - return 'E-ink'; - case 'system': - default: - return 'System'; - } - } - - String _getSortOrderLabel(String value) { - const labels = { - 'title': 'Title', - 'author': 'Author', - 'date_added': 'Date added', - 'last_read': 'Last read', - 'rating': 'Rating', - }; - return labels[value] ?? value; - } - - void _showThemePicker(BuildContext context) { - final prefs = context.read(); - - _showPickerSheet( - context, - items: [('Light', 'light'), ('Dark', 'dark'), ('E-ink', 'eink'), ('System', 'system')], - selected: prefs.themeModePref, - onSelected: (value) => prefs.themeModePref = value, - ); - } - - void _showFontPicker(BuildContext context) { - final prefs = context.read(); - - _showPickerSheet( - context, - items: [ - ('Georgia', 'Georgia'), - ('Literata', 'Literata'), - ('Bookerly', 'Bookerly'), - ('Merriweather', 'Merriweather'), - ('Noto Serif', 'Noto Serif'), - ('Atkinson Hyperlegible', 'Atkinson Hyperlegible'), - ('Open Dyslexic', 'Open Dyslexic'), - ], - selected: prefs.defaultFont, - onSelected: (value) => prefs.defaultFont = value, - ); - } - - void _showLineSpacingPicker(BuildContext context) { - final prefs = context.read(); - - _showPickerSheet( - context, - items: [('Compact', 'compact'), ('Normal', 'normal'), ('Relaxed', 'relaxed')], - selected: prefs.lineSpacing, - onSelected: (value) => prefs.lineSpacing = value, - ); - } - - void _showReadingModePicker(BuildContext context) { - final prefs = context.read(); - - _showPickerSheet( - context, - items: [('Paginated', 'paginated'), ('Continuous scroll', 'scroll')], - selected: prefs.readingMode, - onSelected: (value) => prefs.readingMode = value, - ); - } - - void _showViewModePicker(BuildContext context) { - final prefs = context.read(); - - _showPickerSheet( - context, - items: [('Grid', 'grid'), ('List', 'list'), ('Compact', 'compact')], - selected: prefs.defaultViewMode, - onSelected: (value) => prefs.defaultViewMode = value, - ); - } - - void _showSortOrderPicker(BuildContext context) { - final prefs = context.read(); - - _showPickerSheet( - context, - items: [ - ('Title', 'title'), - ('Author', 'author'), - ('Date added', 'date_added'), - ('Last read', 'last_read'), - ('Rating', 'rating'), - ], - selected: prefs.defaultSortOrder, - onSelected: (value) => prefs.defaultSortOrder = value, - ); - } - - void _showMetadataSourcePicker(BuildContext context) { - final prefs = context.read(); - - _showPickerSheet( - context, - items: [('Open Library', 'Open Library'), ('Google Books', 'Google Books')], - selected: prefs.metadataSource, - onSelected: (value) => prefs.metadataSource = value, - ); - } - - void _showExportFormatPicker(BuildContext context) { - final prefs = context.read(); - - _showPickerSheet( - context, - items: [('Markdown', 'Markdown'), ('PDF', 'PDF'), ('TXT', 'TXT'), ('HTML', 'HTML')], - selected: prefs.annotationExportFormat, - onSelected: (value) => prefs.annotationExportFormat = value, - ); - } - - void _showPickerSheet( - BuildContext context, { - required List<(String label, String value)> items, - required String selected, - required ValueChanged onSelected, - }) { - showModalBottomSheet( - sheetAnimationStyle: AppMotion.animationStyle(context), - context: context, - builder: (sheetContext) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: items.map((item) { - return _buildPickerTile( - context: sheetContext, - title: item.$1, - isSelected: selected == item.$2, - onTap: () { - onSelected(item.$2); - Navigator.pop(sheetContext); - }, - ); - }).toList(), - ), - ), - ); - } - - Widget _buildPickerTile({ - required BuildContext context, - required String title, - required bool isSelected, - required VoidCallback onTap, - }) { - final colorScheme = Theme.of(context).colorScheme; - - return ListTile( - title: Text(title), - leading: Icon( - isSelected ? Icons.radio_button_checked : Icons.radio_button_unchecked, - color: isSelected ? colorScheme.primary : colorScheme.outline, - ), - onTap: onTap, - ); - } - - // ============================================================================ - // SHARED WIDGETS - // ============================================================================ - - Widget _buildMobileHeader(BuildContext context, {double avatarSize = 128}) { - final colorScheme = Theme.of(context).colorScheme; - final textTheme = Theme.of(context).textTheme; - - return Column( - children: [ - _buildAvatar(context, size: avatarSize), - const SizedBox(height: Spacing.md), - Text(_getDisplayName(), style: textTheme.headlineSmall, textAlign: TextAlign.center), - const SizedBox(height: Spacing.xs), - Text( - _getEmail(), - style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant), - textAlign: TextAlign.center, - ), - const SizedBox(height: Spacing.md), - SizedBox( - width: 200, - child: OutlinedButton(onPressed: () => _navigateToEditProfile(context), child: const Text('Edit profile')), - ), - ], - ); - } - - Widget _buildAvatar(BuildContext context, {required double size}) { - final colorScheme = Theme.of(context).colorScheme; - final textTheme = Theme.of(context).textTheme; - final avatarUrl = _getAvatarUrl(); - - return Container( - width: size, - height: size, - decoration: BoxDecoration(borderRadius: BorderRadius.circular(size / 2), color: colorScheme.primaryContainer), - clipBehavior: Clip.antiAlias, - child: avatarUrl != null && avatarUrl.isNotEmpty - ? Image.network( - avatarUrl, - fit: BoxFit.cover, - errorBuilder: (_, _, _) => Center( - child: Text( - _initials, - style: textTheme.headlineMedium?.copyWith( - color: colorScheme.onPrimaryContainer, - fontSize: size * 0.35, - ), - ), - ), - ) - : Center( - child: Text( - _initials, - style: textTheme.headlineMedium?.copyWith(color: colorScheme.onPrimaryContainer, fontSize: size * 0.35), - ), - ), - ); - } - - Widget _buildMenuItem( - BuildContext context, { - required IconData icon, - required String label, - required VoidCallback onTap, - bool isDestructive = false, - bool showChevron = true, - }) { - final colorScheme = Theme.of(context).colorScheme; - final textTheme = Theme.of(context).textTheme; - final iconColor = isDestructive ? colorScheme.error : colorScheme.onSurfaceVariant; - final textColor = isDestructive ? colorScheme.error : null; - - return Material( - color: Colors.transparent, - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(AppRadius.sm), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: Spacing.sm, vertical: Spacing.md), - child: Row( - children: [ - Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: isDestructive - ? colorScheme.errorContainer.withValues(alpha: 0.3) - : colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(AppRadius.sm), - ), - child: Icon(icon, color: iconColor, size: IconSizes.medium), - ), - const SizedBox(width: Spacing.md), - Expanded( - child: Text(label, style: textTheme.bodyLarge?.copyWith(color: textColor)), - ), - if (showChevron) Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant, size: IconSizes.medium), - ], - ), - ), - ), - ); - } } diff --git a/app/lib/widgets/profile/about_settings_section.dart b/app/lib/widgets/profile/about_settings_section.dart new file mode 100644 index 0000000..7436eb2 --- /dev/null +++ b/app/lib/widgets/profile/about_settings_section.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; +import 'package:papyrus/widgets/settings/settings_row.dart'; +import 'package:papyrus/widgets/settings/settings_section.dart'; + +/// About settings section for ProfilePage. +class AboutSettingsSection extends StatelessWidget { + final bool isDesktop; + + const AboutSettingsSection({super.key, this.isDesktop = false}); + + static void showLicenses(BuildContext context) { + showLicensePage(context: context, applicationName: 'Papyrus', applicationVersion: '1.0.0'); + } + + @override + Widget build(BuildContext context) { + if (isDesktop) { + return _buildDesktop(context); + } + return _buildMobile(context); + } + + Widget _buildMobile(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SettingsSectionHeader(title: 'About'), + const SettingsRow(label: 'Version', value: '1.0.0', showChevron: false), + SettingsRow(label: 'Licenses', onTap: () => showLicenses(context)), + SettingsRow(label: 'Support', onTap: () {}), + SettingsRow(label: "What's new", onTap: () {}), + SettingsRow(label: 'GitHub repository', onTap: () {}), + ], + ); + } + + Widget _buildDesktop(BuildContext context) { + return SettingsCard( + children: [ + const SettingsRow(label: 'Version', value: '1.0.0', showChevron: false), + SettingsRow(label: "What's new", onTap: () {}), + SettingsRow(label: 'Licenses', onTap: () => showLicenses(context)), + SettingsRow(label: 'Support', onTap: () {}), + SettingsRow(label: 'GitHub repository', onTap: () {}), + ], + ); + } +} diff --git a/app/lib/widgets/profile/accessibility_settings_section.dart b/app/lib/widgets/profile/accessibility_settings_section.dart new file mode 100644 index 0000000..09c97b1 --- /dev/null +++ b/app/lib/widgets/profile/accessibility_settings_section.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart'; +import 'package:papyrus/providers/preferences_provider.dart'; +import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/widgets/settings/settings_row.dart'; +import 'package:papyrus/widgets/settings/settings_section.dart'; +import 'package:provider/provider.dart'; + +/// Accessibility settings section for ProfilePage. +class AccessibilitySettingsSection extends StatelessWidget { + final bool isDesktop; + + const AccessibilitySettingsSection({super.key, this.isDesktop = false}); + + @override + Widget build(BuildContext context) { + if (isDesktop) { + return _buildDesktop(context); + } + return _buildMobile(context); + } + + Widget _buildMobile(BuildContext context) { + final prefs = context.watch(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SettingsSectionHeader(title: 'Accessibility'), + SettingsToggleRow( + label: 'Reduce animations', + value: prefs.reduceAnimations, + onChanged: (value) => prefs.reduceAnimations = value, + ), + SettingsToggleRow( + label: 'Dyslexia-friendly font', + value: prefs.dyslexiaFont, + onChanged: (value) => prefs.dyslexiaFont = value, + ), + ], + ); + } + + Widget _buildDesktop(BuildContext context) { + final prefs = context.watch(); + + return SettingsCard( + children: [ + SettingsToggleRow( + label: 'Reduce animations', + value: prefs.reduceAnimations, + onChanged: (value) => prefs.reduceAnimations = value, + ), + Padding( + padding: const EdgeInsets.only(left: Spacing.sm, right: Spacing.sm, bottom: Spacing.md), + child: Text( + 'Minimizes motion effects throughout the app. ' + 'Separate from e-ink mode.', + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), + ), + ), + SettingsToggleRow( + label: 'Dyslexia-friendly font', + value: prefs.dyslexiaFont, + onChanged: (value) => prefs.dyslexiaFont = value, + ), + Padding( + padding: const EdgeInsets.only(left: Spacing.sm, right: Spacing.sm, bottom: Spacing.md), + child: Text( + 'Use OpenDyslexic font across the app interface.', + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), + ), + ), + ], + ); + } +} diff --git a/app/lib/widgets/profile/account_settings_section.dart b/app/lib/widgets/profile/account_settings_section.dart new file mode 100644 index 0000000..5d6437c --- /dev/null +++ b/app/lib/widgets/profile/account_settings_section.dart @@ -0,0 +1,145 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:papyrus/providers/auth_provider.dart'; +import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/widgets/settings/settings_row.dart'; +import 'package:papyrus/widgets/settings/settings_section.dart'; +import 'package:provider/provider.dart'; + +/// Account settings section for ProfilePage. +class AccountSettingsSection extends StatelessWidget { + final bool isDesktop; + + const AccountSettingsSection({super.key, this.isDesktop = false}); + + @override + Widget build(BuildContext context) { + if (isDesktop) { + return _buildDesktop(context); + } + return _buildMobile(context); + } + + Widget _buildMobile(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SettingsSectionHeader(title: 'Account'), + SettingsRow(label: 'Change password', onTap: () {}), + SettingsRow(label: 'Linked accounts', onTap: () {}), + SettingsRow(label: 'Two-factor authentication', onTap: () {}), + SettingsRow(label: 'Active sessions', onTap: () {}), + SettingsRow(label: 'Delete account', onTap: () {}), + ], + ); + } + + Widget _buildDesktop(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + final auth = context.watch(); + final user = auth.user; + final displayName = user?.displayName ?? 'Anonymous User'; + final userEmail = user?.email; + final email = (userEmail != null && userEmail.trim().isNotEmpty) ? userEmail : 'No email provided'; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SettingsCard( + children: [ + Row( + children: [ + _buildAvatar(context, displayName: displayName, avatarUrl: user?.avatarUrl, size: 96), + const SizedBox(width: Spacing.lg), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(displayName, style: textTheme.headlineSmall), + const SizedBox(height: Spacing.xs), + Text(email, style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)), + const SizedBox(height: Spacing.md), + Align( + alignment: Alignment.centerLeft, + child: OutlinedButton( + onPressed: () => context.go('/profile/edit'), + child: const Text('Edit profile'), + ), + ), + ], + ), + ), + ], + ), + ], + ), + const SizedBox(height: Spacing.lg), + SettingsCard( + title: 'Security', + children: [ + SettingsRow(label: 'Change password', onTap: () {}), + SettingsRow(label: 'Two-factor authentication', onTap: () {}), + SettingsRow(label: 'Active sessions', onTap: () {}), + ], + ), + const SizedBox(height: Spacing.lg), + SettingsCard( + title: 'Connected accounts', + children: [SettingsRow(label: 'Google', value: 'Not connected', onTap: () {})], + ), + const SizedBox(height: Spacing.lg), + SettingsCard( + title: 'Danger zone', + children: [SettingsRow(label: 'Delete account', onTap: () {})], + ), + ], + ); + } + + Widget _buildAvatar( + BuildContext context, { + required String displayName, + required String? avatarUrl, + required double size, + }) { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + final initials = _getInitials(displayName); + + return Container( + width: size, + height: size, + decoration: BoxDecoration(borderRadius: BorderRadius.circular(size / 2), color: colorScheme.primaryContainer), + clipBehavior: Clip.antiAlias, + child: avatarUrl != null && avatarUrl.isNotEmpty + ? Image.network( + avatarUrl, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => Center( + child: Text( + initials, + style: textTheme.headlineMedium?.copyWith( + color: colorScheme.onPrimaryContainer, + fontSize: size * 0.35, + ), + ), + ), + ) + : Center( + child: Text( + initials, + style: textTheme.headlineMedium?.copyWith(color: colorScheme.onPrimaryContainer, fontSize: size * 0.35), + ), + ), + ); + } + + String _getInitials(String name) { + final parts = name.split(' '); + if (parts.length >= 2) { + return '${parts[0][0]}${parts[1][0]}'.toUpperCase(); + } + return name.isNotEmpty ? name[0].toUpperCase() : '?'; + } +} diff --git a/app/lib/widgets/profile/appearance_settings_section.dart b/app/lib/widgets/profile/appearance_settings_section.dart new file mode 100644 index 0000000..dbbb385 --- /dev/null +++ b/app/lib/widgets/profile/appearance_settings_section.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart'; +import 'package:papyrus/providers/preferences_provider.dart'; +import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/widgets/profile/profile_picker_sheet.dart'; +import 'package:papyrus/widgets/settings/settings_row.dart'; +import 'package:papyrus/widgets/settings/settings_section.dart'; +import 'package:provider/provider.dart'; + +/// Appearance and theme settings section for ProfilePage. +class AppearanceSettingsSection extends StatelessWidget { + final bool isDesktop; + + const AppearanceSettingsSection({super.key, this.isDesktop = false}); + + static String getThemeLabel(String theme) { + switch (theme) { + case 'light': + return 'Light'; + case 'dark': + return 'Dark'; + case 'eink': + return 'E-ink'; + case 'system': + default: + return 'System'; + } + } + + @override + Widget build(BuildContext context) { + if (isDesktop) { + return _buildDesktop(context); + } + return _buildMobile(context); + } + + Widget _buildMobile(BuildContext context) { + final prefs = context.watch(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SettingsSectionHeader(title: 'Appearance'), + SettingsRow(label: 'Theme', value: getThemeLabel(prefs.themeModePref), onTap: () => _showThemePicker(context)), + ], + ); + } + + Widget _buildDesktop(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SettingsCard(title: 'Theme', children: [_buildThemeRadioGroup(context)]), + ], + ); + } + + Widget _buildThemeRadioGroup(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Material( + color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(AppRadius.md), + clipBehavior: Clip.antiAlias, + child: Column( + children: [ + _buildRadioTile(context, 'Light', 'light'), + _buildRadioTile(context, 'Dark', 'dark'), + _buildRadioTile(context, 'E-ink', 'eink'), + _buildRadioTile(context, 'System default', 'system'), + ], + ), + ); + } + + Widget _buildRadioTile(BuildContext context, String label, String value) { + final prefs = context.watch(); + final isSelected = prefs.themeModePref == value; + + return ListTile( + title: Text(label), + leading: Radio( + value: value, + // ignore: deprecated_member_use + groupValue: prefs.themeModePref, + // ignore: deprecated_member_use + onChanged: (newValue) { + if (newValue != null) prefs.themeModePref = newValue; + }, + ), + onTap: () => prefs.themeModePref = value, + selected: isSelected, + ); + } + + void _showThemePicker(BuildContext context) { + final prefs = context.read(); + + showProfilePickerSheet( + context, + items: const [('Light', 'light'), ('Dark', 'dark'), ('E-ink', 'eink'), ('System', 'system')], + selected: prefs.themeModePref, + onSelected: (value) => prefs.themeModePref = value, + ); + } +} diff --git a/app/lib/widgets/profile/developer_settings_section.dart b/app/lib/widgets/profile/developer_settings_section.dart new file mode 100644 index 0000000..b47d522 --- /dev/null +++ b/app/lib/widgets/profile/developer_settings_section.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; +import 'package:papyrus/providers/preferences_provider.dart'; +import 'package:papyrus/widgets/settings/settings_row.dart'; +import 'package:papyrus/widgets/settings/settings_section.dart'; +import 'package:provider/provider.dart'; + +/// Developer options settings section for ProfilePage. +class DeveloperSettingsSection extends StatelessWidget { + final bool isDesktop; + + const DeveloperSettingsSection({super.key, this.isDesktop = false}); + + @override + Widget build(BuildContext context) { + if (isDesktop) { + return _buildDesktop(context); + } + return _buildMobile(context); + } + + Widget _buildMobile(BuildContext context) { + context.watch(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SettingsSectionHeader(title: 'Developer options'), + SettingsRow(label: 'Reload sample data', onTap: () {}), + SettingsRow(label: 'Reset onboarding', onTap: () {}), + SettingsRow(label: 'Performance overlay', onTap: () {}), + ], + ); + } + + Widget _buildDesktop(BuildContext context) { + context.watch(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SettingsCard( + title: 'Debug', + children: [ + SettingsRow(label: 'Reload sample data', onTap: () {}), + SettingsRow(label: 'Reset onboarding', onTap: () {}), + SettingsRow(label: 'Performance overlay', onTap: () {}), + ], + ), + ], + ); + } +} diff --git a/app/lib/widgets/profile/library_settings_section.dart b/app/lib/widgets/profile/library_settings_section.dart new file mode 100644 index 0000000..e21c8d9 --- /dev/null +++ b/app/lib/widgets/profile/library_settings_section.dart @@ -0,0 +1,240 @@ +import 'package:flutter/material.dart'; +import 'package:papyrus/providers/preferences_provider.dart'; +import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/widgets/profile/profile_picker_sheet.dart'; +import 'package:papyrus/widgets/settings/settings_row.dart'; +import 'package:papyrus/widgets/settings/settings_section.dart'; +import 'package:provider/provider.dart'; + +/// Library settings section for ProfilePage. +class LibrarySettingsSection extends StatelessWidget { + final bool isDesktop; + + const LibrarySettingsSection({super.key, this.isDesktop = false}); + + static String capitalize(String s) { + if (s.isEmpty) return s; + return s[0].toUpperCase() + s.substring(1); + } + + static String getSortOrderLabel(String value) { + const labels = { + 'title': 'Title', + 'author': 'Author', + 'date_added': 'Date added', + 'last_read': 'Last read', + 'rating': 'Rating', + }; + return labels[value] ?? value; + } + + @override + Widget build(BuildContext context) { + if (isDesktop) { + return _buildDesktop(context); + } + return _buildMobile(context); + } + + Widget _buildMobile(BuildContext context) { + final prefs = context.watch(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SettingsSectionHeader(title: 'Library'), + SettingsRow( + label: 'Default view', + value: capitalize(prefs.defaultViewMode), + onTap: () => _showViewModePicker(context), + ), + SettingsRow( + label: 'Default sort', + value: getSortOrderLabel(prefs.defaultSortOrder), + onTap: () => _showSortOrderPicker(context), + ), + SettingsRow( + label: 'Metadata source', + value: prefs.metadataSource, + onTap: () => _showMetadataSourcePicker(context), + ), + SettingsRow( + label: 'Export format', + value: prefs.annotationExportFormat, + onTap: () => _showExportFormatPicker(context), + ), + ], + ); + } + + Widget _buildDesktop(BuildContext context) { + final prefs = context.watch(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SettingsCard( + title: 'Display', + children: [ + _buildSegmentedField( + context, + label: 'Default view mode', + value: prefs.defaultViewMode, + options: const {'grid': 'Grid', 'list': 'List', 'compact': 'Compact'}, + onChanged: (value) => prefs.defaultViewMode = value, + ), + const SizedBox(height: Spacing.md), + _buildDropdownField( + context, + label: 'Default sort order', + value: prefs.defaultSortOrder, + options: const ['title', 'author', 'date_added', 'last_read', 'rating'], + labels: const { + 'title': 'Title', + 'author': 'Author', + 'date_added': 'Date added', + 'last_read': 'Last read', + 'rating': 'Rating', + }, + onChanged: (value) => prefs.defaultSortOrder = value, + ), + ], + ), + const SizedBox(height: Spacing.lg), + SettingsCard( + title: 'Data', + children: [ + _buildSegmentedField( + context, + label: 'Metadata source', + value: prefs.metadataSource, + options: const {'Open Library': 'Open Library', 'Google Books': 'Google Books'}, + onChanged: (value) => prefs.metadataSource = value, + ), + const SizedBox(height: Spacing.md), + _buildDropdownField( + context, + label: 'Annotation export format', + value: prefs.annotationExportFormat, + options: const ['Markdown', 'PDF', 'TXT', 'HTML'], + onChanged: (value) => prefs.annotationExportFormat = value, + ), + ], + ), + ], + ); + } + + Widget _buildDropdownField( + BuildContext context, { + required String label, + required String value, + required List options, + Map? labels, + required ValueChanged onChanged, + }) { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)), + const SizedBox(height: Spacing.sm), + DropdownMenu( + initialSelection: value, + expandedInsets: EdgeInsets.zero, + dropdownMenuEntries: options.map((option) { + final displayLabel = labels?[option] ?? option; + return DropdownMenuEntry(value: option, label: displayLabel); + }).toList(), + onSelected: (selected) { + if (selected != null) onChanged(selected); + }, + ), + ], + ); + } + + Widget _buildSegmentedField( + BuildContext context, { + required String label, + required T value, + required Map options, + required ValueChanged onChanged, + }) { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)), + const SizedBox(height: Spacing.sm), + SizedBox( + width: double.infinity, + child: SegmentedButton( + segments: options.entries.map((entry) { + return ButtonSegment(value: entry.key, label: Text(entry.value)); + }).toList(), + selected: {value}, + onSelectionChanged: (selected) { + onChanged(selected.first); + }, + showSelectedIcon: false, + ), + ), + ], + ); + } + + void _showViewModePicker(BuildContext context) { + final prefs = context.read(); + + showProfilePickerSheet( + context, + items: const [('Grid', 'grid'), ('List', 'list'), ('Compact', 'compact')], + selected: prefs.defaultViewMode, + onSelected: (value) => prefs.defaultViewMode = value, + ); + } + + void _showSortOrderPicker(BuildContext context) { + final prefs = context.read(); + + showProfilePickerSheet( + context, + items: const [ + ('Title', 'title'), + ('Author', 'author'), + ('Date added', 'date_added'), + ('Last read', 'last_read'), + ('Rating', 'rating'), + ], + selected: prefs.defaultSortOrder, + onSelected: (value) => prefs.defaultSortOrder = value, + ); + } + + void _showMetadataSourcePicker(BuildContext context) { + final prefs = context.read(); + + showProfilePickerSheet( + context, + items: const [('Open Library', 'Open Library'), ('Google Books', 'Google Books')], + selected: prefs.metadataSource, + onSelected: (value) => prefs.metadataSource = value, + ); + } + + void _showExportFormatPicker(BuildContext context) { + final prefs = context.read(); + + showProfilePickerSheet( + context, + items: const [('Markdown', 'Markdown'), ('PDF', 'PDF'), ('TXT', 'TXT'), ('HTML', 'HTML')], + selected: prefs.annotationExportFormat, + onSelected: (value) => prefs.annotationExportFormat = value, + ); + } +} diff --git a/app/lib/widgets/profile/notifications_settings_section.dart b/app/lib/widgets/profile/notifications_settings_section.dart new file mode 100644 index 0000000..ab2862f --- /dev/null +++ b/app/lib/widgets/profile/notifications_settings_section.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:papyrus/providers/preferences_provider.dart'; +import 'package:papyrus/widgets/settings/settings_row.dart'; +import 'package:papyrus/widgets/settings/settings_section.dart'; +import 'package:provider/provider.dart'; + +/// Notifications settings section for ProfilePage. +class NotificationsSettingsSection extends StatelessWidget { + final bool isDesktop; + + const NotificationsSettingsSection({super.key, this.isDesktop = false}); + + @override + Widget build(BuildContext context) { + if (isDesktop) { + return _buildDesktop(context); + } + return _buildMobile(context); + } + + Widget _buildMobile(BuildContext context) { + final prefs = context.watch(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SettingsSectionHeader(title: 'Notifications'), + SettingsToggleRow( + label: 'Goal reminders', + value: prefs.goalReminders, + onChanged: (value) => prefs.goalReminders = value, + ), + SettingsToggleRow( + label: 'Streak alerts', + value: prefs.streakAlerts, + onChanged: (value) => prefs.streakAlerts = value, + ), + SettingsToggleRow( + label: 'Sync status', + value: prefs.syncStatusNotifications, + onChanged: (value) => prefs.syncStatusNotifications = value, + ), + ], + ); + } + + Widget _buildDesktop(BuildContext context) { + final prefs = context.watch(); + + return SettingsCard( + children: [ + SettingsToggleRow( + label: 'Goal reminders', + value: prefs.goalReminders, + onChanged: (value) => prefs.goalReminders = value, + ), + SettingsToggleRow( + label: 'Streak alerts', + value: prefs.streakAlerts, + onChanged: (value) => prefs.streakAlerts = value, + ), + SettingsToggleRow( + label: 'Sync status', + value: prefs.syncStatusNotifications, + onChanged: (value) => prefs.syncStatusNotifications = value, + ), + ], + ); + } +} diff --git a/app/lib/widgets/profile/privacy_settings_section.dart b/app/lib/widgets/profile/privacy_settings_section.dart new file mode 100644 index 0000000..3248d33 --- /dev/null +++ b/app/lib/widgets/profile/privacy_settings_section.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import 'package:papyrus/providers/preferences_provider.dart'; +import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/widgets/settings/settings_row.dart'; +import 'package:papyrus/widgets/settings/settings_section.dart'; +import 'package:provider/provider.dart'; + +/// Privacy and data management settings section for ProfilePage. +class PrivacySettingsSection extends StatelessWidget { + final bool isDesktop; + + const PrivacySettingsSection({super.key, this.isDesktop = false}); + + @override + Widget build(BuildContext context) { + if (isDesktop) { + return _buildDesktop(context); + } + return _buildMobile(context); + } + + Widget _buildMobile(BuildContext context) { + final prefs = context.watch(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SettingsSectionHeader(title: 'Privacy & data'), + SettingsToggleRow( + label: 'Analytics', + value: prefs.analyticsOptIn, + onChanged: (value) => prefs.analyticsOptIn = value, + ), + SettingsRow(label: 'Privacy policy', onTap: () {}), + SettingsRow(label: 'Export all data', onTap: () {}), + SettingsRow(label: 'Import data', onTap: () {}), + SettingsRow(label: 'Clear local data', onTap: () {}), + ], + ); + } + + Widget _buildDesktop(BuildContext context) { + final prefs = context.watch(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SettingsCard( + title: 'Analytics', + children: [ + SettingsToggleRow( + label: 'Send anonymous usage data', + value: prefs.analyticsOptIn, + onChanged: (value) => prefs.analyticsOptIn = value, + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: Spacing.sm), + child: Text( + 'Help improve Papyrus by sharing anonymous usage statistics. ' + 'No personal data or reading content is collected.', + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), + ), + ), + ], + ), + const SizedBox(height: Spacing.lg), + SettingsCard( + title: 'Data management', + children: [ + SettingsRow(label: 'Export all data', onTap: () {}), + SettingsRow(label: 'Import data', onTap: () {}), + SettingsRow(label: 'Clear local data', onTap: () {}), + ], + ), + const SizedBox(height: Spacing.lg), + SettingsCard( + title: 'Legal', + children: [SettingsRow(label: 'Privacy policy', onTap: () {})], + ), + ], + ); + } +} diff --git a/app/lib/widgets/profile/profile_picker_sheet.dart b/app/lib/widgets/profile/profile_picker_sheet.dart new file mode 100644 index 0000000..ffd0653 --- /dev/null +++ b/app/lib/widgets/profile/profile_picker_sheet.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'package:papyrus/themes/app_motion.dart'; + +/// Displays a modal bottom sheet picker for profile preferences. +void showProfilePickerSheet( + BuildContext context, { + required List<(String label, String value)> items, + required String selected, + required ValueChanged onSelected, +}) { + showModalBottomSheet( + sheetAnimationStyle: AppMotion.animationStyle(context), + context: context, + builder: (sheetContext) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: items.map((item) { + final colorScheme = Theme.of(sheetContext).colorScheme; + final isSelected = selected == item.$2; + return ListTile( + title: Text(item.$1), + leading: Icon( + isSelected ? Icons.radio_button_checked : Icons.radio_button_unchecked, + color: isSelected ? colorScheme.primary : colorScheme.outline, + ), + onTap: () { + onSelected(item.$2); + Navigator.pop(sheetContext); + }, + ); + }).toList(), + ), + ), + ); +} diff --git a/app/lib/widgets/profile/reading_settings_section.dart b/app/lib/widgets/profile/reading_settings_section.dart new file mode 100644 index 0000000..c005e69 --- /dev/null +++ b/app/lib/widgets/profile/reading_settings_section.dart @@ -0,0 +1,301 @@ +import 'package:flutter/material.dart'; +import 'package:papyrus/providers/preferences_provider.dart'; +import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/widgets/profile/profile_picker_sheet.dart'; +import 'package:papyrus/widgets/settings/settings_row.dart'; +import 'package:papyrus/widgets/settings/settings_section.dart'; +import 'package:provider/provider.dart'; + +/// Reading preferences settings section for ProfilePage. +class ReadingSettingsSection extends StatelessWidget { + final bool isDesktop; + + const ReadingSettingsSection({super.key, this.isDesktop = false}); + + static String capitalize(String s) { + if (s.isEmpty) return s; + return s[0].toUpperCase() + s.substring(1); + } + + @override + Widget build(BuildContext context) { + if (isDesktop) { + return _buildDesktop(context); + } + return _buildMobile(context); + } + + Widget _buildMobile(BuildContext context) { + final prefs = context.watch(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SettingsSectionHeader(title: 'Reading'), + SettingsRow(label: 'Default font', value: prefs.defaultFont, onTap: () => _showFontPicker(context)), + SettingsRow( + label: 'Line spacing', + value: capitalize(prefs.lineSpacing), + onTap: () => _showLineSpacingPicker(context), + ), + SettingsRow( + label: 'Reading mode', + value: capitalize(prefs.readingMode), + onTap: () => _showReadingModePicker(context), + ), + SettingsToggleRow( + label: 'Page turn animation', + value: prefs.pageTurnAnimation, + onChanged: (value) => prefs.pageTurnAnimation = value, + ), + SettingsRow(label: 'Reading profiles', onTap: () {}), + ], + ); + } + + Widget _buildDesktop(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + final prefs = context.watch(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SettingsCard( + title: 'Typography', + children: [ + _buildDropdownField( + context, + label: 'Default font', + value: prefs.defaultFont, + options: const [ + 'Georgia', + 'Literata', + 'Bookerly', + 'Merriweather', + 'Noto Serif', + 'Atkinson Hyperlegible', + 'Open Dyslexic', + ], + onChanged: (value) => prefs.defaultFont = value, + ), + const SizedBox(height: Spacing.lg), + Text('Default font size', style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)), + Row( + children: [ + Expanded( + child: Slider( + value: prefs.defaultFontSize, + min: 10, + max: 32, + divisions: 22, + onChanged: (value) => prefs.defaultFontSize = value, + ), + ), + SizedBox(width: 48, child: Text('${prefs.defaultFontSize.toInt()}px', style: textTheme.bodyMedium)), + ], + ), + const SizedBox(height: Spacing.md), + _buildSegmentedField( + context, + label: 'Line spacing', + value: prefs.lineSpacing, + options: const {'compact': 'Compact', 'normal': 'Normal', 'relaxed': 'Relaxed'}, + onChanged: (value) => prefs.lineSpacing = value, + ), + const SizedBox(height: Spacing.md), + _buildSegmentedField( + context, + label: 'Text alignment', + value: prefs.textAlignment, + options: const {'left': 'Left', 'justify': 'Justify'}, + onChanged: (value) => prefs.textAlignment = value, + ), + const SizedBox(height: Spacing.md), + _buildSegmentedField( + context, + label: 'Margins', + value: prefs.margins, + options: const {'small': 'Small', 'medium': 'Medium', 'large': 'Large'}, + onChanged: (value) => prefs.margins = value, + ), + ], + ), + const SizedBox(height: Spacing.lg), + SettingsCard( + title: 'Behavior', + children: [ + _buildSegmentedField( + context, + label: 'Reading mode', + value: prefs.readingMode, + options: const {'paginated': 'Paginated', 'scroll': 'Continuous scroll'}, + onChanged: (value) => prefs.readingMode = value, + ), + const SizedBox(height: Spacing.md), + SettingsToggleRow( + label: 'Page turn animation', + value: prefs.pageTurnAnimation, + onChanged: (value) => prefs.pageTurnAnimation = value, + ), + ], + ), + const SizedBox(height: Spacing.lg), + SettingsCard(title: 'Annotations', children: [_buildHighlightColorField(context)]), + const SizedBox(height: Spacing.lg), + SettingsCard( + children: [SettingsRow(label: 'Reading profiles', onTap: () {})], + ), + ], + ); + } + + Widget _buildHighlightColorField(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + final prefs = context.watch(); + + const highlightColors = { + 'yellow': Color(0xFFFFF176), + 'green': Color(0xFFA5D6A7), + 'blue': Color(0xFF90CAF9), + 'pink': Color(0xFFF48FB1), + 'orange': Color(0xFFFFCC80), + }; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Default highlight color', style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)), + const SizedBox(height: Spacing.sm), + Row( + children: highlightColors.entries.map((entry) { + final isSelected = prefs.defaultHighlightColor == entry.key; + return Padding( + padding: const EdgeInsets.only(right: Spacing.sm), + child: GestureDetector( + onTap: () => prefs.defaultHighlightColor = entry.key, + child: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: entry.value, + shape: BoxShape.circle, + border: isSelected + ? Border.all(color: colorScheme.primary, width: 3) + : Border.all(color: colorScheme.outline, width: 1), + ), + child: isSelected ? Icon(Icons.check, size: 18, color: colorScheme.primary) : null, + ), + ), + ); + }).toList(), + ), + ], + ); + } + + Widget _buildDropdownField( + BuildContext context, { + required String label, + required String value, + required List options, + Map? labels, + required ValueChanged onChanged, + }) { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)), + const SizedBox(height: Spacing.sm), + DropdownMenu( + initialSelection: value, + expandedInsets: EdgeInsets.zero, + dropdownMenuEntries: options.map((option) { + final displayLabel = labels?[option] ?? option; + return DropdownMenuEntry(value: option, label: displayLabel); + }).toList(), + onSelected: (selected) { + if (selected != null) onChanged(selected); + }, + ), + ], + ); + } + + Widget _buildSegmentedField( + BuildContext context, { + required String label, + required T value, + required Map options, + required ValueChanged onChanged, + }) { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)), + const SizedBox(height: Spacing.sm), + SizedBox( + width: double.infinity, + child: SegmentedButton( + segments: options.entries.map((entry) { + return ButtonSegment(value: entry.key, label: Text(entry.value)); + }).toList(), + selected: {value}, + onSelectionChanged: (selected) { + onChanged(selected.first); + }, + showSelectedIcon: false, + ), + ), + ], + ); + } + + void _showFontPicker(BuildContext context) { + final prefs = context.read(); + + showProfilePickerSheet( + context, + items: const [ + ('Georgia', 'Georgia'), + ('Literata', 'Literata'), + ('Bookerly', 'Bookerly'), + ('Merriweather', 'Merriweather'), + ('Noto Serif', 'Noto Serif'), + ('Atkinson Hyperlegible', 'Atkinson Hyperlegible'), + ('Open Dyslexic', 'Open Dyslexic'), + ], + selected: prefs.defaultFont, + onSelected: (value) => prefs.defaultFont = value, + ); + } + + void _showLineSpacingPicker(BuildContext context) { + final prefs = context.read(); + + showProfilePickerSheet( + context, + items: const [('Compact', 'compact'), ('Normal', 'normal'), ('Relaxed', 'relaxed')], + selected: prefs.lineSpacing, + onSelected: (value) => prefs.lineSpacing = value, + ); + } + + void _showReadingModePicker(BuildContext context) { + final prefs = context.read(); + + showProfilePickerSheet( + context, + items: const [('Paginated', 'paginated'), ('Continuous scroll', 'scroll')], + selected: prefs.readingMode, + onSelected: (value) => prefs.readingMode = value, + ); + } +} diff --git a/app/lib/widgets/profile/storage_sync_settings_section.dart b/app/lib/widgets/profile/storage_sync_settings_section.dart new file mode 100644 index 0000000..969afd6 --- /dev/null +++ b/app/lib/widgets/profile/storage_sync_settings_section.dart @@ -0,0 +1,596 @@ +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/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'; +import 'package:papyrus/themes/app_motion.dart'; +import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/widgets/settings/settings_row.dart'; +import 'package:papyrus/widgets/settings/settings_section.dart'; +import 'package:provider/provider.dart'; + +/// Storage, synchronization, and acquisition settings section. +/// +/// Supports both mobile ListView presentation and desktop card layout. +class StorageSyncSettingsSection extends StatelessWidget { + final bool isDesktop; + + const StorageSyncSettingsSection({super.key, this.isDesktop = false}); + + @override + Widget build(BuildContext context) { + if (isDesktop) { + return _buildDesktopContent(context); + } + return _buildMobileSection(context); + } + + // ============================================================================ + // MOBILE PRESENTATION + // ============================================================================ + + Widget _buildMobileSection(BuildContext context) { + final controller = _storageSyncController(context); + + if (controller.isGuest) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 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', + onTap: () => _showOfflineBackupActions(context), + ), + SettingsRow(label: 'Clear local library', onTap: () => _confirmClearLocalLibrary(context)), + ], + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 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), + if (controller.hasFailedMediaUploads) + SettingsRow( + label: 'Media uploads', + value: controller.failedMediaUploadLabel, + onTap: () => _retryFailedMediaUploads(context), + ), + SettingsRow(label: 'Manage servers', onTap: () => _showManageSyncServersSheet(context)), + if (controller.canReconnect) SettingsRow(label: 'Reconnect', onTap: () => _handleReconnectSync(context)), + if (controller.canClearGuestLibrary) + SettingsRow(label: 'Clear local library', onTap: () => _confirmClearLocalLibrary(context)), + if (controller.canClearAuthenticatedCache) + SettingsRow(label: 'Clear local copy', onTap: () => _confirmClearAuthenticatedCache(context)), + ], + ); + } + + // ============================================================================ + // DESKTOP PRESENTATION + // ============================================================================ + + Widget _buildDesktopContent(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + final controller = _storageSyncController(context); + + if (controller.isGuest) return _buildOfflineStorageSyncContent(context); + + return Column( + children: [ + SettingsCard( + key: const Key('profile-data-sync-card'), + title: 'Data sync', + 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), + Padding( + padding: const EdgeInsets.symmetric(horizontal: Spacing.sm, vertical: Spacing.xs), + child: Text( + controller.syncDetail, + 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) + 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 _buildOfflineStorageSyncContent(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + final mutedStyle = textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant); + + return SettingsCard( + title: 'Library storage', + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: Spacing.sm), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 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.', + style: mutedStyle, + ), + ], + ), + ), + const SizedBox(height: Spacing.md), + Wrap( + spacing: Spacing.sm, + runSpacing: Spacing.sm, + children: [ + OutlinedButton.icon( + onPressed: () => _showBackupUnavailable(context, 'Backup export'), + icon: const Icon(Icons.file_download_outlined, size: IconSizes.small), + label: const Text('Export backup'), + ), + OutlinedButton.icon( + onPressed: () => _showBackupUnavailable(context, 'Backup import'), + icon: const Icon(Icons.file_upload_outlined, size: IconSizes.small), + label: const Text('Import backup'), + ), + OutlinedButton.icon( + onPressed: () => _confirmClearLocalLibrary(context), + icon: const Icon(Icons.delete_outline, size: IconSizes.small), + label: const Text('Clear local library'), + ), + ], + ), + ], + ); + } + + // ============================================================================ + // ACQUISITION HELPERS + // ============================================================================ + + 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…', + }; + } + + // ============================================================================ + // STORAGE CONTROLLER & HELPERS + // ============================================================================ + + StorageSyncController _storageSyncController(BuildContext context) { + return StorageSyncController( + authProvider: context.watch(), + powerSyncService: context.read(), + syncSettings: context.watch(), + syncState: context.watch(), + fileStorageUsedBytes: _fileStorageUsedBytes(context.watch()), + mediaStorageUsage: context.watch().storageUsage, + failedMediaUploadCount: _failedMediaUploadCount(context.watch()), + ); + } + + int _fileStorageUsedBytes(DataStore dataStore) { + return dataStore.books.fold(0, (total, book) => total + (book.fileSize ?? 0)); + } + + int _failedMediaUploadCount(MediaUploadQueue queue) { + return queue.pendingTasks.where((task) => task.status == MediaUploadTaskStatus.failed).length; + } + + Widget _buildInfoRow(BuildContext context, {required String label, required String value}) { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: Spacing.sm, vertical: Spacing.xs), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 160, + child: Text(label, style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)), + ), + Expanded(child: SelectableText(value, style: textTheme.bodyMedium)), + ], + ), + ); + } + + // ============================================================================ + // ACTIONS & DIALOGS + // ============================================================================ + + void _showManageSyncServersSheet(BuildContext context) { + showModalBottomSheet( + sheetAnimationStyle: AppMotion.animationStyle(context), + context: context, + builder: (sheetContext) => SafeArea( + child: Consumer( + builder: (context, settings, _) { + return ListView( + shrinkWrap: true, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(Spacing.md, Spacing.md, Spacing.md, Spacing.sm), + child: Text('Sync servers', style: Theme.of(context).textTheme.titleMedium), + ), + ListTile( + leading: Icon( + settings.activeServerId == SyncSettingsProvider.officialServerId + ? Icons.radio_button_checked + : Icons.radio_button_unchecked, + ), + title: const Text('Official server'), + subtitle: const Text('Papyrus-hosted data sync and file storage'), + onTap: () { + settings.selectServer(SyncSettingsProvider.officialServerId); + Navigator.pop(sheetContext); + }, + ), + for (final server in settings.customServers) + ListTile( + leading: Icon( + settings.activeServerId == server.id ? Icons.radio_button_checked : Icons.radio_button_unchecked, + ), + title: Text(server.label), + subtitle: Text(server.url), + onTap: () { + settings.selectServer(server.id); + Navigator.pop(sheetContext); + }, + trailing: PopupMenuButton( + popUpAnimationStyle: AppMotion.animationStyle(context), + onSelected: (value) { + if (value == 'edit') { + Navigator.pop(sheetContext); + unawaited(_showCustomServerDialog(context, server: server)); + } else if (value == 'remove') { + settings.removeCustomServer(server.id); + } + }, + itemBuilder: (context) => const [ + PopupMenuItem(value: 'edit', child: Text('Edit')), + PopupMenuItem(value: 'remove', child: Text('Remove')), + ], + ), + ), + ListTile( + leading: const Icon(Icons.add), + title: const Text('Add custom server'), + onTap: () { + Navigator.pop(sheetContext); + unawaited(_showCustomServerDialog(context)); + }, + ), + ], + ); + }, + ), + ), + ); + } + + Future _showCustomServerDialog(BuildContext context, {CustomSyncServer? server}) async { + final settings = context.read(); + final urlController = TextEditingController(text: server?.url ?? ''); + final messenger = ScaffoldMessenger.of(context); + final snackBarAnimationStyle = AppMotion.animationStyle(context); + + try { + await showDialog( + animationStyle: AppMotion.animationStyle(context), + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(server == null ? 'Add custom server' : 'Edit custom server'), + content: TextField( + controller: urlController, + decoration: const InputDecoration(labelText: 'Server URL'), + keyboardType: TextInputType.url, + autofocus: true, + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(dialogContext), child: const Text('Cancel')), + FilledButton( + onPressed: () async { + try { + if (server == null) { + await settings.addCustomServer(urlController.text); + } else { + await settings.updateCustomServer(server.id, urlController.text); + } + if (dialogContext.mounted) Navigator.pop(dialogContext); + } catch (error) { + messenger.showSnackBar( + snackBarAnimationStyle: snackBarAnimationStyle, + SnackBar(content: Text('Could not save server: $error')), + ); + } + }, + child: const Text('Save'), + ), + ], + ), + ); + } finally { + urlController.dispose(); + } + } + + Future _handleReconnectSync(BuildContext context) async { + final messenger = ScaffoldMessenger.of(context); + final snackBarAnimationStyle = AppMotion.animationStyle(context); + try { + await context.read().reconnect(); + messenger.showSnackBar( + snackBarAnimationStyle: snackBarAnimationStyle, + const SnackBar(content: Text('Sync reconnect requested.')), + ); + } catch (error) { + messenger.showSnackBar( + snackBarAnimationStyle: snackBarAnimationStyle, + SnackBar(content: Text('Could not reconnect sync: $error')), + ); + } + } + + Future _retryFailedMediaUploads(BuildContext context) async { + final messenger = ScaffoldMessenger.of(context); + final snackBarAnimationStyle = AppMotion.animationStyle(context); + await context.read().retryFailed(); + messenger.showSnackBar( + snackBarAnimationStyle: snackBarAnimationStyle, + const SnackBar(content: Text('Media uploads will retry on the next sync.')), + ); + } + + void _showOfflineBackupActions(BuildContext context) { + showModalBottomSheet( + sheetAnimationStyle: AppMotion.animationStyle(context), + context: context, + builder: (sheetContext) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.file_download_outlined), + title: const Text('Export backup'), + subtitle: const Text('Save a copy for another device'), + onTap: () { + Navigator.pop(sheetContext); + _showBackupUnavailable(context, 'Backup export'); + }, + ), + ListTile( + leading: const Icon(Icons.file_upload_outlined), + title: const Text('Import backup'), + subtitle: const Text('Restore from a saved copy'), + onTap: () { + Navigator.pop(sheetContext); + _showBackupUnavailable(context, 'Backup import'); + }, + ), + ], + ), + ), + ); + } + + void _showBackupUnavailable(BuildContext context, String action) { + ScaffoldMessenger.of(context).showSnackBar( + snackBarAnimationStyle: AppMotion.animationStyle(context), + SnackBar(content: Text('$action is not available yet.')), + ); + } + + Future _confirmClearLocalLibrary(BuildContext context) async { + final confirmed = await _confirmStorageAction( + context, + title: 'Clear local library', + message: + 'This deletes the library stored on this device. This cannot be undone unless you have exported a backup.', + actionLabel: 'Clear library', + ); + if (!confirmed || !context.mounted) return; + + final messenger = ScaffoldMessenger.of(context); + final snackBarAnimationStyle = AppMotion.animationStyle(context); + try { + await context.read().clearGuestLibrary(); + messenger.showSnackBar( + snackBarAnimationStyle: snackBarAnimationStyle, + const SnackBar(content: Text('Local library cleared.')), + ); + } catch (error) { + messenger.showSnackBar( + snackBarAnimationStyle: snackBarAnimationStyle, + SnackBar(content: Text('Could not clear local library: $error')), + ); + } + } + + Future _confirmClearAuthenticatedCache(BuildContext context) async { + final confirmed = await _confirmStorageAction( + context, + title: 'Clear local copy', + message: + 'This removes synced library data stored on this device. Your library stays on the server and will download again when sync reconnects.', + actionLabel: 'Clear local copy', + ); + if (!confirmed || !context.mounted) return; + + final messenger = ScaffoldMessenger.of(context); + final snackBarAnimationStyle = AppMotion.animationStyle(context); + try { + final scope = context.read().activeScope; + final powerSyncService = context.read(); + final importService = context.read(); + await powerSyncService.clearAuthenticatedCache(); + if (scope != null) { + await importService.clearCoverFiles(scope); + } + messenger.showSnackBar( + snackBarAnimationStyle: snackBarAnimationStyle, + const SnackBar(content: Text('Local copy cleared.')), + ); + } catch (error) { + messenger.showSnackBar( + snackBarAnimationStyle: snackBarAnimationStyle, + SnackBar(content: Text('Could not clear local copy: $error')), + ); + } + } + + Future _confirmStorageAction( + BuildContext context, { + required String title, + required String message, + required String actionLabel, + }) async { + return await showDialog( + animationStyle: AppMotion.animationStyle(context), + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(title), + content: Text(message), + actions: [ + TextButton(onPressed: () => Navigator.pop(dialogContext, false), child: const Text('Cancel')), + FilledButton(onPressed: () => Navigator.pop(dialogContext, true), child: Text(actionLabel)), + ], + ), + ) ?? + false; + } +} diff --git a/app/test/pages/profile_page_test.dart b/app/test/pages/profile_page_test.dart new file mode 100644 index 0000000..8ae1a7c --- /dev/null +++ b/app/test/pages/profile_page_test.dart @@ -0,0 +1,241 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/auth/auth_api_client.dart'; +import 'package:papyrus/auth/auth_models.dart'; +import 'package:papyrus/auth/auth_repository.dart'; +import 'package:papyrus/auth/papyrus_api_config.dart'; +import 'package:papyrus/auth/token_store.dart'; +import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/media/media_upload_queue.dart'; +import 'package:papyrus/pages/profile_page.dart'; +import 'package:papyrus/powersync/powersync_service.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/widgets/profile/profile_menu_item.dart'; +import 'package:papyrus/widgets/settings/settings_section.dart'; +import 'package:powersync/powersync.dart' hide Column; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class _MemoryStorage implements RefreshTokenStorage { + String? token; + @override + Future delete() async => token = null; + @override + Future read() async => token; + @override + Future write(String val) async => token = val; +} + +class _MockAuthRepo extends AuthRepository { + _MockAuthRepo() + : super( + apiClient: AuthApiClient(config: PapyrusApiConfig(serverBaseUri: Uri.parse('https://api.test'))), + tokenStore: TokenStore(_MemoryStorage()), + ); + + @override + Future bootstrap() async => AuthTokens( + accessToken: 'access-token', + refreshToken: 'refresh-token', + tokenType: 'Bearer', + expiresIn: 3600, + user: PapyrusUser( + userId: '11111111-1111-1111-1111-111111111111', + email: 'reader@example.com', + displayName: 'Reader Profile', + avatarUrl: null, + emailVerified: true, + createdAt: null, + lastLoginAt: null, + ), + ); +} + +class _OfflineConnector extends PowerSyncBackendConnector { + @override + Future fetchCredentials() async => null; + @override + Future uploadData(PowerSyncDatabase database) async {} +} + +class _MockPowerSyncService extends PapyrusPowerSyncService { + _MockPowerSyncService() : super(connectorFactory: _OfflineConnector.new, connectAuthenticated: false); + + @override + LibraryDatabaseMode? get mode => LibraryDatabaseMode.guest; + + @override + SyncState get syncState => const SyncState(connected: true, connecting: false, downloading: false, uploading: false); + + @override + Stream get syncStates => Stream.value(syncState); +} + +void main() { + group('ProfilePage Full Integration', () { + late SharedPreferences prefs; + late AuthProvider authProvider; + late _MockPowerSyncService powerSync; + late SyncSettingsProvider syncSettings; + late DataStore dataStore; + late MediaUploadQueue uploadQueue; + late PreferencesProvider prefsProvider; + late AcquisitionAvailabilityProvider availabilityProvider; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + prefs = await SharedPreferences.getInstance(); + authProvider = AuthProvider(prefs, repository: _MockAuthRepo(), bootstrapOnCreate: false); + await authProvider.bootstrap(); + powerSync = _MockPowerSyncService(); + syncSettings = SyncSettingsProvider( + prefs, + officialConfig: PapyrusApiConfig( + serverBaseUri: Uri.parse('https://api.test'), + powerSyncServiceUri: Uri.parse('https://data-sync.test'), + ), + ); + dataStore = DataStore(); + uploadQueue = MediaUploadQueue(prefs); + prefsProvider = PreferencesProvider(prefs); + availabilityProvider = AcquisitionAvailabilityProvider(authProvider: authProvider); + }); + + Widget buildApp({required Size screenSize}) { + return MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: authProvider), + Provider.value(value: powerSync), + ChangeNotifierProvider.value(value: syncSettings), + StreamProvider.value(value: powerSync.syncStates, initialData: powerSync.syncState), + ChangeNotifierProvider.value(value: dataStore), + ChangeNotifierProvider.value(value: uploadQueue), + ChangeNotifierProvider.value(value: prefsProvider), + ChangeNotifierProvider.value(value: availabilityProvider), + ], + child: MaterialApp( + home: MediaQuery( + data: MediaQueryData(size: screenSize), + child: const ProfilePage(), + ), + ), + ); + } + + testWidgets('mobile layout renders all sections and displays logout dialog', (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('Profile'), findsOneWidget); + expect(find.text('Reader Profile'), findsOneWidget); + expect(find.text('reader@example.com'), findsOneWidget); + + await tester.scrollUntilVisible(find.widgetWithText(SettingsSectionHeader, 'Account'), 300); + expect(find.text('Change password'), findsOneWidget); + + await tester.scrollUntilVisible(find.widgetWithText(SettingsSectionHeader, 'Appearance'), 300); + expect(find.text('Theme'), findsOneWidget); + + await tester.scrollUntilVisible(find.widgetWithText(SettingsSectionHeader, 'Reading'), 300); + expect(find.text('Default font'), findsOneWidget); + + await tester.scrollUntilVisible(find.widgetWithText(SettingsSectionHeader, 'Library'), 300); + expect(find.text('Default view'), findsOneWidget); + + await tester.scrollUntilVisible(find.widgetWithText(SettingsSectionHeader, 'Notifications'), 300); + expect(find.text('Goal reminders'), findsOneWidget); + + await tester.scrollUntilVisible(find.widgetWithText(SettingsSectionHeader, 'Storage'), 300); + expect(find.text('Clear local library'), findsOneWidget); + + await tester.scrollUntilVisible(find.widgetWithText(SettingsSectionHeader, 'Privacy & data'), 300); + expect(find.text('Analytics'), findsOneWidget); + + await tester.scrollUntilVisible(find.widgetWithText(SettingsSectionHeader, 'Accessibility'), 300); + expect(find.text('Reduce animations'), findsOneWidget); + + await tester.scrollUntilVisible(find.widgetWithText(SettingsSectionHeader, 'About'), 300); + expect(find.text('Version'), findsOneWidget); + + await tester.scrollUntilVisible(find.widgetWithText(ProfileMenuItem, 'Log out'), 300); + await tester.tap(find.widgetWithText(ProfileMenuItem, '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); + }); + + testWidgets('desktop layout navigates across all sections correctly', (tester) async { + await tester.binding.setSurfaceSize(const Size(1200, 900)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget(buildApp(screenSize: const Size(1200, 900))); + await tester.pumpAndSettle(); + + // Account section default + expect(find.text('Account'), findsWidgets); + expect(find.text('Reader Profile'), findsOneWidget); + expect(find.text('Edit profile'), findsOneWidget); + + // Navigate to Appearance + await tester.tap(find.widgetWithText(InkWell, 'Appearance')); + await tester.pumpAndSettle(); + expect(find.text('System default'), findsOneWidget); + + // Navigate to Reading + await tester.tap(find.widgetWithText(InkWell, 'Reading')); + await tester.pumpAndSettle(); + expect(find.text('Typography'), findsOneWidget); + expect(find.text('Behavior'), findsOneWidget); + + // Navigate to Library + await tester.tap(find.widgetWithText(InkWell, 'Library')); + await tester.pumpAndSettle(); + expect(find.text('Display'), findsOneWidget); + expect(find.text('Default view mode'), findsOneWidget); + + // Navigate to Notifications + await tester.tap(find.widgetWithText(InkWell, 'Notifications')); + await tester.pumpAndSettle(); + expect(find.text('Goal reminders'), findsOneWidget); + + // Navigate to Storage + await tester.tap(find.widgetWithText(InkWell, 'Storage')); + await tester.pumpAndSettle(); + expect(find.text('Library storage'), findsOneWidget); + + // Navigate to Privacy + await tester.tap(find.widgetWithText(InkWell, 'Privacy')); + await tester.pumpAndSettle(); + expect(find.text('Send anonymous usage data'), findsOneWidget); + + // Navigate to Accessibility + await tester.tap(find.widgetWithText(InkWell, 'Accessibility')); + await tester.pumpAndSettle(); + expect(find.text('Reduce animations'), findsOneWidget); + + // Navigate to About + await tester.tap(find.widgetWithText(InkWell, 'About')); + await tester.pumpAndSettle(); + expect(find.text('Version'), findsOneWidget); + expect(find.text('Licenses'), findsOneWidget); + + // Trigger Logout dialog on desktop + await tester.tap(find.widgetWithText(InkWell, '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); + }); + }); +} diff --git a/app/test/widgets/profile/appearance_settings_section_test.dart b/app/test/widgets/profile/appearance_settings_section_test.dart new file mode 100644 index 0000000..e4c7d13 --- /dev/null +++ b/app/test/widgets/profile/appearance_settings_section_test.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/providers/preferences_provider.dart'; +import 'package:papyrus/widgets/profile/appearance_settings_section.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + group('AppearanceSettingsSection', () { + late PreferencesProvider prefsProvider; + + setUp(() async { + SharedPreferences.setMockInitialValues({'theme_mode': 'light'}); + final prefs = await SharedPreferences.getInstance(); + prefsProvider = PreferencesProvider(prefs); + }); + + Widget buildSection({bool isDesktop = false}) { + return ChangeNotifierProvider.value( + value: prefsProvider, + child: MaterialApp( + home: Scaffold(body: AppearanceSettingsSection(isDesktop: isDesktop)), + ), + ); + } + + testWidgets('renders mobile appearance settings and opens theme picker', (tester) async { + await tester.pumpWidget(buildSection(isDesktop: false)); + + expect(find.text('Appearance'), findsOneWidget); + expect(find.text('Theme'), findsOneWidget); + expect(find.text('Light'), findsOneWidget); + + await tester.tap(find.text('Theme')); + await tester.pumpAndSettle(); + + expect(find.text('Dark'), findsOneWidget); + expect(find.text('E-ink'), findsOneWidget); + expect(find.text('System'), findsOneWidget); + + await tester.tap(find.text('Dark')); + await tester.pumpAndSettle(); + + expect(prefsProvider.themeModePref, equals('dark')); + }); + + testWidgets('renders desktop radio tile group and updates theme preference', (tester) async { + await tester.pumpWidget(buildSection(isDesktop: true)); + + expect(find.text('Theme'), findsOneWidget); + expect(find.text('Light'), findsOneWidget); + expect(find.text('Dark'), findsOneWidget); + expect(find.text('E-ink'), findsOneWidget); + expect(find.text('System default'), findsOneWidget); + + await tester.tap(find.text('Dark')); + await tester.pumpAndSettle(); + + expect(prefsProvider.themeModePref, equals('dark')); + }); + }); +} diff --git a/app/test/widgets/profile/library_settings_section_test.dart b/app/test/widgets/profile/library_settings_section_test.dart new file mode 100644 index 0000000..412f961 --- /dev/null +++ b/app/test/widgets/profile/library_settings_section_test.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/providers/preferences_provider.dart'; +import 'package:papyrus/widgets/profile/library_settings_section.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + group('LibrarySettingsSection', () { + late PreferencesProvider prefsProvider; + + setUp(() async { + SharedPreferences.setMockInitialValues({ + 'default_view_mode': 'grid', + 'default_sort_order': 'date_added', + 'metadata_source': 'Open Library', + 'annotation_export_format': 'Markdown', + }); + final prefs = await SharedPreferences.getInstance(); + prefsProvider = PreferencesProvider(prefs); + }); + + Widget buildSection({bool isDesktop = false}) { + return ChangeNotifierProvider.value( + value: prefsProvider, + child: MaterialApp( + home: Scaffold(body: LibrarySettingsSection(isDesktop: isDesktop)), + ), + ); + } + + testWidgets('renders mobile library settings and rows', (tester) async { + await tester.pumpWidget(buildSection(isDesktop: false)); + + expect(find.text('Library'), findsOneWidget); + expect(find.text('Default view'), findsOneWidget); + expect(find.text('Default sort'), findsOneWidget); + expect(find.text('Metadata source'), findsOneWidget); + expect(find.text('Export format'), findsOneWidget); + }); + + testWidgets('renders desktop display and data configuration cards', (tester) async { + await tester.pumpWidget(buildSection(isDesktop: true)); + + expect(find.text('Display'), findsOneWidget); + expect(find.text('Default view mode'), findsOneWidget); + expect(find.text('Default sort order'), findsOneWidget); + expect(find.text('Data'), findsOneWidget); + expect(find.text('Metadata source'), findsOneWidget); + expect(find.text('Annotation export format'), findsOneWidget); + }); + }); +} diff --git a/app/test/widgets/profile/reading_settings_section_test.dart b/app/test/widgets/profile/reading_settings_section_test.dart new file mode 100644 index 0000000..9e547c6 --- /dev/null +++ b/app/test/widgets/profile/reading_settings_section_test.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/providers/preferences_provider.dart'; +import 'package:papyrus/widgets/profile/reading_settings_section.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + group('ReadingSettingsSection', () { + late PreferencesProvider prefsProvider; + + setUp(() async { + SharedPreferences.setMockInitialValues({ + 'default_font': 'Georgia', + 'line_spacing': 'normal', + 'reading_mode': 'paginated', + 'page_turn_animation': true, + }); + final prefs = await SharedPreferences.getInstance(); + prefsProvider = PreferencesProvider(prefs); + }); + + Widget buildSection({bool isDesktop = false}) { + return ChangeNotifierProvider.value( + value: prefsProvider, + child: MaterialApp( + home: Scaffold( + body: SingleChildScrollView(child: ReadingSettingsSection(isDesktop: isDesktop)), + ), + ), + ); + } + + testWidgets('renders mobile reading options and allows toggling page animation', (tester) async { + await tester.pumpWidget(buildSection(isDesktop: false)); + + expect(find.text('Reading'), findsOneWidget); + expect(find.text('Default font'), findsOneWidget); + expect(find.text('Line spacing'), findsOneWidget); + expect(find.text('Reading mode'), findsOneWidget); + expect(find.text('Page turn animation'), findsOneWidget); + + final switchFinder = find.byType(Switch); + expect(switchFinder, findsOneWidget); + await tester.tap(switchFinder); + await tester.pumpAndSettle(); + + expect(prefsProvider.pageTurnAnimation, isFalse); + }); + + testWidgets('renders desktop reading typography and behavior options', (tester) async { + await tester.pumpWidget(buildSection(isDesktop: true)); + + expect(find.text('Typography'), findsOneWidget); + expect(find.text('Default font size'), findsOneWidget); + expect(find.text('Behavior'), findsOneWidget); + expect(find.text('Annotations'), findsOneWidget); + expect(find.text('Default highlight color'), findsOneWidget); + }); + }); +} diff --git a/app/test/widgets/profile/storage_sync_settings_section_test.dart b/app/test/widgets/profile/storage_sync_settings_section_test.dart new file mode 100644 index 0000000..1db0b6d --- /dev/null +++ b/app/test/widgets/profile/storage_sync_settings_section_test.dart @@ -0,0 +1,133 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/auth/auth_api_client.dart'; +import 'package:papyrus/auth/auth_models.dart'; +import 'package:papyrus/auth/auth_repository.dart'; +import 'package:papyrus/auth/papyrus_api_config.dart'; +import 'package:papyrus/auth/token_store.dart'; +import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/media/media_upload_queue.dart'; +import 'package:papyrus/powersync/powersync_service.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/widgets/profile/storage_sync_settings_section.dart'; +import 'package:powersync/powersync.dart' hide Column; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class _MemoryStorage implements RefreshTokenStorage { + String? token; + @override + Future delete() async => token = null; + @override + Future read() async => token; + @override + Future write(String val) async => token = val; +} + +class _MockAuthRepo extends AuthRepository { + _MockAuthRepo() + : super( + apiClient: AuthApiClient(config: PapyrusApiConfig(serverBaseUri: Uri.parse('https://api.test'))), + tokenStore: TokenStore(_MemoryStorage()), + ); + + @override + Future bootstrap() async => null; +} + +class _OfflineConnector extends PowerSyncBackendConnector { + @override + Future fetchCredentials() async => null; + @override + Future uploadData(PowerSyncDatabase database) async {} +} + +class _MockPowerSyncService extends PapyrusPowerSyncService { + _MockPowerSyncService() : super(connectorFactory: _OfflineConnector.new, connectAuthenticated: false); + + @override + LibraryDatabaseMode? get mode => LibraryDatabaseMode.guest; + + @override + SyncState get syncState => const SyncState(connected: true, connecting: false, downloading: false, uploading: false); + + @override + Stream get syncStates => Stream.value(syncState); +} + +void main() { + group('StorageSyncSettingsSection', () { + late SharedPreferences prefs; + late AuthProvider authProvider; + late _MockPowerSyncService powerSync; + late SyncSettingsProvider syncSettings; + late DataStore dataStore; + late MediaUploadQueue uploadQueue; + late PreferencesProvider prefsProvider; + late AcquisitionAvailabilityProvider availabilityProvider; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + prefs = await SharedPreferences.getInstance(); + authProvider = AuthProvider(prefs, repository: _MockAuthRepo(), bootstrapOnCreate: false); + await authProvider.bootstrap(); + authProvider.setOfflineMode(true); + powerSync = _MockPowerSyncService(); + syncSettings = SyncSettingsProvider( + prefs, + officialConfig: PapyrusApiConfig( + serverBaseUri: Uri.parse('https://api.test'), + powerSyncServiceUri: Uri.parse('https://data-sync.test'), + ), + ); + dataStore = DataStore(); + uploadQueue = MediaUploadQueue(prefs); + prefsProvider = PreferencesProvider(prefs); + availabilityProvider = AcquisitionAvailabilityProvider(authProvider: authProvider); + }); + + Widget buildSection({bool isDesktop = false}) { + return MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: authProvider), + Provider.value(value: powerSync), + ChangeNotifierProvider.value(value: syncSettings), + StreamProvider.value(value: powerSync.syncStates, initialData: powerSync.syncState), + ChangeNotifierProvider.value(value: dataStore), + ChangeNotifierProvider.value(value: uploadQueue), + ChangeNotifierProvider.value(value: prefsProvider), + ChangeNotifierProvider.value(value: availabilityProvider), + ], + child: MaterialApp( + home: Scaffold( + body: SingleChildScrollView(child: StorageSyncSettingsSection(isDesktop: isDesktop)), + ), + ), + ); + } + + testWidgets('renders desktop offline storage card and actions', (tester) async { + await tester.pumpWidget(buildSection(isDesktop: true)); + + expect(find.text('Library storage'), findsOneWidget); + expect(find.text('Your library is stored on this device.'), findsOneWidget); + expect(find.text('Export backup'), findsOneWidget); + expect(find.text('Import backup'), findsOneWidget); + expect(find.text('Clear local library'), findsOneWidget); + }); + + testWidgets('renders mobile storage settings rows', (tester) async { + await tester.pumpWidget(buildSection(isDesktop: false)); + + expect(find.text('Storage'), findsOneWidget); + expect(find.text('Library'), findsOneWidget); + expect(find.text('Stored on this device'), findsOneWidget); + expect(find.text('Export or import a backup'), findsOneWidget); + expect(find.text('Clear local library'), findsOneWidget); + }); + }); +}