diff --git a/app/lib/acquisition/acquisition_api_client.dart b/app/lib/acquisition/acquisition_api_client.dart new file mode 100644 index 0000000..52ad23f --- /dev/null +++ b/app/lib/acquisition/acquisition_api_client.dart @@ -0,0 +1,178 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:papyrus/acquisition/acquisition_models.dart'; +import 'package:papyrus/auth/auth_api_client.dart'; +import 'package:papyrus/auth/papyrus_api_config.dart'; + +class AcquisitionApiClient { + final PapyrusApiConfig config; + final http.Client _httpClient; + final bool _ownsHttpClient; + + AcquisitionApiClient({required this.config, http.Client? httpClient}) + : _httpClient = httpClient ?? http.Client(), + _ownsHttpClient = httpClient == null; + + void close() { + if (_ownsHttpClient) { + _httpClient.close(); + } + } + + Future capabilities(String accessToken) async { + final response = await _httpClient.get( + config.endpoint('/acquisition/capabilities'), + headers: _headers(accessToken), + ); + return AcquisitionCapabilities.fromJson(_decodeObject(response)); + } + + Future> listEndpoints(String accessToken) async { + final response = await _httpClient.get( + config.endpoint('/acquisition/endpoints'), + headers: _headers(accessToken), + ); + return _decodeList(response).map(AcquisitionEndpoint.fromJson).toList(); + } + + Future createEndpoint({ + required String accessToken, + required String name, + required AcquisitionEndpointKind kind, + required Uri baseUrl, + String? apiKey, + String? username, + String? password, + }) async { + final response = await _httpClient.post( + config.endpoint('/acquisition/endpoints'), + headers: _headers(accessToken), + body: jsonEncode({ + 'name': name, + 'kind': kind.apiValue, + 'base_url': baseUrl.toString(), + if (apiKey != null) 'api_key': apiKey, + if (username != null) 'username': username, + if (password != null) 'password': password, + }), + ); + return AcquisitionEndpoint.fromJson(_decodeObject(response)); + } + + Future updateEndpoint({ + required String accessToken, + required String endpointId, + String? name, + Uri? baseUrl, + String? apiKey, + String? username, + String? password, + bool? enabled, + }) async { + final response = await _httpClient.patch( + config.endpoint('/acquisition/endpoints/$endpointId'), + headers: _headers(accessToken), + body: jsonEncode({ + if (name != null) 'name': name, + if (baseUrl != null) 'base_url': baseUrl.toString(), + if (apiKey != null) 'api_key': apiKey, + if (username != null) 'username': username, + if (password != null) 'password': password, + if (enabled != null) 'enabled': enabled, + }), + ); + return AcquisitionEndpoint.fromJson(_decodeObject(response)); + } + + Future deleteEndpoint({ + required String accessToken, + required String endpointId, + }) async { + final response = await _httpClient.delete( + config.endpoint('/acquisition/endpoints/$endpointId'), + headers: _headers(accessToken), + ); + if (response.statusCode >= 200 && response.statusCode < 300) return; + _decodeObject(response); + } + + Future> search({ + required String accessToken, + required String query, + List? endpointIds, + }) async { + final response = await _httpClient.post( + config.endpoint('/acquisition/search'), + headers: _headers(accessToken), + body: jsonEncode({ + 'query': query, + if (endpointIds != null) 'endpoint_ids': endpointIds, + }), + ); + return _decodeList(response).map(TorrentRelease.fromJson).toList(); + } + + Future submitRelease({ + required String accessToken, + required String endpointId, + required TorrentRelease release, + String? category, + String? savePath, + }) async { + final response = await _httpClient.post( + config.endpoint('/acquisition/submissions'), + headers: _headers(accessToken), + body: jsonEncode({ + 'endpoint_id': endpointId, + 'title': release.title, + 'download_url': release.downloadUrl, + if (category != null) 'category': category, + if (savePath != null) 'save_path': savePath, + }), + ); + _decodeObject(response); + } + + Future runArrCommand({ + required String accessToken, + required String endpointId, + required String command, + required List ids, + }) async { + final response = await _httpClient.post( + config.endpoint('/acquisition/arr/$endpointId/commands'), + headers: _headers(accessToken), + body: jsonEncode({'command': command, 'ids': ids}), + ); + _decodeObject(response); + } + + Map _headers(String accessToken) => { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $accessToken', + }; + + Map _decodeObject(http.Response response) { + final decoded = response.body.isEmpty + ? {} + : jsonDecode(response.body) as Map; + if (response.statusCode >= 200 && response.statusCode < 300) return decoded; + final error = decoded['error']; + throw AuthApiException( + statusCode: response.statusCode, + message: error is Map + ? error['message'] as String? ?? 'Acquisition request failed' + : 'Acquisition request failed', + ); + } + + List> _decodeList(http.Response response) { + if (response.statusCode < 200 || response.statusCode >= 300) { + _decodeObject(response); + } + return (jsonDecode(response.body) as List) + .cast>(); + } +} diff --git a/app/lib/acquisition/acquisition_models.dart b/app/lib/acquisition/acquisition_models.dart new file mode 100644 index 0000000..801530d --- /dev/null +++ b/app/lib/acquisition/acquisition_models.dart @@ -0,0 +1,140 @@ +enum AcquisitionEndpointKind { + qbittorrent, + transmission, + deluge, + prowlarr, + torznab, + readarr, + sonarr, + radarr, + lidarr, + whisparr; + + String get apiValue => name; + + String get label => switch (this) { + AcquisitionEndpointKind.qbittorrent => 'qBittorrent', + AcquisitionEndpointKind.transmission => 'Transmission', + AcquisitionEndpointKind.deluge => 'Deluge', + AcquisitionEndpointKind.prowlarr => 'Prowlarr', + AcquisitionEndpointKind.torznab => 'Torznab', + AcquisitionEndpointKind.readarr => 'Readarr', + AcquisitionEndpointKind.sonarr => 'Sonarr', + AcquisitionEndpointKind.radarr => 'Radarr', + AcquisitionEndpointKind.lidarr => 'Lidarr', + AcquisitionEndpointKind.whisparr => 'Whisparr', + }; + + bool get isDownloadClient => switch (this) { + AcquisitionEndpointKind.qbittorrent || + AcquisitionEndpointKind.transmission || + AcquisitionEndpointKind.deluge => true, + _ => false, + }; + + bool get isIndexer => switch (this) { + AcquisitionEndpointKind.prowlarr || AcquisitionEndpointKind.torznab => true, + _ => false, + }; + + bool get isArr => switch (this) { + AcquisitionEndpointKind.readarr || + AcquisitionEndpointKind.sonarr || + AcquisitionEndpointKind.radarr || + AcquisitionEndpointKind.lidarr || + AcquisitionEndpointKind.whisparr => true, + _ => false, + }; +} + +class AcquisitionCapabilities { + final List endpointKinds; + final List indexerKinds; + final List downloadClientKinds; + final List arrKinds; + final Map> arrCommands; + + const AcquisitionCapabilities({ + required this.endpointKinds, + required this.indexerKinds, + required this.downloadClientKinds, + required this.arrKinds, + required this.arrCommands, + }); + + factory AcquisitionCapabilities.fromJson(Map json) { + return AcquisitionCapabilities( + endpointKinds: _kinds(json['endpoint_kinds']), + indexerKinds: _kinds(json['indexer_kinds']), + downloadClientKinds: _kinds(json['download_client_kinds']), + arrKinds: _kinds(json['arr_kinds']), + arrCommands: ((json['arr_commands'] as Map?) ?? {}).map( + (key, value) => MapEntry( + AcquisitionEndpointKind.values.byName(key), + (value as List).cast(), + ), + ), + ); + } + + static List _kinds(Object? value) { + return ((value as List?) ?? []) + .cast() + .map(AcquisitionEndpointKind.values.byName) + .toList(); + } +} + +class AcquisitionEndpoint { + final String id; + final String name; + final AcquisitionEndpointKind kind; + final Uri baseUrl; + final bool enabled; + + const AcquisitionEndpoint({ + required this.id, + required this.name, + required this.kind, + required this.baseUrl, + required this.enabled, + }); + + factory AcquisitionEndpoint.fromJson(Map json) => + AcquisitionEndpoint( + id: json['endpoint_id'] as String, + name: json['name'] as String, + kind: AcquisitionEndpointKind.values.byName(json['kind'] as String), + baseUrl: Uri.parse(json['base_url'] as String), + enabled: json['enabled'] as bool, + ); +} + +class TorrentRelease { + final String title; + final String downloadUrl; + final String protocol; + final String indexer; + final int? seeders; + final int? sizeBytes; + + const TorrentRelease({ + required this.title, + required this.downloadUrl, + required this.protocol, + required this.indexer, + this.seeders, + this.sizeBytes, + }); + + bool get isMagnet => downloadUrl.startsWith('magnet:'); + + factory TorrentRelease.fromJson(Map json) => TorrentRelease( + title: json['title'] as String, + downloadUrl: json['download_url'] as String, + protocol: json['protocol'] as String, + indexer: json['indexer'] as String, + seeders: json['seeders'] as int?, + sizeBytes: json['size_bytes'] as int?, + ); +} diff --git a/app/lib/auth/auth_repository.dart b/app/lib/auth/auth_repository.dart index ce990a9..8381b49 100644 --- a/app/lib/auth/auth_repository.dart +++ b/app/lib/auth/auth_repository.dart @@ -19,14 +19,13 @@ class AuthRepository { AuthRepository({required this.apiClient, required this.tokenStore}); - String? get accessToken => tokenStore.accessToken; - bool get _usesDesktopLoopbackOAuth { if (kIsWeb) { return false; } - return defaultTargetPlatform == TargetPlatform.linux || defaultTargetPlatform == TargetPlatform.windows; + return defaultTargetPlatform == TargetPlatform.linux || + defaultTargetPlatform == TargetPlatform.windows; } Future bootstrap() async { @@ -85,7 +84,10 @@ class AuthRepository { final refreshToken = await tokenStore.readRefreshToken(); if (refreshToken == null) { - throw const AuthApiException(statusCode: 401, message: 'No stored refresh token'); + throw const AuthApiException( + statusCode: 401, + message: 'No stored refresh token', + ); } final tokens = await apiClient.refresh(refreshToken); @@ -99,7 +101,10 @@ class AuthRepository { } } - Future signInWithGoogle({required String clientType, String? deviceLabel}) async { + Future signInWithGoogle({ + required String clientType, + String? deviceLabel, + }) async { await apiClient.ensureServerReachable(); final redirectUri = kIsWeb @@ -117,15 +122,25 @@ class AuthRepository { final callbackUrl = await FlutterWebAuth2.authenticate( url: startUri.toString(), - callbackUrlScheme: _usesDesktopLoopbackOAuth ? desktopOAuthRedirectUri : Uri.parse(nativeOAuthRedirectUri).scheme, + callbackUrlScheme: _usesDesktopLoopbackOAuth + ? desktopOAuthRedirectUri + : Uri.parse(nativeOAuthRedirectUri).scheme, options: const FlutterWebAuth2Options(useWebview: false), ); final callbackUri = Uri.parse(callbackUrl); - return completeGoogleSignIn(callbackUri, clientType: clientType, deviceLabel: deviceLabel); + return completeGoogleSignIn( + callbackUri, + clientType: clientType, + deviceLabel: deviceLabel, + ); } - Future completeGoogleSignIn(Uri callbackUri, {required String clientType, String? deviceLabel}) async { + Future completeGoogleSignIn( + Uri callbackUri, { + required String clientType, + String? deviceLabel, + }) async { final error = callbackUri.queryParameters['error']; if (error != null && error.isNotEmpty) { @@ -135,10 +150,17 @@ class AuthRepository { final code = callbackUri.queryParameters['code']; if (code == null || code.isEmpty) { - throw const AuthApiException(statusCode: 400, message: 'OAuth callback did not include a code'); + throw const AuthApiException( + statusCode: 400, + message: 'OAuth callback did not include a code', + ); } - final tokens = await apiClient.exchangeCode(code: code, clientType: clientType, deviceLabel: deviceLabel); + final tokens = await apiClient.exchangeCode( + code: code, + clientType: clientType, + deviceLabel: deviceLabel, + ); await _save(tokens); return tokens; @@ -161,17 +183,27 @@ class AuthRepository { return apiClient.currentUser(accessToken); } - Future updateCurrentUser({String? displayName, String? avatarUrl}) async { + Future updateCurrentUser({ + String? displayName, + String? avatarUrl, + }) async { final accessToken = await _requireAccessToken(); - return apiClient.updateCurrentUser(accessToken: accessToken, displayName: displayName, avatarUrl: avatarUrl); + return apiClient.updateCurrentUser( + accessToken: accessToken, + displayName: displayName, + avatarUrl: avatarUrl, + ); } Future forgotPassword(String email) { return apiClient.forgotPassword(email); } - Future resetPassword({required String token, required String password}) { + Future resetPassword({ + required String token, + required String password, + }) { return apiClient.resetPassword(token: token, password: password); } @@ -223,6 +255,12 @@ class AuthRepository { return tokenStore.clear(); } + Future withFreshAccessToken( + Future Function(String accessToken) action, + ) { + return _withFreshAccessToken(action); + } + Future _requireAccessToken() async { final currentAccessToken = tokenStore.accessToken; @@ -235,10 +273,15 @@ class AuthRepository { } Future _save(AuthTokens tokens) { - return tokenStore.saveTokens(accessToken: tokens.accessToken, refreshToken: tokens.refreshToken); + return tokenStore.saveTokens( + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + ); } - Future _withFreshAccessToken(Future Function(String accessToken) action) async { + Future _withFreshAccessToken( + Future Function(String accessToken) action, + ) async { try { return await action(await _requireAccessToken()); } on AuthApiException catch (error) { diff --git a/app/lib/config/app_router.dart b/app/lib/config/app_router.dart index 3de412f..b07b7c9 100644 --- a/app/lib/config/app_router.dart +++ b/app/lib/config/app_router.dart @@ -21,21 +21,24 @@ import 'package:papyrus/pages/shelf_contents_page.dart'; import 'package:papyrus/pages/shelves_page.dart'; import 'package:papyrus/pages/statistics_page.dart'; import 'package:papyrus/pages/annotations_page.dart'; +import 'package:papyrus/pages/acquisition_page.dart'; import 'package:papyrus/pages/notes_page.dart'; import 'package:papyrus/pages/welcome_page.dart'; import 'package:papyrus/widgets/shell/adaptive_app_shell.dart'; +import 'package:papyrus/providers/preferences_provider.dart'; class AppRouter { final AuthProvider authProvider; + final PreferencesProvider preferencesProvider; final rootNavigatorKey = GlobalKey(); final shellNavigatorKey = GlobalKey(); - AppRouter({required this.authProvider}); + AppRouter({required this.authProvider, required this.preferencesProvider}); late final GoRouter router = GoRouter( debugLogDiagnostics: true, navigatorKey: rootNavigatorKey, - refreshListenable: authProvider, + refreshListenable: Listenable.merge([authProvider, preferencesProvider]), routes: [ GoRoute( path: '/', @@ -46,24 +49,34 @@ class AppRouter { GoRoute( name: 'LOGIN', path: 'login', - pageBuilder: (context, state) => NoTransitionPage(key: state.pageKey, child: const LoginPage()), + pageBuilder: (context, state) => + NoTransitionPage(key: state.pageKey, child: const LoginPage()), ), GoRoute( name: 'REGISTER', path: 'register', - pageBuilder: (context, state) => NoTransitionPage(key: state.pageKey, child: const RegisterPage()), + pageBuilder: (context, state) => NoTransitionPage( + key: state.pageKey, + child: const RegisterPage(), + ), ), GoRoute( name: 'FORGOT_PASSWORD', path: 'forgot-password', - pageBuilder: (context, state) => NoTransitionPage(key: state.pageKey, child: const ForgotPasswordPage()), + pageBuilder: (context, state) => NoTransitionPage( + key: state.pageKey, + child: const ForgotPasswordPage(), + ), ), GoRoute( name: 'RESET_PASSWORD', path: 'reset-password', pageBuilder: (context, state) => NoTransitionPage( key: state.pageKey, - child: ForgotPasswordPage(resetToken: state.uri.queryParameters['token'], isResetLink: true), + child: ForgotPasswordPage( + resetToken: state.uri.queryParameters['token'], + isResetLink: true, + ), ), ), GoRoute( @@ -87,26 +100,40 @@ class AppRouter { GoRoute( name: 'DASHBOARD', path: '/dashboard', - pageBuilder: (context, state) => NoTransitionPage(key: state.pageKey, child: const DashboardPage()), + pageBuilder: (context, state) => NoTransitionPage( + key: state.pageKey, + child: const DashboardPage(), + ), ), // Library and sub-routes GoRoute( name: 'LIBRARY', path: '/library', redirect: (context, state) { - return state.uri.toString() == '/library' ? '/library/books' : null; + return state.uri.toString() == '/library' + ? '/library/books' + : null; }, - pageBuilder: (context, state) => NoTransitionPage(key: state.pageKey, child: const LibraryPage()), + pageBuilder: (context, state) => NoTransitionPage( + key: state.pageKey, + child: const LibraryPage(), + ), routes: [ GoRoute( name: 'BOOKS', path: 'books', - pageBuilder: (context, state) => NoTransitionPage(key: state.pageKey, child: const LibraryPage()), + pageBuilder: (context, state) => NoTransitionPage( + key: state.pageKey, + child: const LibraryPage(), + ), ), GoRoute( name: 'SHELVES', path: 'shelves', - pageBuilder: (context, state) => NoTransitionPage(key: state.pageKey, child: const ShelvesPage()), + pageBuilder: (context, state) => NoTransitionPage( + key: state.pageKey, + child: const ShelvesPage(), + ), routes: [ GoRoute( name: 'SHELF_CONTENTS', @@ -124,22 +151,34 @@ class AppRouter { GoRoute( name: 'BOOKMARKS', path: 'bookmarks', - pageBuilder: (context, state) => NoTransitionPage(key: state.pageKey, child: const BookmarksPage()), + pageBuilder: (context, state) => NoTransitionPage( + key: state.pageKey, + child: const BookmarksPage(), + ), ), GoRoute( name: 'ANNOTATIONS', path: 'annotations', - pageBuilder: (context, state) => NoTransitionPage(key: state.pageKey, child: const AnnotationsPage()), + pageBuilder: (context, state) => NoTransitionPage( + key: state.pageKey, + child: const AnnotationsPage(), + ), ), GoRoute( name: 'NOTES', path: 'notes', - pageBuilder: (context, state) => NoTransitionPage(key: state.pageKey, child: const NotesPage()), + pageBuilder: (context, state) => NoTransitionPage( + key: state.pageKey, + child: const NotesPage(), + ), ), GoRoute( name: 'SEARCH_OPTIONS', path: 'search/options', - pageBuilder: (context, state) => NoTransitionPage(key: state.pageKey, child: const SearchOptionsPage()), + pageBuilder: (context, state) => NoTransitionPage( + key: state.pageKey, + child: const SearchOptionsPage(), + ), ), GoRoute( name: 'BOOK_DETAILS', @@ -169,24 +208,42 @@ class AppRouter { GoRoute( name: 'GOALS', path: '/goals', - pageBuilder: (context, state) => NoTransitionPage(key: state.pageKey, child: const GoalsPage()), + pageBuilder: (context, state) => + NoTransitionPage(key: state.pageKey, child: const GoalsPage()), ), // Statistics GoRoute( name: 'STATISTICS', path: '/statistics', - pageBuilder: (context, state) => NoTransitionPage(key: state.pageKey, child: const StatisticsPage()), + pageBuilder: (context, state) => NoTransitionPage( + key: state.pageKey, + child: const StatisticsPage(), + ), + ), + GoRoute( + name: 'ACQUISITION', + path: '/acquisition', + pageBuilder: (context, state) => NoTransitionPage( + key: state.pageKey, + child: const AcquisitionPage(), + ), ), // Profile GoRoute( name: 'PROFILE', path: '/profile', - pageBuilder: (context, state) => NoTransitionPage(key: state.pageKey, child: const ProfilePage()), + pageBuilder: (context, state) => NoTransitionPage( + key: state.pageKey, + child: const ProfilePage(), + ), routes: [ GoRoute( name: 'EDIT_PROFILE', path: 'edit', - pageBuilder: (context, state) => NoTransitionPage(key: state.pageKey, child: const EditProfilePage()), + pageBuilder: (context, state) => NoTransitionPage( + key: state.pageKey, + child: const EditProfilePage(), + ), ), ], ), @@ -195,8 +252,10 @@ class AppRouter { GoRoute( name: 'DEVELOPER_OPTIONS', path: '/developer-options', - pageBuilder: (context, state) => - NoTransitionPage(key: state.pageKey, child: const DeveloperOptionsPage()), + pageBuilder: (context, state) => NoTransitionPage( + key: state.pageKey, + child: const DeveloperOptionsPage(), + ), ), ], ), @@ -227,10 +286,17 @@ class AppRouter { return '/'; } - if (location == '/' || location == '/login' || location == '/register' || location == '/reset-password') { + if (location == '/' || + location == '/login' || + location == '/register' || + location == '/reset-password') { return '/library/books'; } + if (location == '/acquisition' && !preferencesProvider.acquisitionEnabled) { + return '/profile'; + } + return null; } } diff --git a/app/lib/main.dart b/app/lib/main.dart index 40016cb..b8dbf31 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -52,6 +52,7 @@ class Papyrus extends StatefulWidget { class _PapyrusState extends State { late final DataStore _dataStore; late final AuthProvider _authProvider; + late final PreferencesProvider _preferencesProvider; late final SyncSettingsProvider _syncSettingsProvider; late final MediaUploadQueue _mediaUploadQueue; late final BookImportService _bookImportService; @@ -70,20 +71,34 @@ class _PapyrusState extends State { super.initState(); _officialApiConfig = PapyrusApiConfig.fromEnvironment(); - _syncSettingsProvider = SyncSettingsProvider(widget.prefs, officialConfig: _officialApiConfig); + _syncSettingsProvider = SyncSettingsProvider( + widget.prefs, + officialConfig: _officialApiConfig, + ); _activeProfileKey = _syncSettingsProvider.activeProfileKey; - _authRepository = _buildAuthRepository(_syncSettingsProvider.activeApiConfig, _activeProfileKey); + _authRepository = _buildAuthRepository( + _syncSettingsProvider.activeApiConfig, + _activeProfileKey, + ); _profileSwitchQueue = SyncProfileSwitchQueue( initialProfileKey: _activeProfileKey, onError: (error, stackTrace) { FlutterError.reportError( - FlutterErrorDetails(exception: error, stack: stackTrace, library: 'papyrus sync profile lifecycle'), + FlutterErrorDetails( + exception: error, + stack: stackTrace, + library: 'papyrus sync profile lifecycle', + ), ); }, ); _dataStore = DataStore(); - _mediaUploadQueue = MediaUploadQueue(widget.prefs, onWorkAvailable: _processMediaUploads); + _preferencesProvider = PreferencesProvider(widget.prefs); + _mediaUploadQueue = MediaUploadQueue( + widget.prefs, + onWorkAvailable: _processMediaUploads, + ); _bookImportService = BookImportService(); _authProvider = AuthProvider(widget.prefs, repository: _authRepository); _powerSyncService = PapyrusPowerSyncService( @@ -95,7 +110,10 @@ class _PapyrusState extends State { ); registerHotRestartCleanup(_disposeDataServices); unawaited(_dataStore.attachBookRepository(_powerSyncService)); - _appRouter = AppRouter(authProvider: _authProvider); + _appRouter = AppRouter( + authProvider: _authProvider, + preferencesProvider: _preferencesProvider, + ); _authProvider.addListener(_syncPowerSyncAuthState); _syncSettingsProvider.addListener(_handleSyncSettingsChanged); _syncPowerSyncAuthState(); @@ -109,10 +127,14 @@ class _PapyrusState extends State { _bookImportService.dispose(); _authProvider.dispose(); _syncSettingsProvider.dispose(); + _preferencesProvider.dispose(); super.dispose(); } - AuthRepository _buildAuthRepository(PapyrusApiConfig config, String profileKey) { + AuthRepository _buildAuthRepository( + PapyrusApiConfig config, + String profileKey, + ) { final tokenStore = TokenStore(SecureRefreshTokenStorage.scoped(profileKey)); return AuthRepository( apiClient: AuthApiClient(config: config), @@ -138,7 +160,11 @@ class _PapyrusState extends State { onError: (Object error, StackTrace stackTrace) { _clearAuthStateOperation(operation); FlutterError.reportError( - FlutterErrorDetails(exception: error, stack: stackTrace, library: 'papyrus media/auth lifecycle'), + FlutterErrorDetails( + exception: error, + stack: stackTrace, + library: 'papyrus media/auth lifecycle', + ), ); }, ); @@ -155,8 +181,13 @@ class _PapyrusState extends State { final user = _authProvider.user; if (user != null && !_authProvider.isOfflineMode) { final userId = user.userId; - await _mediaUploadQueue.activateScope(MediaStorageScope(profileKey: _activeProfileKey, userId: userId)); - await _powerSyncService.activateAuthenticated(userId, profileKey: _activeProfileKey); + await _mediaUploadQueue.activateScope( + MediaStorageScope(profileKey: _activeProfileKey, userId: userId), + ); + await _powerSyncService.activateAuthenticated( + userId, + profileKey: _activeProfileKey, + ); await _refreshMediaUsage(); await _processMediaUploads(); return; @@ -170,7 +201,9 @@ class _PapyrusState extends State { await _mediaUploadQueue.activateScope(null); if (!_authProvider.isBootstrapping && _powerSyncService.mode != null) { - await _powerSyncService.deactivate(clearAuthenticated: !_switchingSyncProfile); + await _powerSyncService.deactivate( + clearAuthenticated: !_switchingSyncProfile, + ); } } @@ -186,10 +219,16 @@ class _PapyrusState extends State { void _handleSyncSettingsChanged() { final nextProfileKey = _syncSettingsProvider.activeProfileKey; final nextConfig = _syncSettingsProvider.activeApiConfig; - _profileSwitchQueue.request(nextProfileKey, () => _switchActiveSyncProfile(nextProfileKey, nextConfig)); + _profileSwitchQueue.request( + nextProfileKey, + () => _switchActiveSyncProfile(nextProfileKey, nextConfig), + ); } - Future _switchActiveSyncProfile(String nextProfileKey, PapyrusApiConfig nextConfig) async { + Future _switchActiveSyncProfile( + String nextProfileKey, + PapyrusApiConfig nextConfig, + ) async { _switchingSyncProfile = true; try { await _mediaUploadQueue.waitUntilIdle(); @@ -197,7 +236,10 @@ class _PapyrusState extends State { await _powerSyncService.deactivate(clearAuthenticated: false); _authRepository = _buildAuthRepository(nextConfig, nextProfileKey); _activeProfileKey = nextProfileKey; - await _authProvider.replaceRepository(_authRepository, bootstrapNewRepository: !_authProvider.isOfflineMode); + await _authProvider.replaceRepository( + _authRepository, + bootstrapNewRepository: !_authProvider.isOfflineMode, + ); unawaited(_refreshMediaUsage()); } finally { _switchingSyncProfile = false; @@ -216,16 +258,24 @@ class _PapyrusState extends State { } Future _processMediaUploads() async { - if (_switchingSyncProfile || !_authProvider.isSignedIn || _authProvider.isOfflineMode) return; + if (_switchingSyncProfile || + !_authProvider.isSignedIn || + _authProvider.isOfflineMode) + return; final user = _authProvider.user; if (user == null) return; final profileKey = _activeProfileKey; final repository = _authRepository; - final scope = MediaStorageScope(profileKey: profileKey, userId: user.userId); + final scope = MediaStorageScope( + profileKey: profileKey, + userId: user.userId, + ); if (_mediaUploadQueue.activeScope != scope) { await _mediaUploadQueue.activateScope(scope); } - if (_switchingSyncProfile || profileKey != _activeProfileKey || !identical(repository, _authRepository)) { + if (_switchingSyncProfile || + profileKey != _activeProfileKey || + !identical(repository, _authRepository)) { return; } await _mediaUploadQueue.processPending( @@ -248,12 +298,17 @@ class _PapyrusState extends State { promotePendingCover: _bookImportService.promotePendingCoverFile, onPromotionError: (error, stackTrace) { FlutterError.reportError( - FlutterErrorDetails(exception: error, stack: stackTrace, library: 'papyrus cover promotion'), + FlutterErrorDetails( + exception: error, + stack: stackTrace, + library: 'papyrus cover promotion', + ), ); }, ), ); - if (identical(repository, _authRepository) && _mediaUploadQueue.activeScope == scope) { + if (identical(repository, _authRepository) && + _mediaUploadQueue.activeScope == scope) { await _refreshMediaUsage(); } } @@ -270,12 +325,15 @@ class _PapyrusState extends State { Provider.value(value: _bookImportService), Provider(create: _createBookDownloadService), Provider(create: _createMediaCacheService), - StreamProvider.value(value: _powerSyncService.syncStates, initialData: _powerSyncService.syncState), + StreamProvider.value( + value: _powerSyncService.syncStates, + initialData: _powerSyncService.syncState, + ), // Auth and UI state providers ChangeNotifierProvider.value(value: _authProvider), ChangeNotifierProvider(create: (_) => SidebarProvider()), ChangeNotifierProvider(create: (_) => LibraryProvider()), - ChangeNotifierProvider(create: (_) => PreferencesProvider(widget.prefs)), + ChangeNotifierProvider.value(value: _preferencesProvider), ], child: Consumer( builder: (context, preferencesProvider, child) { @@ -294,6 +352,8 @@ class _PapyrusState extends State { } } -BookDownloadService _createBookDownloadService(BuildContext _) => const BookDownloadService(); +BookDownloadService _createBookDownloadService(BuildContext _) => + const BookDownloadService(); -MediaCacheService _createMediaCacheService(BuildContext _) => MediaCacheService(); +MediaCacheService _createMediaCacheService(BuildContext _) => + MediaCacheService(); diff --git a/app/lib/pages/acquisition_page.dart b/app/lib/pages/acquisition_page.dart new file mode 100644 index 0000000..5802d64 --- /dev/null +++ b/app/lib/pages/acquisition_page.dart @@ -0,0 +1,826 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:papyrus/acquisition/acquisition_api_client.dart'; +import 'package:papyrus/acquisition/acquisition_models.dart'; +import 'package:papyrus/auth/auth_api_client.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/themes/design_tokens.dart'; +import 'package:provider/provider.dart'; + +class AcquisitionPage extends StatefulWidget { + const AcquisitionPage({super.key}); + + @override + State createState() => _AcquisitionPageState(); +} + +class _AcquisitionPageState extends State { + final _queryController = TextEditingController(); + AcquisitionApiClient? _client; + Uri? _clientBaseUri; + AcquisitionCapabilities? _capabilities; + List _endpoints = []; + List _releases = []; + bool _loading = true; + bool _searching = false; + bool _submitting = false; + String? _error; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final config = context.read().activeApiConfig; + if (_clientBaseUri == config.serverBaseUri) return; + _client?.close(); + _client = AcquisitionApiClient(config: config); + _clientBaseUri = config.serverBaseUri; + _load(); + } + + @override + void dispose() { + _client?.close(); + _queryController.dispose(); + super.dispose(); + } + + AcquisitionApiClient get _apiClient => _client!; + + Future _authenticated(Future Function(String accessToken) action) { + return context.read().withFreshAccessToken(action); + } + + Future _load() async { + if (!context.read().acquisitionEnabled) { + context.go('/profile'); + return; + } + + setState(() { + _loading = true; + _error = null; + }); + + try { + final capabilities = await _authenticated(_apiClient.capabilities); + final endpoints = await _authenticated(_apiClient.listEndpoints); + if (!mounted) return; + setState(() { + _capabilities = capabilities; + _endpoints = endpoints; + }); + } on AuthApiException catch (error) { + if (!mounted) return; + setState(() => _error = _messageFor(error)); + } catch (_) { + if (!mounted) return; + setState(() { + _error = + 'This Papyrus server does not expose the torrent acquisition API.'; + }); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + Future _search() async { + final query = _queryController.text.trim(); + if (query.isEmpty || _capabilities == null) return; + + setState(() { + _searching = true; + _error = null; + _releases = []; + }); + + try { + final indexerIds = _endpoints + .where((endpoint) => endpoint.enabled && endpoint.kind.isIndexer) + .map((endpoint) => endpoint.id) + .toList(); + final releases = await _authenticated((token) { + return _apiClient.search( + accessToken: token, + query: query, + endpointIds: indexerIds.isEmpty ? null : indexerIds, + ); + }); + if (mounted) setState(() => _releases = releases); + } on AuthApiException catch (error) { + if (mounted) setState(() => _error = _messageFor(error)); + } catch (_) { + if (mounted) { + setState(() => _error = 'Search failed. Check your torrent indexers.'); + } + } finally { + if (mounted) setState(() => _searching = false); + } + } + + Future _submitRelease( + TorrentRelease release, + AcquisitionEndpoint client, + ) async { + setState(() => _submitting = true); + try { + await _authenticated((token) { + return _apiClient.submitRelease( + accessToken: token, + endpointId: client.id, + release: release, + ); + }); + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Sent to ${client.name}.'))); + } + } catch (_) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Could not submit this release.')), + ); + } + } finally { + if (mounted) setState(() => _submitting = false); + } + } + + Future _runArrCommand(AcquisitionEndpoint endpoint) async { + final capabilities = _capabilities; + final commands = capabilities?.arrCommands[endpoint.kind] ?? const []; + if (commands.isEmpty) return; + + final command = await _pickArrCommand(endpoint, commands); + if (command == null) return; + + final ids = await _askForIds(command); + if (ids == null) return; + + setState(() => _submitting = true); + try { + await _authenticated((token) { + return _apiClient.runArrCommand( + accessToken: token, + endpointId: endpoint.id, + command: command, + ids: ids, + ); + }); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('$command sent to ${endpoint.name}.')), + ); + } + } catch (_) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Could not run this Arr action.')), + ); + } + } finally { + if (mounted) setState(() => _submitting = false); + } + } + + Future _pickArrCommand( + AcquisitionEndpoint endpoint, + List commands, + ) { + return showModalBottomSheet( + context: context, + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + title: Text(endpoint.name), + subtitle: Text(endpoint.kind.label), + ), + ...commands.map( + (command) => ListTile( + leading: const Icon(Icons.play_arrow_outlined), + title: Text(_arrCommandLabel(command)), + subtitle: Text(command), + onTap: () => Navigator.pop(context, command), + ), + ), + ], + ), + ), + ); + } + + Future?> _askForIds(String command) async { + final controller = TextEditingController(); + try { + return showDialog>( + context: context, + builder: (context) => AlertDialog( + title: Text(_arrCommandLabel(command)), + content: TextField( + controller: controller, + decoration: const InputDecoration( + labelText: 'IDs', + helperText: 'Comma-separated IDs from the Arr application', + ), + keyboardType: TextInputType.text, + autofocus: true, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + final ids = controller.text + .split(',') + .map((value) => int.tryParse(value.trim())) + .whereType() + .toList(); + Navigator.pop(context, ids); + }, + child: const Text('Run'), + ), + ], + ), + ); + } finally { + controller.dispose(); + } + } + + Future _showEndpointDialog({AcquisitionEndpoint? endpoint}) async { + final capabilities = _capabilities; + if (capabilities == null) return; + + final name = TextEditingController(text: endpoint?.name ?? ''); + final url = TextEditingController(text: endpoint?.baseUrl.toString() ?? ''); + final apiKey = TextEditingController(); + final username = TextEditingController(); + final password = TextEditingController(); + var kind = endpoint?.kind ?? capabilities.endpointKinds.first; + var enabled = endpoint?.enabled ?? true; + + try { + final saved = await showDialog( + context: context, + builder: (context) => StatefulBuilder( + builder: (context, setDialogState) => AlertDialog( + title: Text( + endpoint == null ? 'Add integration' : 'Edit integration', + ), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: name, + decoration: const InputDecoration(labelText: 'Name'), + ), + DropdownButtonFormField( + initialValue: kind, + items: capabilities.endpointKinds + .map( + (value) => DropdownMenuItem( + value: value, + child: Text(value.label), + ), + ) + .toList(), + onChanged: endpoint == null + ? (value) => setDialogState(() => kind = value ?? kind) + : null, + decoration: const InputDecoration(labelText: 'Type'), + ), + TextField( + controller: url, + decoration: const InputDecoration(labelText: 'Server URL'), + keyboardType: TextInputType.url, + ), + TextField( + controller: apiKey, + decoration: const InputDecoration(labelText: 'API key'), + ), + TextField( + controller: username, + decoration: const InputDecoration(labelText: 'Username'), + ), + TextField( + controller: password, + obscureText: true, + decoration: const InputDecoration(labelText: 'Password'), + ), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Enabled'), + value: enabled, + onChanged: (value) => setDialogState(() => enabled = value), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () async { + final baseUrl = Uri.tryParse(url.text.trim()); + if (name.text.trim().isEmpty || + baseUrl == null || + !baseUrl.hasScheme || + baseUrl.userInfo.isNotEmpty) { + return; + } + try { + await _authenticated((token) { + if (endpoint == null) { + return _apiClient.createEndpoint( + accessToken: token, + name: name.text.trim(), + kind: kind, + baseUrl: baseUrl, + apiKey: _optional(apiKey.text), + username: _optional(username.text), + password: _optional(password.text), + ); + } + return _apiClient.updateEndpoint( + accessToken: token, + endpointId: endpoint.id, + name: name.text.trim(), + baseUrl: baseUrl, + apiKey: _optional(apiKey.text), + username: _optional(username.text), + password: _optional(password.text), + enabled: enabled, + ); + }); + if (context.mounted) Navigator.pop(context, true); + } catch (_) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Could not save this integration.'), + ), + ); + } + } + }, + child: const Text('Save'), + ), + ], + ), + ), + ); + if (saved == true) await _load(); + } finally { + name.dispose(); + url.dispose(); + apiKey.dispose(); + username.dispose(); + password.dispose(); + } + } + + Future _deleteEndpoint(AcquisitionEndpoint endpoint) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text('Remove ${endpoint.name}?'), + content: const Text( + 'Saved credentials for this integration will be removed.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Remove'), + ), + ], + ), + ); + if (confirmed != true) return; + + try { + await _authenticated((token) { + return _apiClient.deleteEndpoint( + accessToken: token, + endpointId: endpoint.id, + ); + }); + await _load(); + } catch (_) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Could not remove this integration.')), + ); + } + } + } + + @override + Widget build(BuildContext context) { + final capabilities = _capabilities; + final clients = _endpoints + .where((endpoint) => endpoint.enabled && endpoint.kind.isDownloadClient) + .toList(); + final indexers = _endpoints + .where((endpoint) => endpoint.kind.isIndexer) + .toList(); + final arrApps = _endpoints + .where((endpoint) => endpoint.kind.isArr) + .toList(); + + return Scaffold( + appBar: AppBar(title: const Text('Torrent acquisition')), + floatingActionButton: capabilities == null + ? null + : FloatingActionButton.extended( + onPressed: () => _showEndpointDialog(), + icon: const Icon(Icons.add), + label: const Text('Integration'), + ), + body: RefreshIndicator( + onRefresh: _load, + child: ListView( + padding: const EdgeInsets.all(Spacing.md), + children: [ + if (_loading) const LinearProgressIndicator(), + if (_submitting) const LinearProgressIndicator(), + if (_error != null) _ErrorBanner(message: _error!, onRetry: _load), + if (!_loading && _error == null) ...[ + _SearchCard( + queryController: _queryController, + searching: _searching, + canSearch: indexers.any((endpoint) => endpoint.enabled), + onSearch: _search, + ), + const SizedBox(height: Spacing.md), + _EndpointSection( + title: 'Torrent indexers', + emptyLabel: 'No torrent indexers configured', + endpoints: indexers, + onEdit: (endpoint) => _showEndpointDialog(endpoint: endpoint), + onDelete: _deleteEndpoint, + ), + _EndpointSection( + title: 'Download clients', + emptyLabel: 'No download clients configured', + endpoints: _endpoints + .where((endpoint) => endpoint.kind.isDownloadClient) + .toList(), + onEdit: (endpoint) => _showEndpointDialog(endpoint: endpoint), + onDelete: _deleteEndpoint, + ), + _ArrSection( + endpoints: arrApps, + onRun: _runArrCommand, + onEdit: (endpoint) => _showEndpointDialog(endpoint: endpoint), + onDelete: _deleteEndpoint, + ), + if (_releases.isNotEmpty) ...[ + const SizedBox(height: Spacing.md), + Text('Results', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: Spacing.sm), + ..._releases.map( + (release) => _ReleaseTile( + release: release, + clients: clients, + onSubmit: _submitRelease, + ), + ), + ] else if (!_searching && _queryController.text.trim().isNotEmpty) + const Padding( + padding: EdgeInsets.only(top: Spacing.md), + child: Text('No releases found.'), + ), + ], + ], + ), + ), + ); + } + + String _messageFor(AuthApiException error) { + if (error.statusCode == 404) { + return 'This Papyrus server does not expose the torrent acquisition API.'; + } + return error.message; + } + + String? _optional(String value) { + final trimmed = value.trim(); + return trimmed.isEmpty ? null : trimmed; + } + + String _arrCommandLabel(String command) => switch (command) { + 'AuthorSearch' => 'Search authors', + 'BookSearch' => 'Search books', + 'SeriesSearch' => 'Search series', + 'EpisodeSearch' => 'Search episodes', + 'MissingEpisodeSearch' => 'Search missing episodes', + 'MoviesSearch' => 'Search movies', + 'MissingMoviesSearch' => 'Search missing movies', + 'ArtistSearch' => 'Search artists', + 'AlbumSearch' => 'Search albums', + 'MissingAlbumSearch' => 'Search missing albums', + _ => command, + }; +} + +class _SearchCard extends StatelessWidget { + const _SearchCard({ + required this.queryController, + required this.searching, + required this.canSearch, + required this.onSearch, + }); + + final TextEditingController queryController; + final bool searching; + final bool canSearch; + final VoidCallback onSearch; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(Spacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Search torrents', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: Spacing.sm), + TextField( + controller: queryController, + enabled: canSearch && !searching, + onSubmitted: (_) => onSearch(), + decoration: InputDecoration( + labelText: 'Title, author, movie, album, or series', + suffixIcon: searching + ? const Padding( + padding: EdgeInsets.all(12), + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ) + : IconButton( + icon: const Icon(Icons.search), + onPressed: canSearch ? onSearch : null, + ), + ), + ), + if (!canSearch) + const Padding( + padding: EdgeInsets.only(top: Spacing.sm), + child: Text( + 'Add an enabled Prowlarr or Torznab indexer first.', + ), + ), + ], + ), + ), + ); + } +} + +class _EndpointSection extends StatelessWidget { + const _EndpointSection({ + required this.title, + required this.emptyLabel, + required this.endpoints, + required this.onEdit, + required this.onDelete, + }); + + final String title; + final String emptyLabel; + final List endpoints; + final ValueChanged onEdit; + final ValueChanged onDelete; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: Spacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: Spacing.sm), + if (endpoints.isEmpty) + Card( + child: ListTile( + leading: const Icon(Icons.info_outline), + title: Text(emptyLabel), + ), + ) + else + ...endpoints.map( + (endpoint) => _EndpointTile( + endpoint: endpoint, + onEdit: onEdit, + onDelete: onDelete, + ), + ), + ], + ), + ); + } +} + +class _ArrSection extends StatelessWidget { + const _ArrSection({ + required this.endpoints, + required this.onRun, + required this.onEdit, + required this.onDelete, + }); + + final List endpoints; + final ValueChanged onRun; + final ValueChanged onEdit; + final ValueChanged onDelete; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: Spacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Arr applications', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: Spacing.sm), + if (endpoints.isEmpty) + const Card( + child: ListTile( + leading: Icon(Icons.info_outline), + title: Text('No Arr applications configured'), + ), + ) + else + ...endpoints.map( + (endpoint) => _EndpointTile( + endpoint: endpoint, + onEdit: onEdit, + onDelete: onDelete, + trailingAction: IconButton( + tooltip: 'Run action', + icon: const Icon(Icons.play_arrow_outlined), + onPressed: endpoint.enabled ? () => onRun(endpoint) : null, + ), + ), + ), + ], + ), + ); + } +} + +class _EndpointTile extends StatelessWidget { + const _EndpointTile({ + required this.endpoint, + required this.onEdit, + required this.onDelete, + this.trailingAction, + }); + + final AcquisitionEndpoint endpoint; + final ValueChanged onEdit; + final ValueChanged onDelete; + final Widget? trailingAction; + + @override + Widget build(BuildContext context) { + return Card( + child: ListTile( + leading: Icon(_iconFor(endpoint.kind)), + title: Text(endpoint.name), + subtitle: Text('${endpoint.kind.label} • ${endpoint.baseUrl.host}'), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (trailingAction != null) trailingAction!, + Icon( + endpoint.enabled + ? Icons.check_circle_outline + : Icons.pause_circle_outline, + color: endpoint.enabled + ? Theme.of(context).colorScheme.primary + : null, + ), + PopupMenuButton( + onSelected: (value) { + if (value == 'edit') onEdit(endpoint); + if (value == 'delete') onDelete(endpoint); + }, + itemBuilder: (context) => const [ + PopupMenuItem(value: 'edit', child: Text('Edit')), + PopupMenuItem(value: 'delete', child: Text('Remove')), + ], + ), + ], + ), + ), + ); + } + + IconData _iconFor(AcquisitionEndpointKind kind) => switch (kind) { + AcquisitionEndpointKind.qbittorrent || + AcquisitionEndpointKind.transmission || + AcquisitionEndpointKind.deluge => Icons.downloading_outlined, + AcquisitionEndpointKind.prowlarr || + AcquisitionEndpointKind.torznab => Icons.travel_explore, + _ => Icons.auto_awesome_motion_outlined, + }; +} + +class _ReleaseTile extends StatelessWidget { + const _ReleaseTile({ + required this.release, + required this.clients, + required this.onSubmit, + }); + + final TorrentRelease release; + final List clients; + final void Function(TorrentRelease release, AcquisitionEndpoint client) + onSubmit; + + @override + Widget build(BuildContext context) { + return Card( + child: ListTile( + title: Text(release.title), + subtitle: Text( + [ + release.indexer, + if (release.seeders != null) '${release.seeders} seeders', + if (release.sizeBytes != null) _formatBytes(release.sizeBytes!), + if (release.isMagnet) 'magnet', + ].join(' • '), + ), + trailing: PopupMenuButton( + enabled: clients.isNotEmpty, + icon: const Icon(Icons.send_outlined), + tooltip: 'Send to client', + itemBuilder: (context) => clients + .map( + (client) => + PopupMenuItem(value: client, child: Text(client.name)), + ) + .toList(), + onSelected: (client) => onSubmit(release, client), + ), + ), + ); + } + + String _formatBytes(int bytes) { + if (bytes >= 1073741824) + return '${(bytes / 1073741824).toStringAsFixed(1)} GB'; + if (bytes >= 1048576) return '${(bytes / 1048576).toStringAsFixed(1)} MB'; + if (bytes >= 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; + return '$bytes B'; + } +} + +class _ErrorBanner extends StatelessWidget { + const _ErrorBanner({required this.message, required this.onRetry}); + + final String message; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Card( + color: colorScheme.errorContainer, + child: ListTile( + leading: Icon(Icons.error_outline, color: colorScheme.onErrorContainer), + title: Text( + message, + style: TextStyle(color: colorScheme.onErrorContainer), + ), + trailing: TextButton(onPressed: onRetry, child: const Text('Retry')), + ), + ); + } +} diff --git a/app/lib/pages/profile_page.dart b/app/lib/pages/profile_page.dart index 9875984..96f1403 100644 --- a/app/lib/pages/profile_page.dart +++ b/app/lib/pages/profile_page.dart @@ -113,7 +113,11 @@ class _ProfilePageState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ const SettingsSectionHeader(title: 'Appearance'), - SettingsRow(label: 'Theme', value: _getThemeLabel(prefs.themeModePref), onTap: () => _showThemePicker(context)), + SettingsRow( + label: 'Theme', + value: _getThemeLabel(prefs.themeModePref), + onTap: () => _showThemePicker(context), + ), ], ); } @@ -125,7 +129,11 @@ class _ProfilePageState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ const SettingsSectionHeader(title: 'Reading'), - SettingsRow(label: 'Default font', value: prefs.defaultFont, onTap: () => _showFontPicker(context)), + SettingsRow( + label: 'Default font', + value: prefs.defaultFont, + onTap: () => _showFontPicker(context), + ), SettingsRow( label: 'Line spacing', value: _capitalize(prefs.lineSpacing), @@ -205,6 +213,7 @@ class _ProfilePageState extends State { Widget _buildMobileStorageSyncSection(BuildContext context) { final controller = _storageSyncController(context); + final prefs = context.watch(); if (controller.isGuest) { return Column( @@ -213,12 +222,15 @@ class _ProfilePageState extends State { const SettingsSectionHeader(title: 'Storage'), const SettingsRow(label: 'Library', value: 'Stored on this device'), Padding( - padding: const EdgeInsets.symmetric(horizontal: Spacing.sm, vertical: Spacing.xs), + 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), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), ), ), SettingsRow( @@ -226,7 +238,10 @@ class _ProfilePageState extends State { value: 'Export or import a backup', onTap: () => _showOfflineBackupActions(context), ), - SettingsRow(label: 'Clear local library', onTap: () => _confirmClearLocalLibrary(context)), + SettingsRow( + label: 'Clear local library', + onTap: () => _confirmClearLocalLibrary(context), + ), ], ); } @@ -248,12 +263,35 @@ class _ProfilePageState extends State { value: controller.failedMediaUploadLabel, onTap: () => _retryFailedMediaUploads(context), ), - SettingsRow(label: 'Manage servers', onTap: () => _showManageSyncServersSheet(context)), - if (controller.canReconnect) SettingsRow(label: 'Reconnect', onTap: () => _handleReconnectSync(context)), + SettingsRow( + label: 'Manage servers', + onTap: () => _showManageSyncServersSheet(context), + ), + SettingsToggleRow( + label: 'Torrent acquisition', + value: prefs.acquisitionEnabled, + onChanged: (value) => prefs.acquisitionEnabled = value, + ), + if (prefs.acquisitionEnabled) + SettingsRow( + label: 'Torrent & automation', + onTap: () => context.push('/acquisition'), + ), + if (controller.canReconnect) + SettingsRow( + label: 'Reconnect', + onTap: () => _handleReconnectSync(context), + ), if (controller.canClearGuestLibrary) - SettingsRow(label: 'Clear local library', onTap: () => _confirmClearLocalLibrary(context)), + SettingsRow( + label: 'Clear local library', + onTap: () => _confirmClearLocalLibrary(context), + ), if (controller.canClearAuthenticatedCache) - SettingsRow(label: 'Clear local copy', onTap: () => _confirmClearAuthenticatedCache(context)), + SettingsRow( + label: 'Clear local copy', + onTap: () => _confirmClearAuthenticatedCache(context), + ), ], ); } @@ -413,7 +451,12 @@ class _ProfilePageState extends State { label: 'Developer options', section: _ProfileSection.developerOptions, ), - _buildNavItem(context, icon: Icons.info_outline, label: 'About', section: _ProfileSection.about), + _buildNavItem( + context, + icon: Icons.info_outline, + label: 'About', + section: _ProfileSection.about, + ), const SizedBox(height: Spacing.sm), Divider(height: 1, color: colorScheme.outlineVariant), const SizedBox(height: Spacing.sm), @@ -456,7 +499,9 @@ class _ProfilePageState extends State { : isSelected ? colorScheme.onPrimaryContainer : null; - final bgColor = isSelected ? colorScheme.primaryContainer : Colors.transparent; + final bgColor = isSelected + ? colorScheme.primaryContainer + : Colors.transparent; return Padding( padding: const EdgeInsets.symmetric(vertical: 2), @@ -473,7 +518,10 @@ class _ProfilePageState extends State { }, borderRadius: BorderRadius.circular(AppRadius.md), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: Spacing.md, vertical: Spacing.sm + 2), + padding: const EdgeInsets.symmetric( + horizontal: Spacing.md, + vertical: Spacing.sm + 2, + ), child: Row( children: [ Icon(icon, color: iconColor, size: IconSizes.medium), @@ -483,7 +531,9 @@ class _ProfilePageState extends State { label, style: textTheme.bodyMedium?.copyWith( color: textColor, - fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, + fontWeight: isSelected + ? FontWeight.w600 + : FontWeight.normal, ), ), ), @@ -592,7 +642,12 @@ class _ProfilePageState extends State { children: [ Text(_getDisplayName(), style: textTheme.headlineSmall), const SizedBox(height: Spacing.xs), - Text(_getEmail(), style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)), + Text( + _getEmail(), + style: textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), const SizedBox(height: Spacing.md), Align( alignment: Alignment.centerLeft, @@ -621,7 +676,11 @@ class _ProfilePageState extends State { SettingsCard( title: 'Connected accounts', children: [ - SettingsRow(label: 'Google', value: _isGoogleLinked() ? 'Connected' : 'Not connected', onTap: () {}), + SettingsRow( + label: 'Google', + value: _isGoogleLinked() ? 'Connected' : 'Not connected', + onTap: () {}, + ), ], ), const SizedBox(height: Spacing.lg), @@ -646,7 +705,12 @@ class _ProfilePageState extends State { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('Theme', style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)), + Text( + 'Theme', + style: textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), const SizedBox(height: Spacing.sm), _buildRadioTile('Light', 'light'), _buildRadioTile('Dark', 'dark'), @@ -672,7 +736,9 @@ class _ProfilePageState extends State { decoration: BoxDecoration( shape: BoxShape.circle, border: Border.all( - color: isSelected ? Theme.of(context).colorScheme.primary : Theme.of(context).colorScheme.outline, + color: isSelected + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.outline, width: 2, ), ), @@ -681,7 +747,10 @@ class _ProfilePageState extends State { child: Container( width: 10, height: 10, - decoration: BoxDecoration(shape: BoxShape.circle, color: Theme.of(context).colorScheme.primary), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Theme.of(context).colorScheme.primary, + ), ), ) : null, @@ -723,7 +792,12 @@ class _ProfilePageState extends State { onChanged: (value) => prefs.defaultFont = value, ), const SizedBox(height: Spacing.lg), - Text('Default font size', style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)), + Text( + 'Default font size', + style: textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), Row( children: [ Expanded( @@ -735,7 +809,13 @@ class _ProfilePageState extends State { onChanged: (value) => prefs.defaultFontSize = value, ), ), - SizedBox(width: 48, child: Text('${prefs.defaultFontSize.toInt()}px', style: textTheme.bodyMedium)), + SizedBox( + width: 48, + child: Text( + '${prefs.defaultFontSize.toInt()}px', + style: textTheme.bodyMedium, + ), + ), ], ), const SizedBox(height: Spacing.md), @@ -743,7 +823,11 @@ class _ProfilePageState extends State { context, label: 'Line spacing', value: prefs.lineSpacing, - options: const {'compact': 'Compact', 'normal': 'Normal', 'relaxed': 'Relaxed'}, + options: const { + 'compact': 'Compact', + 'normal': 'Normal', + 'relaxed': 'Relaxed', + }, onChanged: (value) => prefs.lineSpacing = value, ), const SizedBox(height: Spacing.md), @@ -759,7 +843,11 @@ class _ProfilePageState extends State { context, label: 'Margins', value: prefs.margins, - options: const {'small': 'Small', 'medium': 'Medium', 'large': 'Large'}, + options: const { + 'small': 'Small', + 'medium': 'Medium', + 'large': 'Large', + }, onChanged: (value) => prefs.margins = value, ), ], @@ -772,7 +860,10 @@ class _ProfilePageState extends State { context, label: 'Reading mode', value: prefs.readingMode, - options: const {'paginated': 'Paginated', 'scroll': 'Continuous scroll'}, + options: const { + 'paginated': 'Paginated', + 'scroll': 'Continuous scroll', + }, onChanged: (value) => prefs.readingMode = value, ), const SizedBox(height: Spacing.md), @@ -784,7 +875,10 @@ class _ProfilePageState extends State { ], ), const SizedBox(height: Spacing.lg), - SettingsCard(title: 'Annotations', children: [_buildHighlightColorField(context)]), + SettingsCard( + title: 'Annotations', + children: [_buildHighlightColorField(context)], + ), const SizedBox(height: Spacing.lg), SettingsCard( children: [SettingsRow(label: 'Reading profiles', onTap: () {})], @@ -809,7 +903,12 @@ class _ProfilePageState extends State { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('Default highlight color', style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)), + Text( + 'Default highlight color', + style: textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), const SizedBox(height: Spacing.sm), Row( children: highlightColors.entries.map((entry) { @@ -828,7 +927,9 @@ class _ProfilePageState extends State { ? Border.all(color: colorScheme.primary, width: 3) : Border.all(color: colorScheme.outline, width: 1), ), - child: isSelected ? Icon(Icons.check, size: 18, color: colorScheme.primary) : null, + child: isSelected + ? Icon(Icons.check, size: 18, color: colorScheme.primary) + : null, ), ), ); @@ -853,7 +954,11 @@ class _ProfilePageState extends State { context, label: 'Default view mode', value: prefs.defaultViewMode, - options: const {'grid': 'Grid', 'list': 'List', 'compact': 'Compact'}, + options: const { + 'grid': 'Grid', + 'list': 'List', + 'compact': 'Compact', + }, onChanged: (value) => prefs.defaultViewMode = value, ), const SizedBox(height: Spacing.md), @@ -861,7 +966,13 @@ class _ProfilePageState extends State { context, label: 'Default sort order', value: prefs.defaultSortOrder, - options: const ['title', 'author', 'date_added', 'last_read', 'rating'], + options: const [ + 'title', + 'author', + 'date_added', + 'last_read', + 'rating', + ], labels: const { 'title': 'Title', 'author': 'Author', @@ -881,7 +992,10 @@ class _ProfilePageState extends State { context, label: 'Metadata source', value: prefs.metadataSource, - options: const {'Open Library': 'Open Library', 'Google Books': 'Google Books'}, + options: const { + 'Open Library': 'Open Library', + 'Google Books': 'Google Books', + }, onChanged: (value) => prefs.metadataSource = value, ), const SizedBox(height: Spacing.md), @@ -930,23 +1044,50 @@ class _ProfilePageState extends State { final colorScheme = Theme.of(context).colorScheme; final textTheme = Theme.of(context).textTheme; final controller = _storageSyncController(context); + final prefs = context.watch(); if (controller.isGuest) return _buildOfflineStorageSyncContent(context); return SettingsCard( title: 'Data sync', children: [ - _buildInfoRow(context, label: 'Active server', value: controller.dataSyncLabel), + _buildInfoRow( + context, + label: 'Active server', + value: controller.dataSyncLabel, + ), _buildInfoRow(context, label: 'Status', value: controller.statusLabel), - _buildInfoRow(context, label: 'File storage', value: controller.fileStorageLabel), + _buildInfoRow( + context, + label: 'File storage', + value: controller.fileStorageLabel, + ), if (controller.hasFailedMediaUploads) - _buildInfoRow(context, label: 'Media uploads', value: controller.failedMediaUploadLabel), + _buildInfoRow( + context, + label: 'Media uploads', + value: controller.failedMediaUploadLabel, + ), const SizedBox(height: Spacing.sm), Padding( - padding: const EdgeInsets.symmetric(horizontal: Spacing.sm, vertical: Spacing.xs), - child: Text(controller.syncDetail, style: textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant)), + padding: const EdgeInsets.symmetric( + horizontal: Spacing.sm, + vertical: Spacing.xs, + ), + child: Text( + controller.syncDetail, + style: textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), ), const SizedBox(height: Spacing.sm), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Torrent acquisition'), + value: prefs.acquisitionEnabled, + onChanged: (value) => prefs.acquisitionEnabled = value, + ), Wrap( spacing: Spacing.sm, runSpacing: Spacing.sm, @@ -963,6 +1104,15 @@ class _ProfilePageState extends State { icon: const Icon(Icons.dns_outlined, size: IconSizes.small), label: const Text('Manage servers'), ), + if (prefs.acquisitionEnabled) + OutlinedButton.icon( + onPressed: () => context.push('/acquisition'), + icon: const Icon( + Icons.downloading_outlined, + size: IconSizes.small, + ), + label: const Text('Torrent & automation'), + ), if (controller.hasFailedMediaUploads) OutlinedButton.icon( onPressed: () => _retryFailedMediaUploads(context), @@ -972,7 +1122,10 @@ class _ProfilePageState extends State { if (controller.canClearAuthenticatedCache) OutlinedButton.icon( onPressed: () => _confirmClearAuthenticatedCache(context), - icon: const Icon(Icons.cleaning_services_outlined, size: IconSizes.small), + icon: const Icon( + Icons.cleaning_services_outlined, + size: IconSizes.small, + ), label: const Text('Clear local copy'), ), ], @@ -983,7 +1136,9 @@ class _ProfilePageState extends State { Widget _buildOfflineStorageSyncContent(BuildContext context) { final textTheme = Theme.of(context).textTheme; - final mutedStyle = textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant); + final mutedStyle = textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ); return SettingsCard( title: 'Library storage', @@ -993,7 +1148,10 @@ class _ProfilePageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('Your library is stored on this device.', style: textTheme.bodyLarge), + 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.', @@ -1009,12 +1167,18 @@ class _ProfilePageState extends State { children: [ OutlinedButton.icon( onPressed: () => _showBackupUnavailable(context, 'Backup export'), - icon: const Icon(Icons.file_download_outlined, size: IconSizes.small), + icon: const Icon( + Icons.file_download_outlined, + size: IconSizes.small, + ), label: const Text('Export backup'), ), OutlinedButton.icon( onPressed: () => _showBackupUnavailable(context, 'Backup import'), - icon: const Icon(Icons.file_upload_outlined, size: IconSizes.small), + icon: const Icon( + Icons.file_upload_outlined, + size: IconSizes.small, + ), label: const Text('Import backup'), ), OutlinedButton.icon( @@ -1036,30 +1200,49 @@ class _ProfilePageState extends State { syncState: context.watch(), fileStorageUsedBytes: _fileStorageUsedBytes(context.watch()), mediaStorageUsage: context.watch().storageUsage, - failedMediaUploadCount: _failedMediaUploadCount(context.watch()), + failedMediaUploadCount: _failedMediaUploadCount( + context.watch(), + ), ); } int _fileStorageUsedBytes(DataStore dataStore) { - return dataStore.books.fold(0, (total, book) => total + (book.fileSize ?? 0)); + return dataStore.books.fold( + 0, + (total, book) => total + (book.fileSize ?? 0), + ); } int _failedMediaUploadCount(MediaUploadQueue queue) { - return queue.pendingTasks.where((task) => task.status == MediaUploadTaskStatus.failed).length; + return queue.pendingTasks + .where((task) => task.status == MediaUploadTaskStatus.failed) + .length; } - Widget _buildInfoRow(BuildContext context, {required String label, required String value}) { + 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), + 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)), + child: Text( + label, + style: textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), ), Expanded(child: SelectableText(value, style: textTheme.bodyMedium)), ], @@ -1088,9 +1271,9 @@ class _ProfilePageState extends State { child: Text( 'Help improve Papyrus by sharing anonymous usage statistics. ' 'No personal data or reading content is collected.', - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), ), ), ], @@ -1126,13 +1309,17 @@ class _ProfilePageState extends State { onChanged: (value) => prefs.reduceAnimations = value, ), Padding( - padding: const EdgeInsets.only(left: Spacing.sm, right: Spacing.sm, bottom: Spacing.md), + padding: const EdgeInsets.only( + left: Spacing.sm, + right: Spacing.sm, + bottom: Spacing.md, + ), child: Text( 'Minimizes motion effects throughout the app. ' 'Separate from e-ink mode.', - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), ), ), SettingsToggleRow( @@ -1141,12 +1328,16 @@ class _ProfilePageState extends State { onChanged: (value) => prefs.dyslexiaFont = value, ), Padding( - padding: const EdgeInsets.only(left: Spacing.sm, right: Spacing.sm, bottom: Spacing.md), + padding: const EdgeInsets.only( + left: Spacing.sm, + right: Spacing.sm, + bottom: Spacing.md, + ), child: Text( 'Use OpenDyslexic font across the app interface.', - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), ), ), ], @@ -1236,7 +1427,10 @@ class _ProfilePageState extends State { title: const Text('Log out'), content: const Text('Are you sure you want to log out?'), actions: [ - TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')), + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), FilledButton( onPressed: () { Navigator.pop(context); @@ -1254,7 +1448,11 @@ class _ProfilePageState extends State { } void _showLicenses(BuildContext context) { - showLicensePage(context: context, applicationName: 'Papyrus', applicationVersion: '1.0.0'); + showLicensePage( + context: context, + applicationName: 'Papyrus', + applicationVersion: '1.0.0', + ); } void _showManageSyncServersSheet(BuildContext context) { @@ -1267,26 +1465,41 @@ class _ProfilePageState extends State { 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), + 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 + 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'), + subtitle: const Text( + 'Papyrus-hosted data sync and file storage', + ), onTap: () { - settings.selectServer(SyncSettingsProvider.officialServerId); + 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, + settings.activeServerId == server.id + ? Icons.radio_button_checked + : Icons.radio_button_unchecked, ), title: Text(server.label), subtitle: Text(server.url), @@ -1298,7 +1511,9 @@ class _ProfilePageState extends State { onSelected: (value) { if (value == 'edit') { Navigator.pop(sheetContext); - unawaited(_showCustomServerDialog(context, server: server)); + unawaited( + _showCustomServerDialog(context, server: server), + ); } else if (value == 'remove') { settings.removeCustomServer(server.id); } @@ -1325,7 +1540,10 @@ class _ProfilePageState extends State { ); } - Future _showCustomServerDialog(BuildContext context, {CustomSyncServer? server}) async { + Future _showCustomServerDialog( + BuildContext context, { + CustomSyncServer? server, + }) async { final settings = context.read(); final urlController = TextEditingController(text: server?.url ?? ''); final messenger = ScaffoldMessenger.of(context); @@ -1334,7 +1552,9 @@ class _ProfilePageState extends State { await showDialog( context: context, builder: (dialogContext) => AlertDialog( - title: Text(server == null ? 'Add custom server' : 'Edit custom server'), + title: Text( + server == null ? 'Add custom server' : 'Edit custom server', + ), content: TextField( controller: urlController, decoration: const InputDecoration(labelText: 'Server URL'), @@ -1342,18 +1562,26 @@ class _ProfilePageState extends State { autofocus: true, ), actions: [ - TextButton(onPressed: () => Navigator.pop(dialogContext), child: const Text('Cancel')), + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: const Text('Cancel'), + ), FilledButton( onPressed: () async { try { if (server == null) { await settings.addCustomServer(urlController.text); } else { - await settings.updateCustomServer(server.id, urlController.text); + await settings.updateCustomServer( + server.id, + urlController.text, + ); } if (dialogContext.mounted) Navigator.pop(dialogContext); } catch (error) { - messenger.showSnackBar(SnackBar(content: Text('Could not save server: $error'))); + messenger.showSnackBar( + SnackBar(content: Text('Could not save server: $error')), + ); } }, child: const Text('Save'), @@ -1370,16 +1598,24 @@ class _ProfilePageState extends State { final messenger = ScaffoldMessenger.of(context); try { await context.read().reconnect(); - messenger.showSnackBar(const SnackBar(content: Text('Sync reconnect requested.'))); + messenger.showSnackBar( + const SnackBar(content: Text('Sync reconnect requested.')), + ); } catch (error) { - messenger.showSnackBar(SnackBar(content: Text('Could not reconnect sync: $error'))); + messenger.showSnackBar( + SnackBar(content: Text('Could not reconnect sync: $error')), + ); } } Future _retryFailedMediaUploads(BuildContext context) async { final messenger = ScaffoldMessenger.of(context); await context.read().retryFailed(); - messenger.showSnackBar(const SnackBar(content: Text('Media uploads will retry on the next sync.'))); + messenger.showSnackBar( + const SnackBar( + content: Text('Media uploads will retry on the next sync.'), + ), + ); } void _showOfflineBackupActions(BuildContext context) { @@ -1414,7 +1650,9 @@ class _ProfilePageState extends State { } void _showBackupUnavailable(BuildContext context, String action) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('$action is not available yet.'))); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('$action is not available yet.'))); } Future _confirmClearLocalLibrary(BuildContext context) async { @@ -1430,9 +1668,13 @@ class _ProfilePageState extends State { final messenger = ScaffoldMessenger.of(context); try { await context.read().clearGuestLibrary(); - messenger.showSnackBar(const SnackBar(content: Text('Local library cleared.'))); + messenger.showSnackBar( + const SnackBar(content: Text('Local library cleared.')), + ); } catch (error) { - messenger.showSnackBar(SnackBar(content: Text('Could not clear local library: $error'))); + messenger.showSnackBar( + SnackBar(content: Text('Could not clear local library: $error')), + ); } } @@ -1455,9 +1697,13 @@ class _ProfilePageState extends State { if (scope != null) { await importService.clearCoverFiles(scope); } - messenger.showSnackBar(const SnackBar(content: Text('Local copy cleared.'))); + messenger.showSnackBar( + const SnackBar(content: Text('Local copy cleared.')), + ); } catch (error) { - messenger.showSnackBar(SnackBar(content: Text('Could not clear local copy: $error'))); + messenger.showSnackBar( + SnackBar(content: Text('Could not clear local copy: $error')), + ); } } @@ -1473,8 +1719,14 @@ class _ProfilePageState extends State { 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)), + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: Text(actionLabel), + ), ], ), ) ?? @@ -1499,7 +1751,12 @@ class _ProfilePageState extends State { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(label, style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)), + Text( + label, + style: textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), const SizedBox(height: Spacing.sm), DropdownMenu( initialSelection: value, @@ -1529,13 +1786,21 @@ class _ProfilePageState extends State { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(label, style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)), + Text( + label, + style: textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), const SizedBox(height: Spacing.sm), SizedBox( width: double.infinity, child: SegmentedButton( segments: options.entries.map((entry) { - return ButtonSegment(value: entry.key, label: Text(entry.value)); + return ButtonSegment( + value: entry.key, + label: Text(entry.value), + ); }).toList(), selected: {value}, onSelectionChanged: (selected) { @@ -1587,7 +1852,12 @@ class _ProfilePageState extends State { _showPickerSheet( context, - items: [('Light', 'light'), ('Dark', 'dark'), ('E-ink', 'eink'), ('System', 'system')], + items: [ + ('Light', 'light'), + ('Dark', 'dark'), + ('E-ink', 'eink'), + ('System', 'system'), + ], selected: prefs.themeModePref, onSelected: (value) => prefs.themeModePref = value, ); @@ -1617,7 +1887,11 @@ class _ProfilePageState extends State { _showPickerSheet( context, - items: [('Compact', 'compact'), ('Normal', 'normal'), ('Relaxed', 'relaxed')], + items: [ + ('Compact', 'compact'), + ('Normal', 'normal'), + ('Relaxed', 'relaxed'), + ], selected: prefs.lineSpacing, onSelected: (value) => prefs.lineSpacing = value, ); @@ -1667,7 +1941,10 @@ class _ProfilePageState extends State { _showPickerSheet( context, - items: [('Open Library', 'Open Library'), ('Google Books', 'Google Books')], + items: [ + ('Open Library', 'Open Library'), + ('Google Books', 'Google Books'), + ], selected: prefs.metadataSource, onSelected: (value) => prefs.metadataSource = value, ); @@ -1678,7 +1955,12 @@ class _ProfilePageState extends State { _showPickerSheet( context, - items: [('Markdown', 'Markdown'), ('PDF', 'PDF'), ('TXT', 'TXT'), ('HTML', 'HTML')], + items: [ + ('Markdown', 'Markdown'), + ('PDF', 'PDF'), + ('TXT', 'TXT'), + ('HTML', 'HTML'), + ], selected: prefs.annotationExportFormat, onSelected: (value) => prefs.annotationExportFormat = value, ); @@ -1741,17 +2023,26 @@ class _ProfilePageState extends State { children: [ _buildAvatar(context, size: avatarSize), const SizedBox(height: Spacing.md), - Text(_getDisplayName(), style: textTheme.headlineSmall, textAlign: TextAlign.center), + Text( + _getDisplayName(), + style: textTheme.headlineSmall, + textAlign: TextAlign.center, + ), const SizedBox(height: Spacing.xs), Text( _getEmail(), - style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant), + style: textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), textAlign: TextAlign.center, ), const SizedBox(height: Spacing.md), SizedBox( width: 200, - child: OutlinedButton(onPressed: () => _navigateToEditProfile(context), child: const Text('Edit profile')), + child: OutlinedButton( + onPressed: () => _navigateToEditProfile(context), + child: const Text('Edit profile'), + ), ), ], ); @@ -1765,7 +2056,10 @@ class _ProfilePageState extends State { return Container( width: size, height: size, - decoration: BoxDecoration(borderRadius: BorderRadius.circular(size / 2), color: colorScheme.primaryContainer), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(size / 2), + color: colorScheme.primaryContainer, + ), clipBehavior: Clip.antiAlias, child: avatarUrl != null && avatarUrl.isNotEmpty ? Image.network( @@ -1784,7 +2078,10 @@ class _ProfilePageState extends State { : Center( child: Text( _initials, - style: textTheme.headlineMedium?.copyWith(color: colorScheme.onPrimaryContainer, fontSize: size * 0.35), + style: textTheme.headlineMedium?.copyWith( + color: colorScheme.onPrimaryContainer, + fontSize: size * 0.35, + ), ), ), ); @@ -1800,7 +2097,9 @@ class _ProfilePageState extends State { }) { final colorScheme = Theme.of(context).colorScheme; final textTheme = Theme.of(context).textTheme; - final iconColor = isDestructive ? colorScheme.error : colorScheme.onSurfaceVariant; + final iconColor = isDestructive + ? colorScheme.error + : colorScheme.onSurfaceVariant; final textColor = isDestructive ? colorScheme.error : null; return Material( @@ -1809,7 +2108,10 @@ class _ProfilePageState extends State { onTap: onTap, borderRadius: BorderRadius.circular(AppRadius.sm), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: Spacing.sm, vertical: Spacing.md), + padding: const EdgeInsets.symmetric( + horizontal: Spacing.sm, + vertical: Spacing.md, + ), child: Row( children: [ Container( @@ -1825,9 +2127,17 @@ class _ProfilePageState extends State { ), const SizedBox(width: Spacing.md), Expanded( - child: Text(label, style: textTheme.bodyLarge?.copyWith(color: textColor)), + child: Text( + label, + style: textTheme.bodyLarge?.copyWith(color: textColor), + ), ), - if (showChevron) Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant, size: IconSizes.medium), + if (showChevron) + Icon( + Icons.chevron_right, + color: colorScheme.onSurfaceVariant, + size: IconSizes.medium, + ), ], ), ), diff --git a/app/lib/providers/auth_provider.dart b/app/lib/providers/auth_provider.dart index a692c7d..0af93c7 100644 --- a/app/lib/providers/auth_provider.dart +++ b/app/lib/providers/auth_provider.dart @@ -34,8 +34,11 @@ class AuthProvider extends ChangeNotifier { String? _error; String? get error => _error; - AuthProvider(this._prefs, {required AuthRepository repository, bool bootstrapOnCreate = true}) - : _repository = repository { + AuthProvider( + this._prefs, { + required AuthRepository repository, + bool bootstrapOnCreate = true, + }) : _repository = repository { _isOfflineMode = _prefs.getBool(_keyOfflineMode) ?? false; if (bootstrapOnCreate) { @@ -43,7 +46,10 @@ class AuthProvider extends ChangeNotifier { } } - Future replaceRepository(AuthRepository repository, {bool bootstrapNewRepository = true}) async { + Future replaceRepository( + AuthRepository repository, { + bool bootstrapNewRepository = true, + }) async { _repository = repository; _user = null; _error = null; @@ -71,7 +77,11 @@ class AuthProvider extends ChangeNotifier { } } - Future register({required String email, required String password, required String displayName}) async { + Future register({ + required String email, + required String password, + required String displayName, + }) async { return _runTokenAction(() { return _repository.register( email: email, @@ -85,7 +95,12 @@ class AuthProvider extends ChangeNotifier { Future login({required String email, required String password}) async { return _runTokenAction(() { - return _repository.login(email: email, password: password, clientType: _clientType, deviceLabel: _deviceLabel); + return _repository.login( + email: email, + password: password, + clientType: _clientType, + deviceLabel: _deviceLabel, + ); }); } @@ -94,7 +109,10 @@ class AuthProvider extends ChangeNotifier { _error = null; try { - final tokens = await _repository.signInWithGoogle(clientType: _clientType, deviceLabel: _deviceLabel); + final tokens = await _repository.signInWithGoogle( + clientType: _clientType, + deviceLabel: _deviceLabel, + ); if (tokens == null) { return false; @@ -115,7 +133,11 @@ class AuthProvider extends ChangeNotifier { Future completeGoogleSignIn(Uri callbackUri) async { return _runTokenAction(() { - return _repository.completeGoogleSignIn(callbackUri, clientType: _clientType, deviceLabel: _deviceLabel); + return _repository.completeGoogleSignIn( + callbackUri, + clientType: _clientType, + deviceLabel: _deviceLabel, + ); }); } @@ -150,9 +172,15 @@ class AuthProvider extends ChangeNotifier { _setStatus(AuthStatus.signedOut); } - Future updateProfile({required String displayName, String? avatarUrl}) async { + Future updateProfile({ + required String displayName, + String? avatarUrl, + }) async { try { - _user = await _repository.updateCurrentUser(displayName: displayName, avatarUrl: avatarUrl); + _user = await _repository.updateCurrentUser( + displayName: displayName, + avatarUrl: avatarUrl, + ); _error = null; notifyListeners(); return true; @@ -167,7 +195,10 @@ class AuthProvider extends ChangeNotifier { return _runMessageAction(() => _repository.forgotPassword(email)); } - Future resetPassword({required String token, required String password}) { + Future resetPassword({ + required String token, + required String password, + }) { return _runMessageAction(() { return _repository.resetPassword(token: token, password: password); }); @@ -185,6 +216,21 @@ class AuthProvider extends ChangeNotifier { return _repository.downloadMedia(assetId); } + Future withFreshAccessToken( + Future Function(String accessToken) action, + ) async { + try { + return await _repository.withFreshAccessToken(action); + } catch (error) { + if (error is AuthApiException && error.statusCode == 401) { + _user = null; + _error = _messageFor(error); + _setStatus(AuthStatus.signedOut); + } + rethrow; + } + } + void setOfflineMode(bool value) { _isOfflineMode = value; _prefs.setBool(_keyOfflineMode, value); diff --git a/app/lib/providers/preferences_provider.dart b/app/lib/providers/preferences_provider.dart index 15c3abb..81b338a 100644 --- a/app/lib/providers/preferences_provider.dart +++ b/app/lib/providers/preferences_provider.dart @@ -44,6 +44,7 @@ class PreferencesProvider extends ChangeNotifier { static const _keyServerType = 'server_type'; static const _keySyncInterval = 'sync_interval'; static const _keyConflictResolution = 'conflict_resolution'; + static const _keyAcquisitionEnabled = 'acquisition_enabled'; // Privacy static const _keyAnalyticsOptIn = 'analytics_opt_in'; @@ -134,7 +135,8 @@ class PreferencesProvider extends ChangeNotifier { } /// Highlight color: 'yellow', 'green', 'blue', 'pink', or 'orange'. - String get defaultHighlightColor => _prefs.getString(_keyDefaultHighlightColor) ?? 'yellow'; + String get defaultHighlightColor => + _prefs.getString(_keyDefaultHighlightColor) ?? 'yellow'; set defaultHighlightColor(String value) { _prefs.setString(_keyDefaultHighlightColor, value); @@ -152,7 +154,8 @@ class PreferencesProvider extends ChangeNotifier { } /// Sort order: 'title', 'author', 'date_added', 'last_read', or 'rating'. - String get defaultSortOrder => _prefs.getString(_keyDefaultSortOrder) ?? 'date_added'; + String get defaultSortOrder => + _prefs.getString(_keyDefaultSortOrder) ?? 'date_added'; set defaultSortOrder(String value) { _prefs.setString(_keyDefaultSortOrder, value); @@ -160,7 +163,8 @@ class PreferencesProvider extends ChangeNotifier { } /// Metadata source: 'Open Library' or 'Google Books'. - String get metadataSource => _prefs.getString(_keyMetadataSource) ?? 'Open Library'; + String get metadataSource => + _prefs.getString(_keyMetadataSource) ?? 'Open Library'; set metadataSource(String value) { _prefs.setString(_keyMetadataSource, value); @@ -168,7 +172,8 @@ class PreferencesProvider extends ChangeNotifier { } /// Annotation export format: 'Markdown', 'PDF', 'TXT', or 'HTML'. - String get annotationExportFormat => _prefs.getString(_keyAnnotationExportFormat) ?? 'Markdown'; + String get annotationExportFormat => + _prefs.getString(_keyAnnotationExportFormat) ?? 'Markdown'; set annotationExportFormat(String value) { _prefs.setString(_keyAnnotationExportFormat, value); @@ -191,7 +196,8 @@ class PreferencesProvider extends ChangeNotifier { notifyListeners(); } - bool get syncStatusNotifications => _prefs.getBool(_keySyncStatusNotifications) ?? false; + bool get syncStatusNotifications => + _prefs.getBool(_keySyncStatusNotifications) ?? false; set syncStatusNotifications(bool value) { _prefs.setBool(_keySyncStatusNotifications, value); @@ -238,13 +244,22 @@ class PreferencesProvider extends ChangeNotifier { } /// Conflict resolution: 'server', 'client', or 'ask'. - String get conflictResolution => _prefs.getString(_keyConflictResolution) ?? 'server'; + String get conflictResolution => + _prefs.getString(_keyConflictResolution) ?? 'server'; set conflictResolution(String value) { _prefs.setString(_keyConflictResolution, value); notifyListeners(); } + bool get acquisitionEnabled => + _prefs.getBool(_keyAcquisitionEnabled) ?? false; + + set acquisitionEnabled(bool value) { + _prefs.setBool(_keyAcquisitionEnabled, value); + notifyListeners(); + } + // -- Privacy -------------------------------------------------------------- bool get analyticsOptIn => _prefs.getBool(_keyAnalyticsOptIn) ?? false; diff --git a/app/test/acquisition/acquisition_api_client_test.dart b/app/test/acquisition/acquisition_api_client_test.dart new file mode 100644 index 0000000..a5828b2 --- /dev/null +++ b/app/test/acquisition/acquisition_api_client_test.dart @@ -0,0 +1,69 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:papyrus/acquisition/acquisition_api_client.dart'; +import 'package:papyrus/acquisition/acquisition_models.dart'; +import 'package:papyrus/auth/papyrus_api_config.dart'; + +void main() { + test('uses acquisition capabilities endpoint with bearer auth', () async { + final seenPaths = []; + final client = AcquisitionApiClient( + config: PapyrusApiConfig(serverBaseUri: Uri.parse('https://api.test')), + httpClient: MockClient((request) async { + seenPaths.add(request.url.path); + expect(request.headers['authorization'], 'Bearer access-token'); + return http.Response( + jsonEncode({ + 'endpoint_kinds': ['qbittorrent', 'prowlarr', 'readarr'], + 'indexer_kinds': ['prowlarr'], + 'download_client_kinds': ['qbittorrent'], + 'arr_kinds': ['readarr'], + 'arr_commands': { + 'readarr': ['BookSearch'], + }, + }), + 200, + ); + }), + ); + + final capabilities = await client.capabilities('access-token'); + + expect(seenPaths, ['/v1/acquisition/capabilities']); + expect(capabilities.downloadClientKinds, [ + AcquisitionEndpointKind.qbittorrent, + ]); + expect(capabilities.arrCommands[AcquisitionEndpointKind.readarr], [ + 'BookSearch', + ]); + }); + + test('submits a selected release to a torrent client', () async { + late Map requestBody; + final client = AcquisitionApiClient( + config: PapyrusApiConfig(serverBaseUri: Uri.parse('https://api.test')), + httpClient: MockClient((request) async { + expect(request.url.path, '/v1/acquisition/submissions'); + requestBody = jsonDecode(request.body) as Map; + return http.Response(jsonEncode({'job_id': 'job-1'}), 201); + }), + ); + + await client.submitRelease( + accessToken: 'access-token', + endpointId: 'client-1', + release: const TorrentRelease( + title: 'Example', + downloadUrl: 'magnet:?xt=urn:btih:example', + protocol: 'torrent', + indexer: 'Prowlarr', + ), + ); + + expect(requestBody['endpoint_id'], 'client-1'); + expect(requestBody['download_url'], 'magnet:?xt=urn:btih:example'); + }); +} diff --git a/app/test/acquisition/acquisition_models_test.dart b/app/test/acquisition/acquisition_models_test.dart new file mode 100644 index 0000000..9467421 --- /dev/null +++ b/app/test/acquisition/acquisition_models_test.dart @@ -0,0 +1,50 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/acquisition/acquisition_models.dart'; + +void main() { + test('recognizes a magnet release returned by an indexer', () { + final release = TorrentRelease.fromJson({ + 'title': 'Example book', + 'download_url': 'magnet:?xt=urn:btih:example', + 'protocol': 'torrent', + 'indexer': 'Prowlarr', + 'seeders': 12, + }); + + expect(release.isMagnet, isTrue); + expect(release.seeders, 12); + }); + + test('parses torrent-only capabilities', () { + final capabilities = AcquisitionCapabilities.fromJson({ + 'endpoint_kinds': [ + 'qbittorrent', + 'transmission', + 'deluge', + 'prowlarr', + 'torznab', + 'readarr', + ], + 'indexer_kinds': ['prowlarr', 'torznab'], + 'download_client_kinds': ['qbittorrent', 'transmission', 'deluge'], + 'arr_kinds': ['readarr'], + 'arr_commands': { + 'readarr': ['AuthorSearch', 'BookSearch'], + }, + }); + + expect(capabilities.indexerKinds, [ + AcquisitionEndpointKind.prowlarr, + AcquisitionEndpointKind.torznab, + ]); + expect(capabilities.downloadClientKinds, [ + AcquisitionEndpointKind.qbittorrent, + AcquisitionEndpointKind.transmission, + AcquisitionEndpointKind.deluge, + ]); + expect(capabilities.arrCommands[AcquisitionEndpointKind.readarr], [ + 'AuthorSearch', + 'BookSearch', + ]); + }); +} diff --git a/app/test/config/app_router_test.dart b/app/test/config/app_router_test.dart index d2b6197..e038228 100644 --- a/app/test/config/app_router_test.dart +++ b/app/test/config/app_router_test.dart @@ -6,6 +6,7 @@ import 'package:papyrus/auth/papyrus_api_config.dart'; import 'package:papyrus/auth/token_store.dart'; import 'package:papyrus/config/app_router.dart'; import 'package:papyrus/providers/auth_provider.dart'; +import 'package:papyrus/providers/preferences_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; class MemoryRefreshTokenStorage implements RefreshTokenStorage { @@ -28,7 +29,11 @@ class MemoryRefreshTokenStorage implements RefreshTokenStorage { class FakeAuthRepository extends AuthRepository { FakeAuthRepository() : super( - apiClient: AuthApiClient(config: PapyrusApiConfig(serverBaseUri: Uri.parse('http://server.test'))), + apiClient: AuthApiClient( + config: PapyrusApiConfig( + serverBaseUri: Uri.parse('http://server.test'), + ), + ), tokenStore: TokenStore(MemoryRefreshTokenStorage()), ); @@ -47,11 +52,18 @@ void main() { test('redirects signed-out users away from protected routes', () async { final prefs = await SharedPreferences.getInstance(); - final provider = AuthProvider(prefs, repository: FakeAuthRepository(), bootstrapOnCreate: false); + final provider = AuthProvider( + prefs, + repository: FakeAuthRepository(), + bootstrapOnCreate: false, + ); await provider.bootstrap(); - final appRouter = AppRouter(authProvider: provider); + final appRouter = AppRouter( + authProvider: provider, + preferencesProvider: PreferencesProvider(prefs), + ); expect(appRouter.redirectForPath('/library/books'), '/'); expect(appRouter.redirectForPath('/login'), isNull); @@ -61,11 +73,18 @@ void main() { test('redirects signed-in users away from auth routes', () async { final prefs = await SharedPreferences.getInstance(); final repository = FakeAuthRepository()..bootstrapResult = _tokens(); - final provider = AuthProvider(prefs, repository: repository, bootstrapOnCreate: false); + final provider = AuthProvider( + prefs, + repository: repository, + bootstrapOnCreate: false, + ); await provider.bootstrap(); - final appRouter = AppRouter(authProvider: provider); + final appRouter = AppRouter( + authProvider: provider, + preferencesProvider: PreferencesProvider(prefs), + ); expect(appRouter.redirectForPath('/login'), '/library/books'); expect(appRouter.redirectForPath('/reset-password'), '/library/books'); @@ -74,22 +93,66 @@ void main() { test('offline mode bypasses protected-route auth redirect', () async { final prefs = await SharedPreferences.getInstance(); - final provider = AuthProvider(prefs, repository: FakeAuthRepository(), bootstrapOnCreate: false); + final provider = AuthProvider( + prefs, + repository: FakeAuthRepository(), + bootstrapOnCreate: false, + ); await provider.bootstrap(); provider.setOfflineMode(true); - final appRouter = AppRouter(authProvider: provider); + final appRouter = AppRouter( + authProvider: provider, + preferencesProvider: PreferencesProvider(prefs), + ); expect(appRouter.redirectForPath('/library/books'), isNull); }); test('book edit has a stable reloadable URL', () async { final prefs = await SharedPreferences.getInstance(); - final provider = AuthProvider(prefs, repository: FakeAuthRepository(), bootstrapOnCreate: false); - final appRouter = AppRouter(authProvider: provider); + final provider = AuthProvider( + prefs, + repository: FakeAuthRepository(), + bootstrapOnCreate: false, + ); + final appRouter = AppRouter( + authProvider: provider, + preferencesProvider: PreferencesProvider(prefs), + ); + + expect( + appRouter.router.namedLocation( + 'BOOK_EDIT', + pathParameters: {'bookId': 'book-1'}, + ), + '/library/edit/book-1', + ); + }); + + test('acquisition route requires explicit opt-in', () async { + final prefs = await SharedPreferences.getInstance(); + final repository = FakeAuthRepository()..bootstrapResult = _tokens(); + final provider = AuthProvider( + prefs, + repository: repository, + bootstrapOnCreate: false, + ); + final preferences = PreferencesProvider(prefs); + + await provider.bootstrap(); + + final appRouter = AppRouter( + authProvider: provider, + preferencesProvider: preferences, + ); + + expect(appRouter.redirectForPath('/acquisition'), '/profile'); + + preferences.acquisitionEnabled = true; - expect(appRouter.router.namedLocation('BOOK_EDIT', pathParameters: {'bookId': 'book-1'}), '/library/edit/book-1'); + expect(appRouter.redirectForPath('/acquisition'), isNull); }); } diff --git a/app/test/pages/profile_storage_sync_test.dart b/app/test/pages/profile_storage_sync_test.dart index 6deabba..66aab8c 100644 --- a/app/test/pages/profile_storage_sync_test.dart +++ b/app/test/pages/profile_storage_sync_test.dart @@ -39,7 +39,11 @@ class _MemoryRefreshTokenStorage implements RefreshTokenStorage { class _FakeAuthRepository extends AuthRepository { _FakeAuthRepository() : super( - apiClient: AuthApiClient(config: PapyrusApiConfig(serverBaseUri: Uri.parse('https://api.test'))), + apiClient: AuthApiClient( + config: PapyrusApiConfig( + serverBaseUri: Uri.parse('https://api.test'), + ), + ), tokenStore: TokenStore(_MemoryRefreshTokenStorage()), ); @@ -63,8 +67,13 @@ class _OfflineConnector extends PowerSyncBackendConnector { } class _FakePowerSyncService extends PapyrusPowerSyncService { - _FakePowerSyncService({required this.currentMode, required this.currentSyncState}) - : super(connectorFactory: _OfflineConnector.new, connectAuthenticated: false); + _FakePowerSyncService({ + required this.currentMode, + required this.currentSyncState, + }) : super( + connectorFactory: _OfflineConnector.new, + connectAuthenticated: false, + ); LibraryDatabaseMode? currentMode; SyncState currentSyncState; @@ -102,13 +111,20 @@ void main() { SharedPreferences.setMockInitialValues({}); }); - Future buildAuthProvider({bool guest = false, bool signedIn = false}) async { + 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); + final provider = AuthProvider( + prefs, + repository: repository, + bootstrapOnCreate: false, + ); await provider.bootstrap(); if (guest) { provider.setOfflineMode(true); @@ -132,15 +148,26 @@ void main() { return MultiProvider( providers: [ - ChangeNotifierProvider.value(value: dataStore ?? DataStore()), - ChangeNotifierProvider.value(value: mediaUploadQueue ?? MediaUploadQueue(prefs)), + ChangeNotifierProvider.value( + value: dataStore ?? DataStore(), + ), + ChangeNotifierProvider.value( + value: mediaUploadQueue ?? MediaUploadQueue(prefs), + ), ChangeNotifierProvider.value( - value: syncSettingsProvider ?? SyncSettingsProvider(prefs, officialConfig: config), + value: + syncSettingsProvider ?? + SyncSettingsProvider(prefs, officialConfig: config), ), Provider.value(value: powerSyncService), - StreamProvider.value(value: powerSyncService.syncStates, initialData: powerSyncService.syncState), + StreamProvider.value( + value: powerSyncService.syncStates, + initialData: powerSyncService.syncState, + ), ChangeNotifierProvider.value(value: authProvider), - ChangeNotifierProvider(create: (_) => PreferencesProvider(prefs)), + ChangeNotifierProvider( + create: (_) => PreferencesProvider(prefs), + ), ], child: MaterialApp( home: MediaQuery( @@ -151,76 +178,220 @@ void main() { ); } - 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()); + 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'), 400); - await tester.pumpAndSettle(); + await tester.pumpWidget( + await buildPage(authProvider: auth, powerSyncService: service), + ); + await tester.pumpAndSettle(); + await tester.scrollUntilVisible(find.text('Storage'), 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://data-sync.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); + expect(find.text('Pending changes'), findsNothing); + expect(find.text('No pending local writes'), findsNothing); + }, + ); - 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://data-sync.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); - 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 { + final auth = await buildAuthProvider(guest: true); + final service = _FakePowerSyncService( + currentMode: LibraryDatabaseMode.guest, + currentSyncState: const SyncState(), + ); - 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.pump(); + await tester.tap(find.text('Storage').first); + await tester.pump(); + + expect(find.text('Library storage'), findsOneWidget); + expect( + find.text('Your library is stored on this device.'), + findsOneWidget, + ); + expect( + find.textContaining('Nothing is sent to Papyrus servers'), + findsOneWidget, + ); + expect(find.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); + expect(find.text('Pending changes'), findsNothing); + expect(find.text('No pending local writes'), findsNothing); + }, + ); - await tester.pumpWidget( - await buildPage(authProvider: auth, powerSyncService: service, screenSize: const Size(1200, 900)), - ); - await tester.pump(); - await tester.tap(find.text('Storage').first); - await tester.pump(); - - expect(find.text('Library storage'), findsOneWidget); - expect(find.text('Your library is stored on this device.'), findsOneWidget); - expect(find.textContaining('Nothing is sent to Papyrus servers'), findsOneWidget); - expect(find.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); - 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), + dataStore: dataStore, + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Storage').first); + await tester.pumpAndSettle(); + + expect(find.text('Data sync'), findsOneWidget); + expect(find.text('Official server'), findsWidgets); + expect( + find.text('350 MB used, 674 MB available of 1 GB'), + findsOneWidget, + ); + expect(find.text('Connected'), findsWidgets); + expect(find.text('Reconnect'), findsOneWidget); + expect(find.text('Manage servers'), findsOneWidget); + expect(find.text('Torrent acquisition'), findsOneWidget); + expect(find.text('Torrent & automation'), findsNothing); + 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); + 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')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Reconnect')); + await tester.pump(); + + expect(service.reconnectCalls, 1); + + await tester.tap(find.text('Torrent acquisition')); + await tester.pumpAndSettle(); + + expect(find.text('Torrent & automation'), findsOneWidget); + }, + ); + + 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), + ); - testWidgets('authenticated storage sync UI shows data sync and hides implementation details', (tester) async { + await tester.pumpWidget( + await buildPage( + authProvider: auth, + powerSyncService: service, + syncSettingsProvider: syncSettings, + ), + ); + await tester.pumpAndSettle(); + await tester.scrollUntilVisible(find.text('Storage'), 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 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)), + currentSyncState: const SyncState( + connected: true, + hasPendingWrites: true, + uploadError: 'upload failed', + ), ); await tester.pumpWidget( @@ -228,83 +399,7 @@ void main() { authProvider: auth, powerSyncService: service, screenSize: const Size(1200, 900), - dataStore: dataStore, - ), - ); - await tester.pumpAndSettle(); - await tester.tap(find.text('Storage').first); - await tester.pumpAndSettle(); - - expect(find.text('Data sync'), findsOneWidget); - expect(find.text('Official server'), findsWidgets); - expect(find.text('350 MB used, 674 MB available of 1 GB'), findsOneWidget); - expect(find.text('Connected'), findsWidgets); - expect(find.text('Reconnect'), findsOneWidget); - expect(find.text('Manage servers'), 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); - 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')); - await tester.pumpAndSettle(); - 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'), 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( - currentMode: LibraryDatabaseMode.authenticated, - currentSyncState: const SyncState(connected: true, hasPendingWrites: true, uploadError: 'upload failed'), - ); - - await tester.pumpWidget( - await buildPage(authProvider: auth, powerSyncService: service, screenSize: const Size(1200, 900)), ); await tester.pumpAndSettle(); await tester.tap(find.text('Storage').first); @@ -323,7 +418,13 @@ DataStore dataStoreWithBooks(List books) { } 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)); + return Book( + id: id, + title: title, + author: 'Author', + fileSize: fileSize, + addedAt: DateTime.utc(2026, 6, 27), + ); } AuthTokens _tokens() {