From 5822f5fe1aa0154a71f2b4d912f72dff34d730e0 Mon Sep 17 00:00:00 2001 From: Eoic Date: Sat, 27 Jun 2026 17:15:59 +0300 Subject: [PATCH] Add storage sync settings UI --- .../powersync_books_integration_test.dart | 3 +- app/lib/auth/token_store.dart | 19 +- app/lib/main.dart | 61 +- app/lib/pages/developer_options_page.dart | 28 - app/lib/pages/profile_page.dart | 556 ++++++++++++++---- app/lib/powersync/powersync_service.dart | 99 +++- .../powersync/storage_sync_controller.dart | 94 +++ app/lib/providers/auth_provider.dart | 15 +- app/lib/providers/sync_settings_provider.dart | 123 ++++ .../widgets/library/bulk_status_sheet.dart | 1 - app/lib/widgets/library/selection_header.dart | 2 +- app/lib/widgets/shared/eink_page_header.dart | 2 +- app/lib/widgets/statistics/stat_card.dart | 2 +- app/test/pages/profile_storage_sync_test.dart | 265 +++++++++ .../powersync/powersync_service_test.dart | 54 +- app/test/providers/auth_provider_test.dart | 14 + .../sync_settings_provider_test.dart | 74 +++ 17 files changed, 1235 insertions(+), 177 deletions(-) create mode 100644 app/lib/powersync/storage_sync_controller.dart create mode 100644 app/lib/providers/sync_settings_provider.dart create mode 100644 app/test/pages/profile_storage_sync_test.dart create mode 100644 app/test/providers/sync_settings_provider_test.dart diff --git a/app/integration_test/powersync_books_integration_test.dart b/app/integration_test/powersync_books_integration_test.dart index 29f791a..62c6c05 100644 --- a/app/integration_test/powersync_books_integration_test.dart +++ b/app/integration_test/powersync_books_integration_test.dart @@ -58,7 +58,8 @@ void main() { PapyrusPowerSyncService service(AuthRepository repository, String label) { return PapyrusPowerSyncService( connectorFactory: () => PapyrusPowerSyncConnector(authRepository: repository, config: config), - pathResolver: (mode) async => path.join(root.path, '$label-${mode.name}.db'), + pathResolver: (mode, profileKey, userId) async => + path.join(root.path, '$label-${mode.name}-${profileKey ?? 'default'}-${userId ?? 'none'}.db'), ); } diff --git a/app/lib/auth/token_store.dart b/app/lib/auth/token_store.dart index 78d1162..515ce36 100644 --- a/app/lib/auth/token_store.dart +++ b/app/lib/auth/token_store.dart @@ -12,19 +12,30 @@ class SecureRefreshTokenStorage implements RefreshTokenStorage { static const _refreshTokenKey = 'papyrus_refresh_token'; final FlutterSecureStorage _storage; + final String namespace; - const SecureRefreshTokenStorage([this._storage = const FlutterSecureStorage()]); + const SecureRefreshTokenStorage([this._storage = const FlutterSecureStorage()]) : namespace = 'default'; + + const SecureRefreshTokenStorage.scoped(this.namespace, [this._storage = const FlutterSecureStorage()]); + + String get _storageKey { + if (namespace == 'default') { + return _refreshTokenKey; + } + + return '$_refreshTokenKey.$namespace'; + } @override - Future read() => _storage.read(key: _refreshTokenKey); + Future read() => _storage.read(key: _storageKey); @override Future write(String refreshToken) { - return _storage.write(key: _refreshTokenKey, value: refreshToken); + return _storage.write(key: _storageKey, value: refreshToken); } @override - Future delete() => _storage.delete(key: _refreshTokenKey); + Future delete() => _storage.delete(key: _storageKey); } class TokenStore { diff --git a/app/lib/main.dart b/app/lib/main.dart index 8a24d4a..6cad691 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -13,6 +13,7 @@ import 'package:papyrus/powersync/sync_state.dart'; import 'package:papyrus/providers/auth_provider.dart'; import 'package:papyrus/providers/library_provider.dart'; import 'package:papyrus/providers/preferences_provider.dart'; +import 'package:papyrus/providers/sync_settings_provider.dart'; import 'package:papyrus/providers/sidebar_provider.dart'; import 'package:papyrus/themes/app_theme.dart'; import 'package:provider/provider.dart'; @@ -40,39 +41,54 @@ class Papyrus extends StatefulWidget { class _PapyrusState extends State { late final DataStore _dataStore; late final AuthProvider _authProvider; + late final SyncSettingsProvider _syncSettingsProvider; late final PapyrusPowerSyncService _powerSyncService; + late final PapyrusApiConfig _officialApiConfig; + late AuthRepository _authRepository; + late String _activeProfileKey; late final AppRouter _appRouter; + bool _switchingSyncProfile = false; @override void initState() { super.initState(); - final apiConfig = PapyrusApiConfig.fromEnvironment(); - final tokenStore = TokenStore(const SecureRefreshTokenStorage()); - final authRepository = AuthRepository( - apiClient: AuthApiClient(config: apiConfig), - tokenStore: tokenStore, - ); + _officialApiConfig = PapyrusApiConfig.fromEnvironment(); + _syncSettingsProvider = SyncSettingsProvider(widget.prefs, officialConfig: _officialApiConfig); + _activeProfileKey = _syncSettingsProvider.activeProfileKey; + _authRepository = _buildAuthRepository(_syncSettingsProvider.activeApiConfig, _activeProfileKey); _dataStore = DataStore(); - _authProvider = AuthProvider(widget.prefs, repository: authRepository); + _authProvider = AuthProvider(widget.prefs, repository: _authRepository); _powerSyncService = PapyrusPowerSyncService( - connectorFactory: () => PapyrusPowerSyncConnector(authRepository: authRepository, config: apiConfig), + connectorFactory: () => + PapyrusPowerSyncConnector(authRepository: _authRepository, config: _syncSettingsProvider.activeApiConfig), ); unawaited(_dataStore.attachBookRepository(_powerSyncService)); _appRouter = AppRouter(authProvider: _authProvider); _authProvider.addListener(_syncPowerSyncAuthState); + _syncSettingsProvider.addListener(_handleSyncSettingsChanged); _syncPowerSyncAuthState(); } @override void dispose() { _authProvider.removeListener(_syncPowerSyncAuthState); + _syncSettingsProvider.removeListener(_handleSyncSettingsChanged); unawaited(_disposeDataServices()); _authProvider.dispose(); + _syncSettingsProvider.dispose(); super.dispose(); } + AuthRepository _buildAuthRepository(PapyrusApiConfig config, String profileKey) { + final tokenStore = TokenStore(SecureRefreshTokenStorage.scoped(profileKey)); + return AuthRepository( + apiClient: AuthApiClient(config: config), + tokenStore: tokenStore, + ); + } + Future _disposeDataServices() async { await _dataStore.disposeBookRepository(); await _powerSyncService.close(); @@ -82,7 +98,7 @@ class _PapyrusState extends State { final user = _authProvider.user; if (user != null && !_authProvider.isOfflineMode) { final userId = user.userId; - unawaited(_powerSyncService.activateAuthenticated(userId)); + unawaited(_powerSyncService.activateAuthenticated(userId, profileKey: _activeProfileKey)); return; } @@ -91,8 +107,30 @@ class _PapyrusState extends State { return; } - if (!_authProvider.isBootstrapping) { - unawaited(_powerSyncService.deactivate()); + if (!_authProvider.isBootstrapping && _powerSyncService.mode != null) { + unawaited(_powerSyncService.deactivate(clearAuthenticated: !_switchingSyncProfile)); + } + } + + void _handleSyncSettingsChanged() { + final nextProfileKey = _syncSettingsProvider.activeProfileKey; + if (nextProfileKey == _activeProfileKey) { + return; + } + + _activeProfileKey = nextProfileKey; + unawaited(_switchActiveSyncProfile()); + } + + Future _switchActiveSyncProfile() async { + _switchingSyncProfile = true; + try { + await _powerSyncService.deactivate(clearAuthenticated: false); + _authRepository = _buildAuthRepository(_syncSettingsProvider.activeApiConfig, _activeProfileKey); + await _authProvider.replaceRepository(_authRepository, bootstrapNewRepository: !_authProvider.isOfflineMode); + } finally { + _switchingSyncProfile = false; + _syncPowerSyncAuthState(); } } @@ -102,6 +140,7 @@ class _PapyrusState extends State { providers: [ // Core data store - single source of truth ChangeNotifierProvider.value(value: _dataStore), + ChangeNotifierProvider.value(value: _syncSettingsProvider), Provider.value(value: _powerSyncService), StreamProvider.value(value: _powerSyncService.syncStates, initialData: _powerSyncService.syncState), // Auth and UI state providers diff --git a/app/lib/pages/developer_options_page.dart b/app/lib/pages/developer_options_page.dart index c222629..b94a4e5 100644 --- a/app/lib/pages/developer_options_page.dart +++ b/app/lib/pages/developer_options_page.dart @@ -1,8 +1,6 @@ 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'; /// Developer options page with debug-only settings. @@ -72,30 +70,4 @@ class DeveloperOptionsPage extends StatelessWidget { ), ); } - - Widget _buildEinkToggle(bool isOn) { - return Container( - width: 56, - height: 32, - decoration: BoxDecoration( - border: Border.all(color: Colors.black, width: BorderWidths.einkDefault), - ), - child: Row( - children: [ - Expanded( - child: Container( - color: isOn ? Colors.black : Colors.white, - child: Center(child: isOn ? const Icon(Icons.check, color: Colors.white, size: 16) : null), - ), - ), - Expanded( - child: Container( - color: isOn ? Colors.white : Colors.black, - child: Center(child: !isOn ? const Icon(Icons.close, color: Colors.white, size: 16) : null), - ), - ), - ], - ), - ); - } } diff --git a/app/lib/pages/profile_page.dart b/app/lib/pages/profile_page.dart index c89a995..0ac3cb0 100644 --- a/app/lib/pages/profile_page.dart +++ b/app/lib/pages/profile_page.dart @@ -1,8 +1,13 @@ +import 'dart:async'; + import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.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/preferences_provider.dart'; +import 'package:papyrus/providers/sync_settings_provider.dart'; import 'package:papyrus/powersync/sync_state.dart'; import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/widgets/settings/settings_row.dart'; @@ -195,26 +200,70 @@ class _ProfilePageState extends State { } Widget _buildMobileStorageSyncSection(BuildContext context) { - final prefs = context.watch(); - final auth = context.watch(); - final sync = context.watch(); + final controller = _storageSyncController(context); + + if (controller.isGuest) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SettingsSectionHeader(title: 'Storage & sync'), + 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 & sync'), - SettingsRow(label: 'Storage backend', value: prefs.storageBackend, onTap: () => _showStoragePicker(context)), + SettingsRow(label: 'Current mode', value: controller.modeLabel), + SettingsRow(label: 'Local database', value: controller.databaseLabel), SettingsRow( - label: 'Sync server', - value: prefs.serverUrl.isEmpty ? 'Not connected' : prefs.serverUrl, - onTap: () {}, + label: 'Metadata sync', + value: controller.metadataSyncLabel, + onTap: controller.shouldShowServerSettings ? () => _showSyncServerPicker(context) : null, + showChevron: controller.shouldShowServerSettings, ), - SettingsRow(label: 'Current status', value: _syncStatusLabel(auth, sync)), - SettingsToggleRow( - label: 'Sync enabled', - value: prefs.syncEnabled, - onChanged: (value) => prefs.syncEnabled = value, + if (controller.shouldShowCustomServerUrls) ...[ + SettingsRow(label: 'API server', value: controller.syncSettings.activeApiConfig.serverBaseUri.toString()), + SettingsRow( + label: 'PowerSync service', + value: controller.syncSettings.activeApiConfig.powerSyncServiceUri.toString(), + ), + ], + if (controller.shouldShowServerSettings) ...[ + SettingsRow(label: 'Current status', value: controller.statusLabel), + SettingsRow(label: 'Pending writes', value: controller.pendingWritesLabel), + SettingsRow(label: 'Sync detail', value: controller.syncDetail), + ], + SettingsRow( + label: 'Media storage', + value: controller.mediaStorageLabel, + onTap: controller.shouldShowServerSettings ? () => _showMediaStoragePicker(context) : null, + showChevron: controller.shouldShowServerSettings, ), + if (controller.mediaStorageRestrictionMessage case final message?) + SettingsRow(label: 'Media policy', value: message), + if (controller.canReconnect) SettingsRow(label: 'Reconnect sync', onTap: () => _handleReconnectSync(context)), + if (controller.canClearGuestLibrary) + SettingsRow(label: 'Clear local library', onTap: () => _confirmClearLocalLibrary(context)), + if (controller.canClearAuthenticatedCache) + SettingsRow(label: 'Clear account local cache', onTap: () => _confirmClearAuthenticatedCache(context)), ], ); } @@ -889,33 +938,20 @@ class _ProfilePageState extends State { Widget _buildStorageSyncContent(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final textTheme = Theme.of(context).textTheme; - final prefs = context.watch(); - final auth = context.watch(); - final sync = context.watch(); - final connected = sync.connected; + final controller = _storageSyncController(context); + final connected = controller.syncState.connected; + + if (controller.isGuest) return _buildOfflineStorageSyncContent(context); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ SettingsCard( - title: 'Storage backends', + title: 'Library storage', children: [ - _buildDropdownField( - context, - label: 'Primary backend', - value: prefs.storageBackend, - options: const ['Local', 'Cloud', 'Self-hosted'], - onChanged: (value) => prefs.storageBackend = value, - ), - const SizedBox(height: Spacing.md), - Align( - alignment: Alignment.centerLeft, - child: OutlinedButton.icon( - onPressed: () {}, - icon: const Icon(Icons.add, size: IconSizes.small), - label: const Text('Add storage backend'), - ), - ), + _buildInfoRow(context, label: 'Current mode', value: controller.modeLabel), + _buildInfoRow(context, label: 'Local database', value: controller.databaseLabel), + _buildInfoRow(context, label: 'Metadata sync', value: controller.metadataSyncLabel), ], ), const SizedBox(height: Spacing.lg), @@ -930,7 +966,7 @@ class _ProfilePageState extends State { children: [ Text('Server', style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)), const SizedBox(height: Spacing.xs), - Text(prefs.serverUrl.isEmpty ? 'Not connected' : prefs.serverUrl, style: textTheme.bodyLarge), + Text(controller.metadataSyncLabel, style: textTheme.bodyLarge), ], ), ), @@ -941,7 +977,7 @@ class _ProfilePageState extends State { borderRadius: BorderRadius.circular(AppRadius.sm), ), child: Text( - _syncStatusLabel(auth, sync), + controller.statusLabel, style: textTheme.labelSmall?.copyWith( color: connected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant, ), @@ -950,50 +986,160 @@ class _ProfilePageState extends State { ], ), const SizedBox(height: Spacing.md), - _buildSegmentedField( - context, - label: 'Server type', - value: prefs.serverType, - options: const {'official': 'Official', 'self-hosted': 'Self-hosted'}, - onChanged: (value) => prefs.serverType = value, - ), - const SizedBox(height: Spacing.md), - SettingsToggleRow( - label: 'Sync enabled', - value: prefs.syncEnabled, - onChanged: (value) => prefs.syncEnabled = value, - ), - const SizedBox(height: Spacing.md), - _buildDropdownField( - context, - label: 'Sync interval', - value: prefs.syncInterval, - options: const ['realtime', '1min', '5min', 'manual'], - labels: const { - 'realtime': 'Real-time', - '1min': 'Every minute', - '5min': 'Every 5 minutes', - 'manual': 'Manual only', - }, - onChanged: (value) => prefs.syncInterval = value, - ), - const SizedBox(height: Spacing.md), - _buildSegmentedField( - context, - label: 'Conflict resolution', - value: prefs.conflictResolution, - options: const {'server': 'Server wins', 'client': 'Client wins', 'ask': 'Ask me'}, - onChanged: (value) => prefs.conflictResolution = value, + if (controller.shouldShowServerSettings) ...[ + _buildSegmentedField( + context, + label: 'Sync server', + value: controller.syncSettings.serverType, + options: const {SyncServerType.official: 'Official server', SyncServerType.custom: 'Custom server'}, + onChanged: (value) => _selectSyncServerType(context, value), + ), + if (controller.shouldShowCustomServerUrls) ...[ + const SizedBox(height: Spacing.md), + _buildInfoRow( + context, + label: 'API server', + value: controller.syncSettings.activeApiConfig.serverBaseUri.toString(), + ), + _buildInfoRow( + context, + label: 'PowerSync service', + value: controller.syncSettings.activeApiConfig.powerSyncServiceUri.toString(), + ), + const SizedBox(height: Spacing.sm), + OutlinedButton( + onPressed: () => _showCustomServerDialog(context), + child: const Text('Edit custom server'), + ), + ], + const SizedBox(height: Spacing.md), + _buildInfoRow(context, label: 'Status', value: controller.statusLabel), + _buildInfoRow(context, label: 'Pending writes', value: controller.pendingWritesLabel), + const SizedBox(height: Spacing.md), + Padding( + padding: const EdgeInsets.symmetric(horizontal: Spacing.sm, vertical: Spacing.xs), + child: Text( + controller.syncDetail, + style: textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ), + ] else + Padding( + padding: const EdgeInsets.symmetric(horizontal: Spacing.sm, vertical: Spacing.xs), + child: Text( + 'Guest libraries stay fully offline. No server connection is used.', + style: textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ), + if (controller.canReconnect || + controller.canClearGuestLibrary || + controller.canClearAuthenticatedCache) ...[ + 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 sync'), + ), + if (controller.canClearGuestLibrary) + OutlinedButton.icon( + onPressed: () => _confirmClearLocalLibrary(context), + icon: const Icon(Icons.delete_outline, size: IconSizes.small), + label: const Text('Clear local library'), + ), + if (controller.canClearAuthenticatedCache) + OutlinedButton.icon( + onPressed: () => _confirmClearAuthenticatedCache(context), + icon: const Icon(Icons.cleaning_services_outlined, size: IconSizes.small), + label: const Text('Clear account local cache'), + ), + ], + ), + ], + ], + ), + const SizedBox(height: Spacing.lg), + SettingsCard( + title: 'Media storage', + children: [ + _buildInfoRow(context, label: 'Book files and covers', value: controller.mediaStorageLabel), + if (controller.mediaStorageRestrictionMessage case final message?) + Padding( + padding: const EdgeInsets.symmetric(horizontal: Spacing.sm, vertical: Spacing.xs), + child: Text(message, style: textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant)), + ), + if (controller.shouldShowServerSettings && controller.syncSettings.serverType == SyncServerType.custom) ...[ + const SizedBox(height: Spacing.md), + _buildSegmentedField( + context, + label: 'Media destination', + value: controller.syncSettings.mediaStorageBackend, + options: const { + MediaStorageBackend.local: 'Local device', + MediaStorageBackend.selfHosted: 'Self-hosted server', + }, + onChanged: (value) => controller.syncSettings.mediaStorageBackend = value, + ), + ], + if (controller.shouldShowServerSettings && controller.syncSettings.serverType == SyncServerType.official) + Padding( + padding: const EdgeInsets.symmetric(horizontal: Spacing.sm, vertical: Spacing.xs), + child: Text( + 'Use a custom server if you want Papyrus-managed media storage. Third-party storage backends can be added later.', + style: textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ), + ], + ), + ], + ); + } + + 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'), ), - const SizedBox(height: Spacing.md), - Padding( - padding: const EdgeInsets.symmetric(horizontal: Spacing.sm, vertical: Spacing.xs), - child: Text(_syncDetail(sync), style: textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant)), + OutlinedButton.icon( + onPressed: () => _showBackupUnavailable(context, 'Backup import'), + icon: const Icon(Icons.file_upload_outlined, size: IconSizes.small), + label: const Text('Import backup'), ), - const SizedBox(height: Spacing.sm), - Align( - alignment: Alignment.centerLeft, - child: OutlinedButton(onPressed: () {}, child: const Text('Sync now')), + OutlinedButton.icon( + onPressed: () => _confirmClearLocalLibrary(context), + icon: const Icon(Icons.delete_outline, size: IconSizes.small), + label: const Text('Clear local library'), ), ], ), @@ -1001,22 +1147,32 @@ class _ProfilePageState extends State { ); } - String _syncStatusLabel(AuthProvider auth, SyncState sync) { - if (auth.isOfflineMode) return 'Guest local'; - if (sync.uploadError != null || sync.downloadError != null) return 'Error'; - if (sync.connecting) return 'Connecting'; - if (sync.uploading || sync.downloading) return 'Syncing'; - if (sync.connected) return sync.hasPendingWrites ? 'Pending upload' : 'Connected'; - return 'Offline'; + StorageSyncController _storageSyncController(BuildContext context) { + return StorageSyncController( + authProvider: context.watch(), + powerSyncService: context.read(), + syncSettings: context.watch(), + syncState: context.watch(), + ); } - String _syncDetail(SyncState sync) { - final error = sync.uploadError ?? sync.downloadError; - if (error != null) return 'Sync error: $error'; - if (sync.hasPendingWrites) return 'Local changes are waiting to upload'; - final lastSyncedAt = sync.lastSyncedAt; - if (lastSyncedAt == null) return 'No completed sync yet'; - return 'Last sync: ${lastSyncedAt.toLocal()}'; + 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 --------------------------------------------------------- @@ -1209,6 +1365,213 @@ class _ProfilePageState extends State { showLicensePage(context: context, applicationName: 'Papyrus', applicationVersion: '1.0.0'); } + void _showSyncServerPicker(BuildContext context) { + final settings = context.read(); + + _showPickerSheet( + context, + items: const [('Official server', 'official'), ('Custom server', 'custom')], + selected: settings.serverType.name, + onSelected: (value) { + final serverType = value == SyncServerType.custom.name ? SyncServerType.custom : SyncServerType.official; + unawaited(_selectSyncServerType(context, serverType)); + }, + ); + } + + Future _selectSyncServerType(BuildContext context, SyncServerType value) async { + final settings = context.read(); + + if (value == SyncServerType.official) { + settings.serverType = SyncServerType.official; + return; + } + + if (settings.customApiUrl.isEmpty || settings.customPowerSyncUrl.isEmpty) { + await _showCustomServerDialog(context, switchToCustomAfterSave: true); + return; + } + + settings.serverType = SyncServerType.custom; + } + + void _showMediaStoragePicker(BuildContext context) { + final settings = context.read(); + final items = settings.serverType == SyncServerType.custom + ? const [('Local device only', 'local'), ('Self-hosted server', 'selfHosted')] + : const [('Local device only', 'local')]; + + _showPickerSheet( + context, + items: items, + selected: settings.mediaStorageBackend.name, + onSelected: (value) { + settings.mediaStorageBackend = value == MediaStorageBackend.selfHosted.name + ? MediaStorageBackend.selfHosted + : MediaStorageBackend.local; + }, + ); + } + + Future _showCustomServerDialog(BuildContext context, {bool switchToCustomAfterSave = false}) async { + final settings = context.read(); + final apiController = TextEditingController( + text: settings.customApiUrl.isEmpty ? 'http://localhost:8080' : settings.customApiUrl, + ); + final powerSyncController = TextEditingController( + text: settings.customPowerSyncUrl.isEmpty ? 'http://localhost:8081' : settings.customPowerSyncUrl, + ); + final messenger = ScaffoldMessenger.of(context); + + try { + await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('Custom sync server'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: apiController, + decoration: const InputDecoration(labelText: 'Papyrus API URL'), + keyboardType: TextInputType.url, + ), + const SizedBox(height: Spacing.md), + TextField( + controller: powerSyncController, + decoration: const InputDecoration(labelText: 'PowerSync service URL'), + keyboardType: TextInputType.url, + ), + ], + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(dialogContext), child: const Text('Cancel')), + FilledButton( + onPressed: () { + try { + settings.setCustomServerUrls(apiUrl: apiController.text, powerSyncUrl: powerSyncController.text); + if (switchToCustomAfterSave) { + settings.serverType = SyncServerType.custom; + } + Navigator.pop(dialogContext); + } catch (error) { + messenger.showSnackBar(SnackBar(content: Text('Invalid server URL: $error'))); + } + }, + child: const Text('Save'), + ), + ], + ), + ); + } finally { + apiController.dispose(); + powerSyncController.dispose(); + } + } + + Future _handleReconnectSync(BuildContext context) async { + final messenger = ScaffoldMessenger.of(context); + try { + await context.read().reconnect(); + messenger.showSnackBar(const SnackBar(content: Text('Sync reconnect requested.'))); + } catch (error) { + messenger.showSnackBar(SnackBar(content: Text('Could not reconnect sync: $error'))); + } + } + + void _showOfflineBackupActions(BuildContext context) { + showModalBottomSheet( + 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(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); + try { + await context.read().clearGuestLibrary(); + messenger.showSnackBar(const SnackBar(content: Text('Local library cleared.'))); + } catch (error) { + messenger.showSnackBar(SnackBar(content: Text('Could not clear local library: $error'))); + } + } + + Future _confirmClearAuthenticatedCache(BuildContext context) async { + final confirmed = await _confirmStorageAction( + context, + title: 'Clear account local cache', + message: + 'This clears only the local account cache on this device. Synced books remain on the server and will download again after reconnecting.', + actionLabel: 'Clear local cache', + ); + if (!confirmed || !context.mounted) return; + + final messenger = ScaffoldMessenger.of(context); + try { + await context.read().clearAuthenticatedCache(); + messenger.showSnackBar(const SnackBar(content: Text('Account local cache cleared.'))); + } catch (error) { + messenger.showSnackBar(SnackBar(content: Text('Could not clear account cache: $error'))); + } + } + + Future _confirmStorageAction( + BuildContext context, { + required String title, + required String message, + required String actionLabel, + }) async { + return await showDialog( + 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 // ============================================================================ @@ -1412,17 +1775,6 @@ class _ProfilePageState extends State { ); } - void _showStoragePicker(BuildContext context) { - final prefs = context.read(); - - _showPickerSheet( - context, - items: [('Local', 'Local'), ('Cloud', 'Cloud'), ('Self-hosted', 'Self-hosted')], - selected: prefs.storageBackend, - onSelected: (value) => prefs.storageBackend = value, - ); - } - void _showPickerSheet( BuildContext context, { required List<(String label, String value)> items, diff --git a/app/lib/powersync/powersync_service.dart b/app/lib/powersync/powersync_service.dart index 0fa3574..1f35dd4 100644 --- a/app/lib/powersync/powersync_service.dart +++ b/app/lib/powersync/powersync_service.dart @@ -11,7 +11,8 @@ import 'package:path_provider/path_provider.dart'; import 'package:powersync/powersync.dart'; typedef PowerSyncConnectorFactory = PowerSyncBackendConnector Function(); -typedef LibraryDatabasePathResolver = Future Function(LibraryDatabaseMode mode); +typedef LibraryDatabasePathResolver = + Future Function(LibraryDatabaseMode mode, String? profileKey, String? userId); class PapyrusPowerSyncService implements BookRepository { final PowerSyncConnectorFactory connectorFactory; @@ -27,6 +28,7 @@ class PapyrusPowerSyncService implements BookRepository { Future? _modeOperation; LibraryDatabaseMode? _mode; String? _authenticatedUserId; + String? _authenticatedProfileKey; SyncState _syncState = const SyncState(); PapyrusPowerSyncService({required this.connectorFactory, this.pathResolver, this.connectAuthenticated = true}); @@ -37,8 +39,12 @@ class PapyrusPowerSyncService implements BookRepository { Future activateGuest() => _switchMode(LibraryDatabaseMode.guest); - Future activateAuthenticated(String userId) { - return _switchMode(LibraryDatabaseMode.authenticated, authenticatedUserId: userId); + Future activateAuthenticated(String userId, {String profileKey = 'official'}) { + return _switchMode( + LibraryDatabaseMode.authenticated, + authenticatedUserId: userId, + authenticatedProfileKey: profileKey, + ); } Future setOnline(bool online) async { @@ -54,15 +60,54 @@ class PapyrusPowerSyncService implements BookRepository { } } + Future reconnect() async { + await _modeOperation; + if (_mode != LibraryDatabaseMode.authenticated) { + throw StateError('Only authenticated libraries can connect to PowerSync'); + } + final database = _requireDatabase(); + _watchStatus(database); + await database.disconnect(); + await database.connect(connector: connectorFactory()); + } + + Future clearGuestLibrary() async { + await _modeOperation; + if (_mode != LibraryDatabaseMode.guest) { + throw StateError('Only guest libraries can be cleared with clearGuestLibrary'); + } + final database = _requireDatabase(); + await database.execute('DELETE FROM books'); + _booksController.add(const []); + _setSyncState(const SyncState()); + } + + Future clearAuthenticatedCache() async { + await _modeOperation; + if (_mode != LibraryDatabaseMode.authenticated) { + throw StateError('Only authenticated libraries can clear the account cache'); + } + final userId = _authenticatedUserId; + final profileKey = _authenticatedProfileKey ?? 'official'; + if (userId == null) { + throw StateError('Authenticated library is missing a user id'); + } + + await _closeActive(clearAuthenticated: true); + _mode = null; + _authenticatedUserId = null; + _authenticatedProfileKey = null; + _booksController.add(const []); + _setSyncState(const SyncState()); + await activateAuthenticated(userId, profileKey: profileKey); + } + Future deactivate({bool clearAuthenticated = true}) async { await _modeOperation; - final previousMode = _mode; await _closeActive(clearAuthenticated: clearAuthenticated); - if (clearAuthenticated && previousMode != LibraryDatabaseMode.authenticated) { - await _clearStoredAuthenticatedDatabase(); - } _mode = null; _authenticatedUserId = null; + _authenticatedProfileKey = null; _booksController.add(const []); _setSyncState(const SyncState()); } @@ -104,15 +149,20 @@ class PapyrusPowerSyncService implements BookRepository { await _syncStateController.close(); } - Future _switchMode(LibraryDatabaseMode mode, {String? authenticatedUserId}) async { + Future _switchMode( + LibraryDatabaseMode mode, { + String? authenticatedUserId, + String? authenticatedProfileKey, + }) async { await _modeOperation; if (_mode == mode && _database != null && - (mode == LibraryDatabaseMode.guest || _authenticatedUserId == authenticatedUserId)) { + (mode == LibraryDatabaseMode.guest || + (_authenticatedUserId == authenticatedUserId && _authenticatedProfileKey == authenticatedProfileKey))) { return; } - final operation = _performModeSwitch(mode, authenticatedUserId); + final operation = _performModeSwitch(mode, authenticatedUserId, authenticatedProfileKey); _modeOperation = operation; try { await operation; @@ -123,10 +173,15 @@ class PapyrusPowerSyncService implements BookRepository { } } - Future _performModeSwitch(LibraryDatabaseMode mode, String? authenticatedUserId) async { - await _closeActive(clearAuthenticated: _mode == LibraryDatabaseMode.authenticated); + Future _performModeSwitch( + LibraryDatabaseMode mode, + String? authenticatedUserId, + String? authenticatedProfileKey, + ) async { + await _closeActive(clearAuthenticated: false); _mode = mode; _authenticatedUserId = authenticatedUserId; + _authenticatedProfileKey = authenticatedProfileKey; _booksController.add(const []); final database = PowerSyncDatabase( @@ -239,27 +294,23 @@ class PapyrusPowerSyncService implements BookRepository { await database.close(); } - Future _clearStoredAuthenticatedDatabase() async { - final database = PowerSyncDatabase( - schema: papyrusAccountSchema, - path: await _databasePath(LibraryDatabaseMode.authenticated), - ); - await database.initialize(); - await database.disconnectAndClear(clearLocal: true); - await database.close(); - } - Future _databasePath(LibraryDatabaseMode mode) async { final customResolver = pathResolver; if (customResolver != null) { - return customResolver(mode); + return customResolver(mode, _authenticatedProfileKey, _authenticatedUserId); } - final fileName = mode == LibraryDatabaseMode.guest ? 'papyrus-guest.db' : 'papyrus-account.db'; + final fileName = mode == LibraryDatabaseMode.guest + ? 'papyrus-guest.db' + : 'papyrus-account-${_safeFileComponent(_authenticatedProfileKey ?? 'official')}-${_safeFileComponent(_authenticatedUserId ?? 'anonymous')}.db'; if (kIsWeb) { return fileName; } final directory = await getApplicationSupportDirectory(); return path.join(directory.path, fileName); } + + String _safeFileComponent(String value) { + return value.replaceAll(RegExp(r'[^a-zA-Z0-9_.-]'), '_'); + } } diff --git a/app/lib/powersync/storage_sync_controller.dart b/app/lib/powersync/storage_sync_controller.dart new file mode 100644 index 0000000..ce5ec9a --- /dev/null +++ b/app/lib/powersync/storage_sync_controller.dart @@ -0,0 +1,94 @@ +import 'package:papyrus/powersync/powersync_service.dart'; +import 'package:papyrus/powersync/sync_state.dart'; +import 'package:papyrus/providers/auth_provider.dart'; +import 'package:papyrus/providers/sync_settings_provider.dart'; + +class StorageSyncController { + StorageSyncController({ + required this.authProvider, + required this.powerSyncService, + required this.syncSettings, + required this.syncState, + }); + + final AuthProvider authProvider; + final PapyrusPowerSyncService powerSyncService; + final SyncSettingsProvider syncSettings; + final SyncState syncState; + + LibraryDatabaseMode? get databaseMode => powerSyncService.mode; + + bool get isGuest => authProvider.isOfflineMode || databaseMode == LibraryDatabaseMode.guest; + bool get isAuthenticated => authProvider.isSignedIn && databaseMode == LibraryDatabaseMode.authenticated; + bool get isSignedOut => !authProvider.isSignedIn && !authProvider.isOfflineMode; + + String get modeLabel { + if (isGuest) return 'Guest local'; + if (isAuthenticated) return 'Account synced'; + return 'Signed out'; + } + + String get databaseLabel { + switch (databaseMode) { + case LibraryDatabaseMode.guest: + return 'papyrus-guest.db'; + case LibraryDatabaseMode.authenticated: + return 'Server-scoped account cache'; + case null: + return 'No active library database'; + } + } + + String get backendLabel { + if (isAuthenticated) return syncSettings.activeServerLabel; + if (isGuest) return 'Local only'; + return 'Not connected'; + } + + String get metadataSyncLabel { + if (isGuest) return 'Metadata sync off'; + return syncSettings.activeServerLabel; + } + + bool get shouldShowServerSettings => !isGuest; + + bool get shouldShowCustomServerUrls { + return shouldShowServerSettings && syncSettings.serverType == SyncServerType.custom; + } + + String get mediaStorageLabel => syncSettings.mediaStorageLabel; + + String? get mediaStorageRestrictionMessage => syncSettings.mediaStorageRestrictionMessage; + + String get statusLabel { + if (isGuest) return 'Guest local'; + if (isSignedOut) return 'Signed out'; + if (syncState.uploadError != null || syncState.downloadError != null) return 'Error'; + if (syncState.connecting) return 'Connecting'; + if (syncState.uploading || syncState.downloading) return 'Syncing'; + if (syncState.connected) { + return syncState.hasPendingWrites ? 'Pending upload' : 'Connected'; + } + return 'Offline'; + } + + String get syncDetail { + final error = syncState.uploadError ?? syncState.downloadError; + if (error != null) return 'Sync error: $error'; + if (syncState.hasPendingWrites) return 'Local changes are waiting to upload'; + final lastSyncedAt = syncState.lastSyncedAt; + if (lastSyncedAt == null) return 'No completed sync yet'; + return 'Last sync: ${lastSyncedAt.toLocal()}'; + } + + String get pendingWritesLabel => + syncState.hasPendingWrites ? 'Local changes pending upload' : 'No pending local writes'; + + bool get canReconnect => isAuthenticated; + bool get canClearGuestLibrary => databaseMode == LibraryDatabaseMode.guest; + bool get canClearAuthenticatedCache => databaseMode == LibraryDatabaseMode.authenticated; + + Future reconnect() => powerSyncService.reconnect(); + Future clearGuestLibrary() => powerSyncService.clearGuestLibrary(); + Future clearAuthenticatedCache() => powerSyncService.clearAuthenticatedCache(); +} diff --git a/app/lib/providers/auth_provider.dart b/app/lib/providers/auth_provider.dart index 1fa5ba3..dfe7d47 100644 --- a/app/lib/providers/auth_provider.dart +++ b/app/lib/providers/auth_provider.dart @@ -7,7 +7,7 @@ import 'package:papyrus/auth/auth_repository.dart'; import 'package:shared_preferences/shared_preferences.dart'; class AuthProvider extends ChangeNotifier { - final AuthRepository _repository; + AuthRepository _repository; final SharedPreferences _prefs; static const _keyOfflineMode = 'offline_mode'; @@ -43,6 +43,19 @@ class AuthProvider extends ChangeNotifier { } } + Future replaceRepository(AuthRepository repository, {bool bootstrapNewRepository = true}) async { + _repository = repository; + _user = null; + _error = null; + + if (_isOfflineMode || !bootstrapNewRepository) { + _setStatus(AuthStatus.signedOut); + return; + } + + await bootstrap(); + } + Future bootstrap() async { _setStatus(AuthStatus.bootstrapping); diff --git a/app/lib/providers/sync_settings_provider.dart b/app/lib/providers/sync_settings_provider.dart new file mode 100644 index 0000000..0880d3f --- /dev/null +++ b/app/lib/providers/sync_settings_provider.dart @@ -0,0 +1,123 @@ +import 'dart:convert'; + +import 'package:crypto/crypto.dart'; +import 'package:flutter/foundation.dart'; +import 'package:papyrus/auth/papyrus_api_config.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +enum SyncServerType { official, custom } + +enum MediaStorageBackend { local, selfHosted } + +class SyncSettingsProvider extends ChangeNotifier { + static const _keyServerType = 'sync_server_type'; + static const _keyCustomApiUrl = 'sync_custom_api_url'; + static const _keyCustomPowerSyncUrl = 'sync_custom_powersync_url'; + static const _keyMediaStorageBackend = 'media_storage_backend'; + + final SharedPreferences _prefs; + final PapyrusApiConfig officialConfig; + + SyncSettingsProvider(this._prefs, {required this.officialConfig}); + + SyncServerType get serverType { + final value = _prefs.getString(_keyServerType); + return value == SyncServerType.custom.name ? SyncServerType.custom : SyncServerType.official; + } + + set serverType(SyncServerType value) { + _prefs.setString(_keyServerType, value.name); + if (value == SyncServerType.official && mediaStorageBackend != MediaStorageBackend.local) { + _prefs.setString(_keyMediaStorageBackend, MediaStorageBackend.local.name); + } + notifyListeners(); + } + + String get customApiUrl => _prefs.getString(_keyCustomApiUrl) ?? ''; + + String get customPowerSyncUrl => _prefs.getString(_keyCustomPowerSyncUrl) ?? ''; + + void setCustomServerUrls({required String apiUrl, required String powerSyncUrl}) { + final normalizedApiUrl = _normalizeUrl(apiUrl); + final normalizedPowerSyncUrl = _normalizeUrl(powerSyncUrl); + + _prefs.setString(_keyCustomApiUrl, normalizedApiUrl); + _prefs.setString(_keyCustomPowerSyncUrl, normalizedPowerSyncUrl); + notifyListeners(); + } + + String get activeServerLabel { + return serverType == SyncServerType.official ? 'Official server' : 'Custom server'; + } + + PapyrusApiConfig get activeApiConfig { + if (serverType == SyncServerType.official) { + return officialConfig; + } + + return PapyrusApiConfig(serverBaseUri: Uri.parse(customApiUrl), powerSyncServiceUri: Uri.parse(customPowerSyncUrl)); + } + + String get activeProfileKey { + if (serverType == SyncServerType.official) { + return 'official'; + } + + final input = '${activeApiConfig.serverBaseUri}|${activeApiConfig.powerSyncServiceUri}'; + final digest = sha256.convert(utf8.encode(input)).toString(); + return 'custom-${digest.substring(0, 16)}'; + } + + MediaStorageBackend get mediaStorageBackend { + final value = _prefs.getString(_keyMediaStorageBackend); + final backend = value == MediaStorageBackend.selfHosted.name + ? MediaStorageBackend.selfHosted + : MediaStorageBackend.local; + + if (serverType == SyncServerType.official && backend != MediaStorageBackend.local) { + return MediaStorageBackend.local; + } + + return backend; + } + + set mediaStorageBackend(MediaStorageBackend value) { + if (serverType == SyncServerType.official && value != MediaStorageBackend.local) { + _prefs.setString(_keyMediaStorageBackend, MediaStorageBackend.local.name); + notifyListeners(); + return; + } + + _prefs.setString(_keyMediaStorageBackend, value.name); + notifyListeners(); + } + + String get mediaStorageLabel { + switch (mediaStorageBackend) { + case MediaStorageBackend.local: + return 'Local device only'; + case MediaStorageBackend.selfHosted: + return 'Self-hosted server'; + } + } + + String? get mediaStorageRestrictionMessage { + if (serverType == SyncServerType.official) { + return 'Official servers do not store book files or covers. Media stays on this device.'; + } + + return null; + } + + String _normalizeUrl(String value) { + final trimmed = value.trim(); + final withScheme = trimmed.contains('://') ? trimmed : 'http://$trimmed'; + final uri = Uri.tryParse(withScheme); + + if (uri == null || !uri.hasScheme || uri.host.isEmpty) { + throw ArgumentError.value(value, 'value', 'Expected a valid server URL'); + } + + return uri.removeFragment().toString(); + } +} diff --git a/app/lib/widgets/library/bulk_status_sheet.dart b/app/lib/widgets/library/bulk_status_sheet.dart index 0e0ce8f..317b4cc 100644 --- a/app/lib/widgets/library/bulk_status_sheet.dart +++ b/app/lib/widgets/library/bulk_status_sheet.dart @@ -35,7 +35,6 @@ class BulkStatusSheet extends StatelessWidget { @override Widget build(BuildContext context) { final textTheme = Theme.of(context).textTheme; - final colorScheme = Theme.of(context).colorScheme; return SafeArea( child: Column( diff --git a/app/lib/widgets/library/selection_header.dart b/app/lib/widgets/library/selection_header.dart index 8e630f2..1549fb4 100644 --- a/app/lib/widgets/library/selection_header.dart +++ b/app/lib/widgets/library/selection_header.dart @@ -42,7 +42,7 @@ class SelectionHeader extends StatelessWidget { child: Text(_allSelected ? 'Deselect all' : 'Select all'), ), const Spacer(), - if (actions != null) actions!, + ?actions, ], ); } diff --git a/app/lib/widgets/shared/eink_page_header.dart b/app/lib/widgets/shared/eink_page_header.dart index 387fad5..9b28326 100644 --- a/app/lib/widgets/shared/eink_page_header.dart +++ b/app/lib/widgets/shared/eink_page_header.dart @@ -30,7 +30,7 @@ class EinkPageHeader extends StatelessWidget { children: [ Text(title, style: Theme.of(context).textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.bold)), const Spacer(), - if (trailing != null) trailing!, + ?trailing, ], ), ); diff --git a/app/lib/widgets/statistics/stat_card.dart b/app/lib/widgets/statistics/stat_card.dart index 95394f0..af51312 100644 --- a/app/lib/widgets/statistics/stat_card.dart +++ b/app/lib/widgets/statistics/stat_card.dart @@ -203,7 +203,7 @@ class StatSectionCard extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text(title, style: textTheme.titleMedium), - if (action != null) action!, + ?action, ], ), const SizedBox(height: Spacing.md), diff --git a/app/test/pages/profile_storage_sync_test.dart b/app/test/pages/profile_storage_sync_test.dart new file mode 100644 index 0000000..2545df5 --- /dev/null +++ b/app/test/pages/profile_storage_sync_test.dart @@ -0,0 +1,265 @@ +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/pages/profile_page.dart'; +import 'package:papyrus/powersync/powersync_service.dart'; +import 'package:papyrus/powersync/sync_state.dart'; +import 'package:papyrus/providers/auth_provider.dart'; +import 'package:papyrus/providers/preferences_provider.dart'; +import 'package:papyrus/providers/sync_settings_provider.dart'; +import 'package:powersync/powersync.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class _MemoryRefreshTokenStorage implements RefreshTokenStorage { + String? value; + + @override + Future delete() async { + value = null; + } + + @override + Future read() async => value; + + @override + Future write(String refreshToken) async { + value = refreshToken; + } +} + +class _FakeAuthRepository extends AuthRepository { + _FakeAuthRepository() + : super( + apiClient: AuthApiClient(config: PapyrusApiConfig(serverBaseUri: Uri.parse('https://api.test'))), + tokenStore: TokenStore(_MemoryRefreshTokenStorage()), + ); + + AuthTokens? bootstrapResult; + + @override + Future bootstrap() async { + return bootstrapResult; + } + + @override + Future clearTokens() async {} +} + +class _OfflineConnector extends PowerSyncBackendConnector { + @override + Future fetchCredentials() async => null; + + @override + Future uploadData(PowerSyncDatabase database) async {} +} + +class _FakePowerSyncService extends PapyrusPowerSyncService { + _FakePowerSyncService({required this.currentMode, required this.currentSyncState}) + : super(connectorFactory: _OfflineConnector.new, connectAuthenticated: false); + + LibraryDatabaseMode? currentMode; + SyncState currentSyncState; + int reconnectCalls = 0; + int clearGuestLibraryCalls = 0; + int clearAuthenticatedCacheCalls = 0; + + @override + LibraryDatabaseMode? get mode => currentMode; + + @override + SyncState get syncState => currentSyncState; + + @override + Stream get syncStates => Stream.value(currentSyncState); + + @override + Future reconnect() async { + reconnectCalls += 1; + } + + @override + Future clearGuestLibrary() async { + clearGuestLibraryCalls += 1; + } + + @override + Future clearAuthenticatedCache() async { + clearAuthenticatedCacheCalls += 1; + } +} + +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + Future buildAuthProvider({bool guest = false, bool signedIn = false}) async { + final prefs = await SharedPreferences.getInstance(); + final repository = _FakeAuthRepository(); + if (signedIn) { + repository.bootstrapResult = _tokens(); + } + final provider = AuthProvider(prefs, repository: repository, bootstrapOnCreate: false); + await provider.bootstrap(); + if (guest) { + provider.setOfflineMode(true); + } + return provider; + } + + Future buildPage({ + required AuthProvider authProvider, + required _FakePowerSyncService powerSyncService, + Size screenSize = const Size(400, 900), + }) async { + final prefs = await SharedPreferences.getInstance(); + final config = PapyrusApiConfig( + serverBaseUri: Uri.parse('https://api.test'), + powerSyncServiceUri: Uri.parse('https://powersync.test'), + ); + + return MultiProvider( + providers: [ + ChangeNotifierProvider( + create: (_) => SyncSettingsProvider(prefs, officialConfig: config), + ), + Provider.value(value: powerSyncService), + StreamProvider.value(value: powerSyncService.syncStates, initialData: powerSyncService.syncState), + ChangeNotifierProvider.value(value: authProvider), + ChangeNotifierProvider(create: (_) => PreferencesProvider(prefs)), + ], + child: MaterialApp( + home: MediaQuery( + data: MediaQueryData(size: screenSize), + child: const ProfilePage(), + ), + ), + ); + } + + testWidgets('offline storage sync UI is local-first and hides sync internals', (tester) async { + final auth = await buildAuthProvider(guest: true); + final service = _FakePowerSyncService(currentMode: LibraryDatabaseMode.guest, currentSyncState: const SyncState()); + + await tester.pumpWidget(await buildPage(authProvider: auth, powerSyncService: service)); + await tester.pumpAndSettle(); + await tester.scrollUntilVisible(find.text('Storage & sync'), 400); + await tester.pumpAndSettle(); + + expect(find.text('Stored on this device'), findsOneWidget); + expect(find.text('Export or import a backup'), findsOneWidget); + expect(find.text('Clear local library'), findsOneWidget); + expect(find.textContaining('Nothing is sent to Papyrus servers'), findsOneWidget); + expect(find.text('Guest local'), findsNothing); + expect(find.text('papyrus-guest.db'), findsNothing); + expect(find.text('Metadata sync off'), findsNothing); + expect(find.text('Clear guest library'), findsNothing); + expect(find.text('https://api.test'), findsNothing); + expect(find.text('https://powersync.test'), findsNothing); + expect(find.text('Current mode'), findsNothing); + expect(find.text('Local database'), findsNothing); + expect(find.text('Metadata sync'), findsNothing); + expect(find.text('Media storage'), findsNothing); + expect(find.text('Storage backend'), findsNothing); + expect(find.text('Sync enabled'), findsNothing); + expect(find.text('Sync interval'), findsNothing); + expect(find.text('Conflict resolution'), findsNothing); + expect(find.text('Add storage backend'), findsNothing); + }); + + testWidgets('offline desktop storage sync is local-first and hides sync internals', (tester) async { + final auth = await buildAuthProvider(guest: true); + final service = _FakePowerSyncService(currentMode: LibraryDatabaseMode.guest, currentSyncState: const SyncState()); + + await tester.pumpWidget( + await buildPage(authProvider: auth, powerSyncService: service, screenSize: const Size(1200, 900)), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Storage & sync').first); + await tester.pumpAndSettle(); + + expect(find.text('Library storage'), findsOneWidget); + expect(find.text('Your library is stored on this device.'), findsOneWidget); + expect(find.textContaining('Nothing is sent to Papyrus servers'), findsOneWidget); + expect(find.text('Export backup'), findsOneWidget); + expect(find.text('Import backup'), findsOneWidget); + expect(find.text('Clear local library'), findsOneWidget); + expect(find.text('Guest local'), findsNothing); + expect(find.text('papyrus-guest.db'), findsNothing); + expect(find.text('Metadata sync off'), findsNothing); + expect(find.text('Clear guest library'), findsNothing); + expect(find.text('Current mode'), findsNothing); + expect(find.text('Local database'), findsNothing); + expect(find.text('Metadata sync'), findsNothing); + expect(find.text('Media storage'), findsNothing); + }); + + testWidgets('authenticated storage sync UI shows official metadata sync and local-only media policy', (tester) async { + final auth = await buildAuthProvider(signedIn: true); + final service = _FakePowerSyncService( + currentMode: LibraryDatabaseMode.authenticated, + currentSyncState: SyncState(connected: true, lastSyncedAt: DateTime.utc(2026, 6, 27, 10, 30)), + ); + + await tester.pumpWidget(await buildPage(authProvider: auth, powerSyncService: service)); + await tester.pumpAndSettle(); + await tester.scrollUntilVisible(find.text('Storage & sync'), 400); + await tester.pumpAndSettle(); + + expect(find.text('Account synced'), findsWidgets); + expect(find.text('Server-scoped account cache'), findsOneWidget); + expect(find.text('Official server'), findsWidgets); + expect(find.text('Local device only'), findsOneWidget); + expect(find.textContaining('Official servers do not store book files or covers'), findsOneWidget); + expect(find.text('Connected'), findsWidgets); + expect(find.text('Reconnect sync'), findsOneWidget); + expect(find.text('Clear account local cache'), findsOneWidget); + + await tester.ensureVisible(find.text('Reconnect sync')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Reconnect sync')); + await tester.pump(); + + expect(service.reconnectCalls, 1); + }); + + testWidgets('storage sync UI shows pending writes and sync errors', (tester) async { + final auth = await buildAuthProvider(signedIn: true); + final service = _FakePowerSyncService( + currentMode: LibraryDatabaseMode.authenticated, + currentSyncState: const SyncState(connected: true, hasPendingWrites: true, uploadError: 'upload failed'), + ); + + await tester.pumpWidget(await buildPage(authProvider: auth, powerSyncService: service)); + await tester.pumpAndSettle(); + await tester.scrollUntilVisible(find.text('Storage & sync'), 400); + await tester.pumpAndSettle(); + + expect(find.text('Error'), findsWidgets); + expect(find.text('Sync error: upload failed'), findsOneWidget); + expect(find.text('Local changes pending upload'), findsOneWidget); + }); +} + +AuthTokens _tokens() { + return 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', + avatarUrl: null, + emailVerified: true, + createdAt: null, + lastLoginAt: null, + ), + ); +} diff --git a/app/test/powersync/powersync_service_test.dart b/app/test/powersync/powersync_service_test.dart index 7c31386..78b1062 100644 --- a/app/test/powersync/powersync_service_test.dart +++ b/app/test/powersync/powersync_service_test.dart @@ -36,8 +36,10 @@ void main() { return PapyrusPowerSyncService( connectorFactory: OfflineConnector.new, connectAuthenticated: false, - pathResolver: (mode) async => - path.join(directory.path, mode == LibraryDatabaseMode.guest ? 'guest.db' : 'account.db'), + pathResolver: (mode, profileKey, userId) async => path.join( + directory.path, + mode == LibraryDatabaseMode.guest ? 'guest.db' : 'account-${profileKey ?? 'default'}-${userId ?? 'none'}.db', + ), ); } @@ -67,4 +69,52 @@ void main() { expect(await second.getById('account-book'), isNull); await second.close(); }); + + test('clearGuestLibrary removes only guest-local books', () async { + final first = service(); + await first.activateGuest(); + await first.upsert(_book('guest-book')); + + await first.clearGuestLibrary(); + + expect(await first.getById('guest-book'), isNull); + await first.close(); + }); + + test('clearAuthenticatedCache removes local account cache for the active account', () async { + final first = service(); + await first.activateAuthenticated('user-one'); + await first.upsert(_book('account-book')); + + await first.clearAuthenticatedCache(); + + expect(first.mode, LibraryDatabaseMode.authenticated); + expect(await first.getById('account-book'), isNull); + await first.close(); + }); + + test('reconnect requires an authenticated database', () async { + final first = service(); + await first.activateGuest(); + + expect(first.reconnect(), throwsStateError); + await first.close(); + }); + + test('authenticated cache is isolated by sync server profile', () async { + final first = service(); + await first.activateAuthenticated('user-one', profileKey: 'official'); + await first.upsert(_book('official-book')); + + await first.activateAuthenticated('user-one', profileKey: 'custom-local'); + + expect(await first.getById('official-book'), isNull); + await first.upsert(_book('custom-book')); + + await first.activateAuthenticated('user-one', profileKey: 'official'); + + expect((await first.getById('official-book'))?.title, 'Persistent guest book'); + expect(await first.getById('custom-book'), isNull); + await first.close(); + }); } diff --git a/app/test/providers/auth_provider_test.dart b/app/test/providers/auth_provider_test.dart index f5a8930..bc0e729 100644 --- a/app/test/providers/auth_provider_test.dart +++ b/app/test/providers/auth_provider_test.dart @@ -104,6 +104,20 @@ void main() { expect(provider.isSignedIn, isFalse); expect(repository.clearCalled, isTrue); }); + + test('switching auth repository bootstraps the new server session without clearing the old one', () async { + final prefs = await SharedPreferences.getInstance(); + final officialRepository = FakeAuthRepository()..bootstrapResult = _tokens('Official User'); + final customRepository = FakeAuthRepository()..bootstrapResult = _tokens('Custom User'); + final provider = AuthProvider(prefs, repository: officialRepository, bootstrapOnCreate: false); + + await provider.bootstrap(); + await provider.replaceRepository(customRepository); + + expect(officialRepository.clearCalled, isFalse); + expect(provider.isSignedIn, isTrue); + expect(provider.user?.displayName, 'Custom User'); + }); } AuthTokens _tokens(String displayName) { diff --git a/app/test/providers/sync_settings_provider_test.dart b/app/test/providers/sync_settings_provider_test.dart new file mode 100644 index 0000000..1c0c8e6 --- /dev/null +++ b/app/test/providers/sync_settings_provider_test.dart @@ -0,0 +1,74 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/auth/papyrus_api_config.dart'; +import 'package:papyrus/providers/sync_settings_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + PapyrusApiConfig officialConfig() { + return PapyrusApiConfig( + serverBaseUri: Uri.parse('https://api.papyrus.test'), + powerSyncServiceUri: Uri.parse('https://powersync.papyrus.test'), + ); + } + + test('defaults to the official metadata sync profile and local-only media storage', () async { + final prefs = await SharedPreferences.getInstance(); + final provider = SyncSettingsProvider(prefs, officialConfig: officialConfig()); + + expect(provider.serverType, SyncServerType.official); + expect(provider.activeServerLabel, 'Official server'); + expect(provider.activeProfileKey, 'official'); + expect(provider.activeApiConfig.serverBaseUri, Uri.parse('https://api.papyrus.test')); + expect(provider.activeApiConfig.powerSyncServiceUri, Uri.parse('https://powersync.papyrus.test')); + expect(provider.mediaStorageBackend, MediaStorageBackend.local); + }); + + test('persists a custom metadata sync profile with a stable profile key', () async { + final prefs = await SharedPreferences.getInstance(); + final provider = SyncSettingsProvider(prefs, officialConfig: officialConfig()); + + provider.setCustomServerUrls(apiUrl: 'http://localhost:8080', powerSyncUrl: 'http://localhost:8081'); + provider.serverType = SyncServerType.custom; + + final restored = SyncSettingsProvider(prefs, officialConfig: officialConfig()); + + expect(restored.serverType, SyncServerType.custom); + expect(restored.activeServerLabel, 'Custom server'); + expect(restored.customApiUrl, 'http://localhost:8080'); + expect(restored.customPowerSyncUrl, 'http://localhost:8081'); + expect(restored.activeApiConfig.serverBaseUri, Uri.parse('http://localhost:8080')); + expect(restored.activeApiConfig.powerSyncServiceUri, Uri.parse('http://localhost:8081')); + expect(restored.activeProfileKey, provider.activeProfileKey); + expect(restored.activeProfileKey, startsWith('custom-')); + }); + + test('does not allow official servers to be selected for media file storage', () async { + final prefs = await SharedPreferences.getInstance(); + final provider = SyncSettingsProvider(prefs, officialConfig: officialConfig()); + + provider.mediaStorageBackend = MediaStorageBackend.selfHosted; + + expect(provider.serverType, SyncServerType.official); + expect(provider.mediaStorageBackend, MediaStorageBackend.local); + expect(provider.mediaStorageRestrictionMessage, contains('Official servers do not store book files or covers')); + }); + + test('allows self-hosted media storage only when a custom sync server is active', () async { + final prefs = await SharedPreferences.getInstance(); + final provider = SyncSettingsProvider(prefs, officialConfig: officialConfig()); + + provider.setCustomServerUrls(apiUrl: 'http://localhost:8080', powerSyncUrl: 'http://localhost:8081'); + provider.serverType = SyncServerType.custom; + provider.mediaStorageBackend = MediaStorageBackend.selfHosted; + + expect(provider.mediaStorageBackend, MediaStorageBackend.selfHosted); + + provider.serverType = SyncServerType.official; + + expect(provider.mediaStorageBackend, MediaStorageBackend.local); + }); +}