From 80498208a4e33bf39c1f68b955cc671425b46a4c Mon Sep 17 00:00:00 2001 From: Eoic Date: Sat, 27 Jun 2026 17:48:25 +0300 Subject: [PATCH 1/2] Update online data sync settings UI --- app/lib/pages/profile_page.dart | 369 ++++++------------ .../powersync/storage_sync_controller.dart | 12 +- app/lib/providers/sync_settings_provider.dart | 333 +++++++++++++--- app/test/pages/profile_storage_sync_test.dart | 79 +++- .../sync_settings_provider_test.dart | 117 ++++-- 5 files changed, 546 insertions(+), 364 deletions(-) diff --git a/app/lib/pages/profile_page.dart b/app/lib/pages/profile_page.dart index 0ac3cb0..79649e8 100644 --- a/app/lib/pages/profile_page.dart +++ b/app/lib/pages/profile_page.dart @@ -231,35 +231,16 @@ class _ProfilePageState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ const SettingsSectionHeader(title: 'Storage & sync'), - SettingsRow(label: 'Current mode', value: controller.modeLabel), - SettingsRow(label: 'Local database', value: controller.databaseLabel), SettingsRow( - label: 'Metadata sync', - value: controller.metadataSyncLabel, - onTap: controller.shouldShowServerSettings ? () => _showSyncServerPicker(context) : null, - showChevron: controller.shouldShowServerSettings, + label: 'Data sync', + value: controller.dataSyncLabel, + onTap: () => _showManageSyncServersSheet(context), ), - 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)), + SettingsRow(label: 'Current status', value: controller.statusLabel), + SettingsRow(label: 'Pending changes', value: controller.pendingWritesLabel), + SettingsRow(label: 'File storage', value: controller.fileStorageLabel, showChevron: false), + 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) @@ -939,160 +920,43 @@ class _ProfilePageState extends State { final colorScheme = Theme.of(context).colorScheme; final textTheme = Theme.of(context).textTheme; final controller = _storageSyncController(context); - final connected = controller.syncState.connected; if (controller.isGuest) return _buildOfflineStorageSyncContent(context); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, + return SettingsCard( + title: 'Data sync', children: [ - SettingsCard( - title: 'Library storage', - children: [ - _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), - SettingsCard( - title: 'Metadata sync', - children: [ - Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Server', style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)), - const SizedBox(height: Spacing.xs), - Text(controller.metadataSyncLabel, style: textTheme.bodyLarge), - ], - ), - ), - Container( - padding: const EdgeInsets.symmetric(horizontal: Spacing.sm, vertical: Spacing.xs), - decoration: BoxDecoration( - color: connected ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(AppRadius.sm), - ), - child: Text( - controller.statusLabel, - style: textTheme.labelSmall?.copyWith( - color: connected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant, - ), - ), - ), - ], - ), - const SizedBox(height: Spacing.md), - 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'), - ), - ], - ), - ], - ], + _buildInfoRow(context, label: 'Active server', value: controller.dataSyncLabel), + _buildInfoRow(context, label: 'Status', value: controller.statusLabel), + _buildInfoRow(context, label: 'Pending changes', value: controller.pendingWritesLabel), + _buildInfoRow(context, label: 'File storage', value: controller.fileStorageLabel), + 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.lg), - SettingsCard( - title: 'Media storage', + const SizedBox(height: Spacing.sm), + Wrap( + spacing: Spacing.sm, + runSpacing: Spacing.sm, + alignment: WrapAlignment.start, 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.canReconnect) + OutlinedButton.icon( + onPressed: () => _handleReconnectSync(context), + icon: const Icon(Icons.sync, size: IconSizes.small), + label: const Text('Reconnect'), ), - ], - 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), - ), + OutlinedButton.icon( + onPressed: () => _showManageSyncServersSheet(context), + icon: const Icon(Icons.dns_outlined, size: IconSizes.small), + label: const Text('Manage servers'), + ), + 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'), ), ], ), @@ -1365,97 +1229,103 @@ 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; - }, + void _showManageSyncServersSheet(BuildContext context) { + showModalBottomSheet( + 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( + 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, {bool switchToCustomAfterSave = false}) async { + Future _showCustomServerDialog(BuildContext context, {CustomSyncServer? server}) 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 urlController = TextEditingController(text: server?.url ?? ''); 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, - ), - ], + 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: () { + onPressed: () async { try { - settings.setCustomServerUrls(apiUrl: apiController.text, powerSyncUrl: powerSyncController.text); - if (switchToCustomAfterSave) { - settings.serverType = SyncServerType.custom; + if (server == null) { + await settings.addCustomServer(urlController.text); + } else { + await settings.updateCustomServer(server.id, urlController.text); } - Navigator.pop(dialogContext); + if (dialogContext.mounted) Navigator.pop(dialogContext); } catch (error) { - messenger.showSnackBar(SnackBar(content: Text('Invalid server URL: $error'))); + messenger.showSnackBar(SnackBar(content: Text('Could not save server: $error'))); } }, child: const Text('Save'), @@ -1464,8 +1334,7 @@ class _ProfilePageState extends State { ), ); } finally { - apiController.dispose(); - powerSyncController.dispose(); + urlController.dispose(); } } diff --git a/app/lib/powersync/storage_sync_controller.dart b/app/lib/powersync/storage_sync_controller.dart index ce5ec9a..a1904d1 100644 --- a/app/lib/powersync/storage_sync_controller.dart +++ b/app/lib/powersync/storage_sync_controller.dart @@ -45,20 +45,14 @@ class StorageSyncController { return 'Not connected'; } - String get metadataSyncLabel { - if (isGuest) return 'Metadata sync off'; + String get dataSyncLabel { + if (isGuest) return 'Data 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 fileStorageLabel => syncSettings.fileStorageLabel; String get statusLabel { if (isGuest) return 'Guest local'; diff --git a/app/lib/providers/sync_settings_provider.dart b/app/lib/providers/sync_settings_provider.dart index 0880d3f..73a9ec5 100644 --- a/app/lib/providers/sync_settings_provider.dart +++ b/app/lib/providers/sync_settings_provider.dart @@ -2,122 +2,333 @@ import 'dart:convert'; import 'package:crypto/crypto.dart'; import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; import 'package:papyrus/auth/papyrus_api_config.dart'; import 'package:shared_preferences/shared_preferences.dart'; enum SyncServerType { official, custom } -enum MediaStorageBackend { local, selfHosted } +typedef DataSyncDiscoveryFetcher = Future Function(Uri serverUrl); + +class SyncSettingsException implements Exception { + const SyncSettingsException(this.message); + + final String message; + + @override + String toString() => message; +} + +class DataSyncDiscoverySettings { + const DataSyncDiscoverySettings({required this.dataSyncUri, required this.fileStorageQuotaBytes}); + + final Uri dataSyncUri; + final int? fileStorageQuotaBytes; + + factory DataSyncDiscoverySettings.fromJson(Map json) { + final dataSyncUrl = json['data_sync_url'] as String?; + if (dataSyncUrl == null || dataSyncUrl.trim().isEmpty) { + throw const SyncSettingsException('Server did not provide data sync settings'); + } + + final fileStorage = json['file_storage']; + final quotaBytes = fileStorage is Map ? fileStorage['quota_bytes'] as int? : null; + + return DataSyncDiscoverySettings(dataSyncUri: Uri.parse(dataSyncUrl), fileStorageQuotaBytes: quotaBytes); + } +} + +class CustomSyncServer { + const CustomSyncServer({ + required this.id, + required this.url, + required this.label, + required this.dataSyncUri, + required this.fileStorageQuotaBytes, + }); + + final String id; + final String url; + final String label; + final Uri dataSyncUri; + final int? fileStorageQuotaBytes; + + Map toJson() { + return { + 'id': id, + 'url': url, + 'label': label, + 'data_sync_url': dataSyncUri.toString(), + 'file_storage_quota_bytes': fileStorageQuotaBytes, + }; + } + + factory CustomSyncServer.fromJson(Map json) { + return CustomSyncServer( + id: json['id'] as String, + url: json['url'] as String, + label: json['label'] as String, + dataSyncUri: Uri.parse(json['data_sync_url'] as String), + fileStorageQuotaBytes: json['file_storage_quota_bytes'] as int?, + ); + } +} 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'; + static const officialServerId = 'official'; + static const officialFileStorageQuotaBytes = 1_073_741_824; + + static const _keyActiveServerId = 'sync_active_server_id'; + static const _keyCustomServers = 'sync_custom_servers'; + static const _legacyKeyServerType = 'sync_server_type'; + static const _legacyKeyCustomApiUrl = 'sync_custom_api_url'; + static const _legacyKeyCustomPowerSyncUrl = 'sync_custom_powersync_url'; final SharedPreferences _prefs; + final DataSyncDiscoveryFetcher _discoveryFetcher; final PapyrusApiConfig officialConfig; - SyncSettingsProvider(this._prefs, {required this.officialConfig}); + SyncSettingsProvider(this._prefs, {required this.officialConfig, DataSyncDiscoveryFetcher? discoveryFetcher}) + : _discoveryFetcher = discoveryFetcher ?? _fetchDataSyncSettings { + _migrateLegacyCustomServer(); + } - SyncServerType get serverType { - final value = _prefs.getString(_keyServerType); - return value == SyncServerType.custom.name ? SyncServerType.custom : SyncServerType.official; + String get activeServerId { + final id = _prefs.getString(_keyActiveServerId) ?? officialServerId; + if (id == officialServerId || customServers.any((server) => server.id == id)) { + return id; + } + return officialServerId; } - set serverType(SyncServerType value) { - _prefs.setString(_keyServerType, value.name); - if (value == SyncServerType.official && mediaStorageBackend != MediaStorageBackend.local) { - _prefs.setString(_keyMediaStorageBackend, MediaStorageBackend.local.name); + List get customServers { + final raw = _prefs.getString(_keyCustomServers); + if (raw == null || raw.isEmpty) return const []; + + final decoded = jsonDecode(raw) as List; + return decoded.map((item) => CustomSyncServer.fromJson(item as Map)).toList(growable: false); + } + + CustomSyncServer? get activeCustomServer { + final id = activeServerId; + if (id == officialServerId) return null; + for (final server in customServers) { + if (server.id == id) return server; } - notifyListeners(); + return null; } - String get customApiUrl => _prefs.getString(_keyCustomApiUrl) ?? ''; + String get activeServerLabel => activeCustomServer?.label ?? 'Official server'; - String get customPowerSyncUrl => _prefs.getString(_keyCustomPowerSyncUrl) ?? ''; + String get activeServerUrl => activeCustomServer?.url ?? officialConfig.serverBaseUri.toString(); - void setCustomServerUrls({required String apiUrl, required String powerSyncUrl}) { - final normalizedApiUrl = _normalizeUrl(apiUrl); - final normalizedPowerSyncUrl = _normalizeUrl(powerSyncUrl); + PapyrusApiConfig get activeApiConfig { + final customServer = activeCustomServer; + if (customServer == null) return officialConfig; - _prefs.setString(_keyCustomApiUrl, normalizedApiUrl); - _prefs.setString(_keyCustomPowerSyncUrl, normalizedPowerSyncUrl); - notifyListeners(); + return PapyrusApiConfig(serverBaseUri: Uri.parse(customServer.url), powerSyncServiceUri: customServer.dataSyncUri); } - String get activeServerLabel { - return serverType == SyncServerType.official ? 'Official server' : 'Custom server'; + String get activeProfileKey { + final customServer = activeCustomServer; + if (customServer == null) return officialServerId; + + final digest = sha256.convert(utf8.encode(customServer.url)).toString(); + return 'custom-${digest.substring(0, 16)}'; } - PapyrusApiConfig get activeApiConfig { - if (serverType == SyncServerType.official) { - return officialConfig; + int? get fileStorageQuotaBytes { + return activeCustomServer?.fileStorageQuotaBytes ?? officialFileStorageQuotaBytes; + } + + String get fileStorageLabel { + final quotaBytes = fileStorageQuotaBytes; + if (quotaBytes == null) return 'Available'; + return 'Up to ${_formatQuota(quotaBytes)} included'; + } + + bool get isOfficialServer => activeServerId == officialServerId; + + Future addCustomServer(String url) async { + final serverUri = _normalizeServerUri(url); + final normalizedUrl = serverUri.toString(); + final servers = customServers; + if (servers.any((server) => server.url == normalizedUrl)) { + throw const SyncSettingsException('This custom server already exists'); } - return PapyrusApiConfig(serverBaseUri: Uri.parse(customApiUrl), powerSyncServiceUri: Uri.parse(customPowerSyncUrl)); + final settings = await _discoveryFetcher(serverUri); + final server = _customServerFromDiscovery(serverUri, settings); + _saveCustomServers([...servers, server]); + _setActiveServerId(server.id); + notifyListeners(); + return server; } - String get activeProfileKey { - if (serverType == SyncServerType.official) { - return 'official'; + Future updateCustomServer(String id, String url) async { + final servers = customServers; + final index = servers.indexWhere((server) => server.id == id); + if (index == -1) throw const SyncSettingsException('Custom server was not found'); + + final serverUri = _normalizeServerUri(url); + final normalizedUrl = serverUri.toString(); + if (servers.any((server) => server.id != id && server.url == normalizedUrl)) { + throw const SyncSettingsException('This custom server already exists'); } - final input = '${activeApiConfig.serverBaseUri}|${activeApiConfig.powerSyncServiceUri}'; - final digest = sha256.convert(utf8.encode(input)).toString(); - return 'custom-${digest.substring(0, 16)}'; + final settings = await _discoveryFetcher(serverUri); + final updated = _customServerFromDiscovery(serverUri, settings); + final nextServers = [...servers]..[index] = updated; + _saveCustomServers(nextServers); + if (activeServerId == id) _setActiveServerId(updated.id); + notifyListeners(); + return updated; } - MediaStorageBackend get mediaStorageBackend { - final value = _prefs.getString(_keyMediaStorageBackend); - final backend = value == MediaStorageBackend.selfHosted.name - ? MediaStorageBackend.selfHosted - : MediaStorageBackend.local; + void selectServer(String id) { + if (id != officialServerId && !customServers.any((server) => server.id == id)) { + throw const SyncSettingsException('Custom server was not found'); + } + + _setActiveServerId(id); + notifyListeners(); + } - if (serverType == SyncServerType.official && backend != MediaStorageBackend.local) { - return MediaStorageBackend.local; + void removeCustomServer(String id) { + final nextServers = customServers.where((server) => server.id != id).toList(growable: false); + if (nextServers.length == customServers.length) { + throw const SyncSettingsException('Custom server was not found'); } - return backend; + _saveCustomServers(nextServers); + if (activeServerId == id) _setActiveServerId(officialServerId); + notifyListeners(); } - set mediaStorageBackend(MediaStorageBackend value) { - if (serverType == SyncServerType.official && value != MediaStorageBackend.local) { - _prefs.setString(_keyMediaStorageBackend, MediaStorageBackend.local.name); - notifyListeners(); + @Deprecated('Use activeServerId instead.') + SyncServerType get serverType => activeServerId == officialServerId ? SyncServerType.official : SyncServerType.custom; + + @Deprecated('Use selectServer instead.') + set serverType(SyncServerType value) { + if (value == SyncServerType.official) { + selectServer(officialServerId); return; } + if (customServers.isEmpty) { + throw const SyncSettingsException('Custom server was not found'); + } + selectServer(customServers.first.id); + } - _prefs.setString(_keyMediaStorageBackend, value.name); + @Deprecated('Use customServers instead.') + String get customApiUrl => activeCustomServer?.url ?? ''; + + @Deprecated('Use customServers instead.') + String get customPowerSyncUrl => activeCustomServer?.dataSyncUri.toString() ?? ''; + + @Deprecated('Use addCustomServer instead.') + void setCustomServerUrls({required String apiUrl, required String powerSyncUrl}) { + final serverUri = _normalizeServerUri(apiUrl); + final dataSyncUri = _normalizeServerUri(powerSyncUrl); + final server = CustomSyncServer( + id: _customServerId(serverUri.toString()), + url: serverUri.toString(), + label: _labelForServerUri(serverUri), + dataSyncUri: dataSyncUri, + fileStorageQuotaBytes: null, + ); + _saveCustomServers([server]); + _setActiveServerId(server.id); notifyListeners(); } - String get mediaStorageLabel { - switch (mediaStorageBackend) { - case MediaStorageBackend.local: - return 'Local device only'; - case MediaStorageBackend.selfHosted: - return 'Self-hosted server'; + void _migrateLegacyCustomServer() { + if (customServers.isNotEmpty) return; + + final legacyApiUrl = _prefs.getString(_legacyKeyCustomApiUrl); + final legacyDataSyncUrl = _prefs.getString(_legacyKeyCustomPowerSyncUrl); + if (legacyApiUrl == null || legacyApiUrl.isEmpty || legacyDataSyncUrl == null || legacyDataSyncUrl.isEmpty) { + return; } - } - String? get mediaStorageRestrictionMessage { - if (serverType == SyncServerType.official) { - return 'Official servers do not store book files or covers. Media stays on this device.'; + final serverUri = _normalizeServerUri(legacyApiUrl); + final dataSyncUri = _normalizeServerUri(legacyDataSyncUrl); + final server = CustomSyncServer( + id: _customServerId(serverUri.toString()), + url: serverUri.toString(), + label: _labelForServerUri(serverUri), + dataSyncUri: dataSyncUri, + fileStorageQuotaBytes: null, + ); + + _saveCustomServers([server]); + if (_prefs.getString(_legacyKeyServerType) == SyncServerType.custom.name) { + _setActiveServerId(server.id); } + } - return null; + CustomSyncServer _customServerFromDiscovery(Uri serverUri, DataSyncDiscoverySettings settings) { + final normalizedUrl = serverUri.toString(); + return CustomSyncServer( + id: _customServerId(normalizedUrl), + url: normalizedUrl, + label: _labelForServerUri(serverUri), + dataSyncUri: settings.dataSyncUri, + fileStorageQuotaBytes: settings.fileStorageQuotaBytes, + ); } - String _normalizeUrl(String value) { + void _saveCustomServers(List servers) { + _prefs.setString(_keyCustomServers, jsonEncode(servers.map((server) => server.toJson()).toList())); + } + + void _setActiveServerId(String id) { + _prefs.setString(_keyActiveServerId, id); + } + + static Future _fetchDataSyncSettings(Uri serverUrl) async { + final client = http.Client(); + try { + final config = PapyrusApiConfig(serverBaseUri: serverUrl); + final response = await client.get(config.endpoint('/sync/settings'), headers: {'Accept': 'application/json'}); + if (response.statusCode < 200 || response.statusCode >= 300) { + throw const SyncSettingsException('Server settings could not be loaded'); + } + return DataSyncDiscoverySettings.fromJson(jsonDecode(response.body) as Map); + } catch (error) { + if (error is SyncSettingsException) rethrow; + throw const SyncSettingsException('Server settings could not be loaded'); + } finally { + client.close(); + } + } + + Uri _normalizeServerUri(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'); + throw SyncSettingsException('Expected a valid server URL'); } - return uri.removeFragment().toString(); + final path = uri.path == '/' ? '' : uri.path.replaceFirst(RegExp(r'/+$'), ''); + return uri.replace(path: path, query: null, fragment: null); + } + + String _customServerId(String normalizedUrl) { + final digest = sha256.convert(utf8.encode(normalizedUrl)).toString(); + return 'custom-${digest.substring(0, 16)}'; + } + + String _labelForServerUri(Uri uri) { + final path = uri.path.isEmpty ? '' : uri.path; + return '${uri.authority}$path'; + } + + String _formatQuota(int bytes) { + final gib = bytes / (1024 * 1024 * 1024); + if (gib == gib.roundToDouble()) return '${gib.round()} GB'; + return '${gib.toStringAsFixed(1)} GB'; } } diff --git a/app/test/pages/profile_storage_sync_test.dart b/app/test/pages/profile_storage_sync_test.dart index 2545df5..dfe2d0c 100644 --- a/app/test/pages/profile_storage_sync_test.dart +++ b/app/test/pages/profile_storage_sync_test.dart @@ -116,17 +116,18 @@ void main() { required AuthProvider authProvider, required _FakePowerSyncService powerSyncService, Size screenSize = const Size(400, 900), + SyncSettingsProvider? syncSettingsProvider, }) async { final prefs = await SharedPreferences.getInstance(); final config = PapyrusApiConfig( serverBaseUri: Uri.parse('https://api.test'), - powerSyncServiceUri: Uri.parse('https://powersync.test'), + powerSyncServiceUri: Uri.parse('https://data-sync.test'), ); return MultiProvider( providers: [ - ChangeNotifierProvider( - create: (_) => SyncSettingsProvider(prefs, officialConfig: config), + ChangeNotifierProvider.value( + value: syncSettingsProvider ?? SyncSettingsProvider(prefs, officialConfig: config), ), Provider.value(value: powerSyncService), StreamProvider.value(value: powerSyncService.syncStates, initialData: powerSyncService.syncState), @@ -160,7 +161,7 @@ void main() { 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('https://data-sync.test'), findsNothing); expect(find.text('Current mode'), findsNothing); expect(find.text('Local database'), findsNothing); expect(find.text('Metadata sync'), findsNothing); @@ -199,35 +200,79 @@ void main() { expect(find.text('Media storage'), findsNothing); }); - testWidgets('authenticated storage sync UI shows official metadata sync and local-only media policy', (tester) async { + testWidgets('authenticated storage sync UI shows data sync and hides implementation details', (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.pumpWidget( + await buildPage(authProvider: auth, powerSyncService: service, screenSize: const Size(1200, 900)), + ); await tester.pumpAndSettle(); - await tester.scrollUntilVisible(find.text('Storage & sync'), 400); + await tester.tap(find.text('Storage & sync').first); await tester.pumpAndSettle(); - expect(find.text('Account synced'), findsWidgets); - expect(find.text('Server-scoped account cache'), findsOneWidget); + expect(find.text('Data sync'), 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('Up to 1 GB included'), findsOneWidget); expect(find.text('Connected'), findsWidgets); - expect(find.text('Reconnect sync'), findsOneWidget); + expect(find.text('Reconnect'), findsOneWidget); + expect(find.text('Manage servers'), findsOneWidget); expect(find.text('Clear account local cache'), findsOneWidget); + expect(find.text('Metadata sync'), findsNothing); + expect(find.text('PowerSync service'), findsNothing); + expect(find.textContaining('PowerSync'), findsNothing); + expect(find.text('Library storage'), findsNothing); + expect(find.text('Media storage'), findsNothing); + expect(find.text('Local database'), findsNothing); + expect(find.text('Server-scoped account cache'), findsNothing); - await tester.ensureVisible(find.text('Reconnect sync')); + await tester.ensureVisible(find.text('Reconnect')); await tester.pumpAndSettle(); - await tester.tap(find.text('Reconnect sync')); + await tester.tap(find.text('Reconnect')); await tester.pump(); expect(service.reconnectCalls, 1); }); + testWidgets('manage servers lists official and custom servers for switching', (tester) async { + final prefs = await SharedPreferences.getInstance(); + final syncSettings = SyncSettingsProvider( + prefs, + officialConfig: PapyrusApiConfig( + serverBaseUri: Uri.parse('https://api.test'), + powerSyncServiceUri: Uri.parse('https://data-sync.test'), + ), + discoveryFetcher: (serverUrl) async => DataSyncDiscoverySettings( + dataSyncUri: Uri.parse('https://sync.${serverUrl.host}'), + fileStorageQuotaBytes: 1_073_741_824, + ), + ); + await syncSettings.addCustomServer('https://reader.example'); + syncSettings.selectServer(SyncSettingsProvider.officialServerId); + final auth = await buildAuthProvider(signedIn: true); + final service = _FakePowerSyncService( + currentMode: LibraryDatabaseMode.authenticated, + currentSyncState: const SyncState(connected: true), + ); + + await tester.pumpWidget( + await buildPage(authProvider: auth, powerSyncService: service, syncSettingsProvider: syncSettings), + ); + await tester.pumpAndSettle(); + await tester.scrollUntilVisible(find.text('Storage & sync'), 400); + await tester.pumpAndSettle(); + await tester.tap(find.text('Manage servers')); + await tester.pumpAndSettle(); + + expect(find.text('Sync servers'), findsOneWidget); + expect(find.text('Official server'), findsWidgets); + expect(find.text('reader.example'), findsOneWidget); + expect(find.text('Add custom server'), findsOneWidget); + }); + testWidgets('storage sync UI shows pending writes and sync errors', (tester) async { final auth = await buildAuthProvider(signedIn: true); final service = _FakePowerSyncService( @@ -235,9 +280,11 @@ void main() { currentSyncState: const SyncState(connected: true, hasPendingWrites: true, uploadError: 'upload failed'), ); - await tester.pumpWidget(await buildPage(authProvider: auth, powerSyncService: service)); + await tester.pumpWidget( + await buildPage(authProvider: auth, powerSyncService: service, screenSize: const Size(1200, 900)), + ); await tester.pumpAndSettle(); - await tester.scrollUntilVisible(find.text('Storage & sync'), 400); + await tester.tap(find.text('Storage & sync').first); await tester.pumpAndSettle(); expect(find.text('Error'), findsWidgets); diff --git a/app/test/providers/sync_settings_provider_test.dart b/app/test/providers/sync_settings_provider_test.dart index 1c0c8e6..93adf6c 100644 --- a/app/test/providers/sync_settings_provider_test.dart +++ b/app/test/providers/sync_settings_provider_test.dart @@ -11,64 +11,125 @@ void main() { PapyrusApiConfig officialConfig() { return PapyrusApiConfig( serverBaseUri: Uri.parse('https://api.papyrus.test'), - powerSyncServiceUri: Uri.parse('https://powersync.papyrus.test'), + powerSyncServiceUri: Uri.parse('https://data-sync.papyrus.test'), ); } - test('defaults to the official metadata sync profile and local-only media storage', () async { + SyncSettingsProvider providerWithFetcher( + SharedPreferences prefs, { + Map? discovered, + }) { + return SyncSettingsProvider( + prefs, + officialConfig: officialConfig(), + discoveryFetcher: (serverUrl) async { + final settings = discovered?[serverUrl.toString()]; + if (settings == null) { + throw SyncSettingsException('Server settings could not be loaded'); + } + return settings; + }, + ); + } + + test('defaults to the official data sync profile with 1 GB file storage', () async { final prefs = await SharedPreferences.getInstance(); final provider = SyncSettingsProvider(prefs, officialConfig: officialConfig()); - expect(provider.serverType, SyncServerType.official); + expect(provider.activeServerId, SyncSettingsProvider.officialServerId); 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); + expect(provider.activeApiConfig.powerSyncServiceUri, Uri.parse('https://data-sync.papyrus.test')); + expect(provider.fileStorageLabel, 'Up to 1 GB included'); + expect(provider.customServers, isEmpty); }); - test('persists a custom metadata sync profile with a stable profile key', () async { + test('adds multiple custom servers from one public URL and switches the active server', () async { final prefs = await SharedPreferences.getInstance(); - final provider = SyncSettingsProvider(prefs, officialConfig: officialConfig()); + final provider = providerWithFetcher( + prefs, + discovered: { + 'http://localhost:8080': DataSyncDiscoverySettings( + dataSyncUri: Uri.parse('http://localhost:8081'), + fileStorageQuotaBytes: 1_073_741_824, + ), + 'https://reader.example': DataSyncDiscoverySettings( + dataSyncUri: Uri.parse('https://sync.reader.example'), + fileStorageQuotaBytes: 2_147_483_648, + ), + }, + ); - provider.setCustomServerUrls(apiUrl: 'http://localhost:8080', powerSyncUrl: 'http://localhost:8081'); - provider.serverType = SyncServerType.custom; + final local = await provider.addCustomServer('localhost:8080'); + final remote = await provider.addCustomServer('https://reader.example'); + provider.selectServer(local.id); 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.customServers.map((server) => server.url), ['http://localhost:8080', 'https://reader.example']); + expect(restored.activeServerId, local.id); + expect(restored.activeServerLabel, 'localhost:8080'); 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-')); + expect(remote.id, isNot(local.id)); }); - test('does not allow official servers to be selected for media file storage', () async { + test('rejects duplicate custom server URLs after normalization', () async { final prefs = await SharedPreferences.getInstance(); - final provider = SyncSettingsProvider(prefs, officialConfig: officialConfig()); + final provider = providerWithFetcher( + prefs, + discovered: { + 'http://localhost:8080': DataSyncDiscoverySettings( + dataSyncUri: Uri.parse('http://localhost:8081'), + fileStorageQuotaBytes: null, + ), + }, + ); - provider.mediaStorageBackend = MediaStorageBackend.selfHosted; + await provider.addCustomServer('localhost:8080'); - expect(provider.serverType, SyncServerType.official); - expect(provider.mediaStorageBackend, MediaStorageBackend.local); - expect(provider.mediaStorageRestrictionMessage, contains('Official servers do not store book files or covers')); + await expectLater( + provider.addCustomServer('http://localhost:8080/'), + throwsA(isA().having((error) => error.message, 'message', contains('already exists'))), + ); }); - test('allows self-hosted media storage only when a custom sync server is active', () async { + test('removing the active custom server switches back to official', () async { final prefs = await SharedPreferences.getInstance(); - final provider = SyncSettingsProvider(prefs, officialConfig: officialConfig()); + final provider = providerWithFetcher( + prefs, + discovered: { + 'http://localhost:8080': DataSyncDiscoverySettings( + dataSyncUri: Uri.parse('http://localhost:8081'), + fileStorageQuotaBytes: null, + ), + }, + ); - provider.setCustomServerUrls(apiUrl: 'http://localhost:8080', powerSyncUrl: 'http://localhost:8081'); - provider.serverType = SyncServerType.custom; - provider.mediaStorageBackend = MediaStorageBackend.selfHosted; + final custom = await provider.addCustomServer('localhost:8080'); - expect(provider.mediaStorageBackend, MediaStorageBackend.selfHosted); + provider.removeCustomServer(custom.id); + + expect(provider.activeServerId, SyncSettingsProvider.officialServerId); + expect(provider.customServers, isEmpty); + }); - provider.serverType = SyncServerType.official; + test('migrates legacy single custom API and sync URLs', () async { + SharedPreferences.setMockInitialValues({ + 'sync_server_type': 'custom', + 'sync_custom_api_url': 'http://legacy-api.test', + 'sync_custom_powersync_url': 'http://legacy-sync.test', + }); + final prefs = await SharedPreferences.getInstance(); + + final provider = SyncSettingsProvider(prefs, officialConfig: officialConfig()); - expect(provider.mediaStorageBackend, MediaStorageBackend.local); + expect(provider.customServers, hasLength(1)); + expect(provider.activeServerId, provider.customServers.single.id); + expect(provider.activeServerLabel, 'legacy-api.test'); + expect(provider.activeApiConfig.serverBaseUri, Uri.parse('http://legacy-api.test')); + expect(provider.activeApiConfig.powerSyncServiceUri, Uri.parse('http://legacy-sync.test')); }); } From a6491fb08dd0ef20f25e6679a8550917cb57d599 Mon Sep 17 00:00:00 2001 From: Eoic Date: Sat, 27 Jun 2026 17:58:25 +0300 Subject: [PATCH 2/2] Refine data sync storage labels --- app/lib/pages/profile_page.dart | 24 +++++++----- .../powersync/storage_sync_controller.dart | 11 +++--- app/lib/providers/sync_settings_provider.dart | 25 ++++++++---- app/test/pages/profile_storage_sync_test.dart | 38 +++++++++++++++++-- .../sync_settings_provider_test.dart | 2 +- 5 files changed, 72 insertions(+), 28 deletions(-) diff --git a/app/lib/pages/profile_page.dart b/app/lib/pages/profile_page.dart index 79649e8..df0a6a2 100644 --- a/app/lib/pages/profile_page.dart +++ b/app/lib/pages/profile_page.dart @@ -3,6 +3,7 @@ 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/powersync/powersync_service.dart'; import 'package:papyrus/powersync/storage_sync_controller.dart'; import 'package:papyrus/providers/auth_provider.dart'; @@ -237,14 +238,13 @@ class _ProfilePageState extends State { onTap: () => _showManageSyncServersSheet(context), ), SettingsRow(label: 'Current status', value: controller.statusLabel), - SettingsRow(label: 'Pending changes', value: controller.pendingWritesLabel), - SettingsRow(label: 'File storage', value: controller.fileStorageLabel, showChevron: false), + SettingsRow(label: 'File storage', value: controller.fileStorageLabel), 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 account local cache', onTap: () => _confirmClearAuthenticatedCache(context)), + SettingsRow(label: 'Clear local copy', onTap: () => _confirmClearAuthenticatedCache(context)), ], ); } @@ -928,7 +928,6 @@ class _ProfilePageState extends State { children: [ _buildInfoRow(context, label: 'Active server', value: controller.dataSyncLabel), _buildInfoRow(context, label: 'Status', value: controller.statusLabel), - _buildInfoRow(context, label: 'Pending changes', value: controller.pendingWritesLabel), _buildInfoRow(context, label: 'File storage', value: controller.fileStorageLabel), const SizedBox(height: Spacing.sm), Padding( @@ -956,7 +955,7 @@ class _ProfilePageState extends State { OutlinedButton.icon( onPressed: () => _confirmClearAuthenticatedCache(context), icon: const Icon(Icons.cleaning_services_outlined, size: IconSizes.small), - label: const Text('Clear account local cache'), + label: const Text('Clear local copy'), ), ], ), @@ -1017,9 +1016,14 @@ class _ProfilePageState extends State { powerSyncService: context.read(), syncSettings: context.watch(), syncState: context.watch(), + fileStorageUsedBytes: _fileStorageUsedBytes(context.watch()), ); } + int _fileStorageUsedBytes(DataStore dataStore) { + return dataStore.books.fold(0, (total, book) => total + (book.fileSize ?? 0)); + } + Widget _buildInfoRow(BuildContext context, {required String label, required String value}) { final colorScheme = Theme.of(context).colorScheme; final textTheme = Theme.of(context).textTheme; @@ -1405,19 +1409,19 @@ class _ProfilePageState extends State { Future _confirmClearAuthenticatedCache(BuildContext context) async { final confirmed = await _confirmStorageAction( context, - title: 'Clear account local cache', + title: 'Clear local copy', 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', + '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); try { await context.read().clearAuthenticatedCache(); - messenger.showSnackBar(const SnackBar(content: Text('Account local cache cleared.'))); + messenger.showSnackBar(const SnackBar(content: Text('Local copy cleared.'))); } catch (error) { - messenger.showSnackBar(SnackBar(content: Text('Could not clear account cache: $error'))); + messenger.showSnackBar(SnackBar(content: Text('Could not clear local copy: $error'))); } } diff --git a/app/lib/powersync/storage_sync_controller.dart b/app/lib/powersync/storage_sync_controller.dart index a1904d1..aa35b62 100644 --- a/app/lib/powersync/storage_sync_controller.dart +++ b/app/lib/powersync/storage_sync_controller.dart @@ -9,12 +9,14 @@ class StorageSyncController { required this.powerSyncService, required this.syncSettings, required this.syncState, + required this.fileStorageUsedBytes, }); final AuthProvider authProvider; final PapyrusPowerSyncService powerSyncService; final SyncSettingsProvider syncSettings; final SyncState syncState; + final int fileStorageUsedBytes; LibraryDatabaseMode? get databaseMode => powerSyncService.mode; @@ -52,7 +54,7 @@ class StorageSyncController { bool get shouldShowServerSettings => !isGuest; - String get fileStorageLabel => syncSettings.fileStorageLabel; + String get fileStorageLabel => syncSettings.fileStorageLabel(usedBytes: fileStorageUsedBytes); String get statusLabel { if (isGuest) return 'Guest local'; @@ -61,7 +63,7 @@ class StorageSyncController { if (syncState.connecting) return 'Connecting'; if (syncState.uploading || syncState.downloading) return 'Syncing'; if (syncState.connected) { - return syncState.hasPendingWrites ? 'Pending upload' : 'Connected'; + return syncState.hasPendingWrites ? 'Waiting to sync' : 'Connected'; } return 'Offline'; } @@ -69,15 +71,12 @@ class StorageSyncController { 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'; + if (syncState.hasPendingWrites) return 'Changes will sync automatically'; 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; diff --git a/app/lib/providers/sync_settings_provider.dart b/app/lib/providers/sync_settings_provider.dart index 73a9ec5..c837d26 100644 --- a/app/lib/providers/sync_settings_provider.dart +++ b/app/lib/providers/sync_settings_provider.dart @@ -141,10 +141,12 @@ class SyncSettingsProvider extends ChangeNotifier { return activeCustomServer?.fileStorageQuotaBytes ?? officialFileStorageQuotaBytes; } - String get fileStorageLabel { + String fileStorageLabel({required int usedBytes}) { final quotaBytes = fileStorageQuotaBytes; - if (quotaBytes == null) return 'Available'; - return 'Up to ${_formatQuota(quotaBytes)} included'; + if (quotaBytes == null) return '${_formatBytes(usedBytes)} used'; + + final availableBytes = quotaBytes > usedBytes ? quotaBytes - usedBytes : 0; + return '${_formatBytes(usedBytes)} used, ${_formatBytes(availableBytes)} available of ${_formatBytes(quotaBytes)}'; } bool get isOfficialServer => activeServerId == officialServerId; @@ -326,9 +328,18 @@ class SyncSettingsProvider extends ChangeNotifier { return '${uri.authority}$path'; } - String _formatQuota(int bytes) { - final gib = bytes / (1024 * 1024 * 1024); - if (gib == gib.roundToDouble()) return '${gib.round()} GB'; - return '${gib.toStringAsFixed(1)} GB'; + String _formatBytes(int bytes) { + const mib = 1024 * 1024; + const gib = 1024 * mib; + + if (bytes >= gib) return '${_formatByteValue(bytes / gib)} GB'; + if (bytes == 0) return '0 MB'; + return '${_formatByteValue(bytes / mib)} MB'; + } + + String _formatByteValue(double value) { + if (value == value.roundToDouble()) return value.round().toString(); + if (value >= 10) return value.toStringAsFixed(1); + return value.toStringAsFixed(2); } } diff --git a/app/test/pages/profile_storage_sync_test.dart b/app/test/pages/profile_storage_sync_test.dart index dfe2d0c..251334e 100644 --- a/app/test/pages/profile_storage_sync_test.dart +++ b/app/test/pages/profile_storage_sync_test.dart @@ -5,6 +5,9 @@ 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/data/repositories/book_repository.dart'; +import 'package:papyrus/models/book.dart'; import 'package:papyrus/pages/profile_page.dart'; import 'package:papyrus/powersync/powersync_service.dart'; import 'package:papyrus/powersync/sync_state.dart'; @@ -117,6 +120,7 @@ void main() { required _FakePowerSyncService powerSyncService, Size screenSize = const Size(400, 900), SyncSettingsProvider? syncSettingsProvider, + DataStore? dataStore, }) async { final prefs = await SharedPreferences.getInstance(); final config = PapyrusApiConfig( @@ -126,6 +130,7 @@ void main() { return MultiProvider( providers: [ + ChangeNotifierProvider.value(value: dataStore ?? DataStore()), ChangeNotifierProvider.value( value: syncSettingsProvider ?? SyncSettingsProvider(prefs, officialConfig: config), ), @@ -171,6 +176,8 @@ void main() { expect(find.text('Sync interval'), findsNothing); expect(find.text('Conflict resolution'), findsNothing); expect(find.text('Add storage backend'), findsNothing); + expect(find.text('Pending changes'), findsNothing); + expect(find.text('No pending local writes'), findsNothing); }); testWidgets('offline desktop storage sync is local-first and hides sync internals', (tester) async { @@ -198,17 +205,28 @@ void main() { expect(find.text('Local database'), findsNothing); expect(find.text('Metadata sync'), findsNothing); expect(find.text('Media storage'), findsNothing); + expect(find.text('Pending changes'), findsNothing); + expect(find.text('No pending local writes'), findsNothing); }); testWidgets('authenticated storage sync UI shows data sync and hides implementation details', (tester) async { final auth = await buildAuthProvider(signedIn: true); + final dataStore = dataStoreWithBooks([ + testBook(id: 'book-1', title: 'Small book', fileSize: 100 * 1024 * 1024), + testBook(id: 'book-2', title: 'Large book', fileSize: 250 * 1024 * 1024), + ]); 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, screenSize: const Size(1200, 900)), + await buildPage( + authProvider: auth, + powerSyncService: service, + screenSize: const Size(1200, 900), + dataStore: dataStore, + ), ); await tester.pumpAndSettle(); await tester.tap(find.text('Storage & sync').first); @@ -216,11 +234,13 @@ void main() { expect(find.text('Data sync'), findsOneWidget); expect(find.text('Official server'), findsWidgets); - expect(find.text('Up to 1 GB included'), findsOneWidget); + expect(find.text('350 MB used, 674 MB available of 1 GB'), findsOneWidget); expect(find.text('Connected'), findsWidgets); expect(find.text('Reconnect'), findsOneWidget); expect(find.text('Manage servers'), findsOneWidget); - expect(find.text('Clear account local cache'), findsOneWidget); + expect(find.text('Clear local copy'), findsOneWidget); + expect(find.text('Clear account local cache'), findsNothing); + expect(find.text('Pending changes'), findsNothing); expect(find.text('Metadata sync'), findsNothing); expect(find.text('PowerSync service'), findsNothing); expect(find.textContaining('PowerSync'), findsNothing); @@ -289,10 +309,20 @@ void main() { expect(find.text('Error'), findsWidgets); expect(find.text('Sync error: upload failed'), findsOneWidget); - expect(find.text('Local changes pending upload'), findsOneWidget); + expect(find.text('Pending changes'), findsNothing); + expect(find.text('Local changes pending upload'), findsNothing); }); } +DataStore dataStoreWithBooks(List books) { + final repository = InMemoryBookRepository()..replaceAll(books); + return DataStore(bookRepository: repository); +} + +Book testBook({required String id, required String title, int? fileSize}) { + return Book(id: id, title: title, author: 'Author', fileSize: fileSize, addedAt: DateTime.utc(2026, 6, 27)); +} + AuthTokens _tokens() { return AuthTokens( accessToken: 'access-token', diff --git a/app/test/providers/sync_settings_provider_test.dart b/app/test/providers/sync_settings_provider_test.dart index 93adf6c..815ea13 100644 --- a/app/test/providers/sync_settings_provider_test.dart +++ b/app/test/providers/sync_settings_provider_test.dart @@ -41,7 +41,7 @@ void main() { expect(provider.activeProfileKey, 'official'); expect(provider.activeApiConfig.serverBaseUri, Uri.parse('https://api.papyrus.test')); expect(provider.activeApiConfig.powerSyncServiceUri, Uri.parse('https://data-sync.papyrus.test')); - expect(provider.fileStorageLabel, 'Up to 1 GB included'); + expect(provider.fileStorageLabel(usedBytes: 0), '0 MB used, 1 GB available of 1 GB'); expect(provider.customServers, isEmpty); });