From 56e9bda7d870c88f06dd96f18594f470414c3569 Mon Sep 17 00:00:00 2001 From: "Matheus Henrique(Aka: TheusHen)" Date: Mon, 13 Jul 2026 14:06:33 -0300 Subject: [PATCH 1/8] Add torrent acquisition API client --- .../acquisition/acquisition_api_client.dart | 125 ++++++++++++++++++ app/lib/acquisition/acquisition_models.dart | 69 ++++++++++ .../acquisition/acquisition_models_test.dart | 17 +++ 3 files changed, 211 insertions(+) create mode 100644 app/lib/acquisition/acquisition_api_client.dart create mode 100644 app/lib/acquisition/acquisition_models.dart create mode 100644 app/test/acquisition/acquisition_models_test.dart diff --git a/app/lib/acquisition/acquisition_api_client.dart b/app/lib/acquisition/acquisition_api_client.dart new file mode 100644 index 0000000..dc74865 --- /dev/null +++ b/app/lib/acquisition/acquisition_api_client.dart @@ -0,0 +1,125 @@ +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; + + AcquisitionApiClient({required this.config, http.Client? httpClient}) + : _httpClient = httpClient ?? http.Client(); + + 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> 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..bc15689 --- /dev/null +++ b/app/lib/acquisition/acquisition_models.dart @@ -0,0 +1,69 @@ +enum AcquisitionEndpointKind { + qbittorrent, + transmission, + deluge, + prowlarr, + torznab, + newznab, + readarr, + sonarr, + radarr, + lidarr, + whisparr; + + String get apiValue => name; +} + +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/test/acquisition/acquisition_models_test.dart b/app/test/acquisition/acquisition_models_test.dart new file mode 100644 index 0000000..284b7fa --- /dev/null +++ b/app/test/acquisition/acquisition_models_test.dart @@ -0,0 +1,17 @@ +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); + }); +} From fd33e689f9f375cb033a8c26e8f3c8771d694e47 Mon Sep 17 00:00:00 2001 From: "Matheus Henrique(Aka: TheusHen)" Date: Mon, 13 Jul 2026 14:10:47 -0300 Subject: [PATCH 2/8] Add acquisition settings interface --- app/lib/config/app_router.dart | 100 ++++-- app/lib/pages/acquisition_page.dart | 308 +++++++++++++++++ app/lib/pages/profile_page.dart | 491 +++++++++++++++++++++------ app/lib/providers/auth_provider.dart | 53 ++- 4 files changed, 824 insertions(+), 128 deletions(-) create mode 100644 app/lib/pages/acquisition_page.dart diff --git a/app/lib/config/app_router.dart b/app/lib/config/app_router.dart index 3de412f..a5b5cff 100644 --- a/app/lib/config/app_router.dart +++ b/app/lib/config/app_router.dart @@ -21,6 +21,7 @@ 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'; @@ -46,24 +47,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 +98,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 +149,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 +206,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 +250,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,7 +284,10 @@ class AppRouter { return '/'; } - if (location == '/' || location == '/login' || location == '/register' || location == '/reset-password') { + if (location == '/' || + location == '/login' || + location == '/register' || + location == '/reset-password') { return '/library/books'; } diff --git a/app/lib/pages/acquisition_page.dart b/app/lib/pages/acquisition_page.dart new file mode 100644 index 0000000..0051919 --- /dev/null +++ b/app/lib/pages/acquisition_page.dart @@ -0,0 +1,308 @@ +import 'package:flutter/material.dart'; +import 'package:papyrus/acquisition/acquisition_api_client.dart'; +import 'package:papyrus/acquisition/acquisition_models.dart'; +import 'package:papyrus/providers/auth_provider.dart'; +import 'package:papyrus/providers/sync_settings_provider.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(); + List _endpoints = []; + List _releases = []; + bool _loading = true; + String? _error; + + @override + void initState() { + super.initState(); + _loadEndpoints(); + } + + @override + void dispose() { + _queryController.dispose(); + super.dispose(); + } + + AcquisitionApiClient get _client => AcquisitionApiClient( + config: context.read().activeApiConfig, + ); + + String? get _token => context.read().accessToken; + + Future _loadEndpoints() async { + final token = _token; + if (token == null) { + setState(() { + _loading = false; + _error = 'Sign in to manage acquisition integrations.'; + }); + return; + } + try { + final endpoints = await _client.listEndpoints(token); + if (mounted) setState(() => _endpoints = endpoints); + } catch (_) { + if (mounted) + setState(() => _error = 'Could not load acquisition integrations.'); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + Future _search() async { + final token = _token; + final query = _queryController.text.trim(); + if (token == null || query.isEmpty) return; + setState(() => _loading = true); + try { + final releases = await _client.search(accessToken: token, query: query); + if (mounted) setState(() => _releases = releases); + } catch (_) { + if (mounted) + setState( + () => _error = 'Search failed. Check the configured indexers.', + ); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + Future _addEndpoint() async { + final name = TextEditingController(); + final url = TextEditingController(); + final apiKey = TextEditingController(); + final username = TextEditingController(); + final password = TextEditingController(); + var kind = AcquisitionEndpointKind.qbittorrent; + final submitted = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Add integration'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: name, + decoration: const InputDecoration(labelText: 'Name'), + ), + DropdownButtonFormField( + initialValue: kind, + items: AcquisitionEndpointKind.values + .map( + (value) => DropdownMenuItem( + value: value, + child: Text(value.name), + ), + ) + .toList(), + onChanged: (value) => kind = value ?? kind, + decoration: const InputDecoration(labelText: 'Type'), + ), + TextField( + controller: url, + decoration: const InputDecoration(labelText: 'Server URL'), + ), + TextField( + controller: apiKey, + decoration: const InputDecoration( + labelText: 'API key (if applicable)', + ), + ), + TextField( + controller: username, + decoration: const InputDecoration( + labelText: 'Username (if applicable)', + ), + ), + TextField( + controller: password, + obscureText: true, + decoration: const InputDecoration( + labelText: 'Password (if applicable)', + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () async { + final token = _token; + final baseUrl = Uri.tryParse(url.text.trim()); + if (token == null || + name.text.trim().isEmpty || + baseUrl == null || + !baseUrl.hasScheme) + return; + try { + await _client.createEndpoint( + accessToken: token, + name: name.text.trim(), + kind: kind, + baseUrl: baseUrl, + apiKey: apiKey.text.trim().isEmpty + ? null + : apiKey.text.trim(), + username: username.text.trim().isEmpty + ? null + : username.text.trim(), + password: password.text.isEmpty ? null : password.text, + ); + 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'), + ), + ], + ), + ); + name.dispose(); + url.dispose(); + apiKey.dispose(); + username.dispose(); + password.dispose(); + if (submitted == true) _loadEndpoints(); + } + + @override + Widget build(BuildContext context) { + final downloadClients = _endpoints.where( + (endpoint) => const { + AcquisitionEndpointKind.qbittorrent, + AcquisitionEndpointKind.transmission, + AcquisitionEndpointKind.deluge, + }.contains(endpoint.kind), + ); + return Scaffold( + appBar: AppBar(title: const Text('Acquisition')), + floatingActionButton: FloatingActionButton.extended( + onPressed: _addEndpoint, + icon: const Icon(Icons.add), + label: const Text('Integration'), + ), + body: RefreshIndicator( + onRefresh: _loadEndpoints, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + Text( + 'Torrent & automation', + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: 8), + const Text( + 'Configure indexers, download clients, and Readarr or other Arr applications.', + ), + const SizedBox(height: 16), + TextField( + controller: _queryController, + onSubmitted: (_) => _search(), + decoration: InputDecoration( + labelText: 'Search releases', + suffixIcon: IconButton( + icon: const Icon(Icons.search), + onPressed: _search, + ), + ), + ), + if (_error != null) + Padding( + padding: const EdgeInsets.only(top: 12), + child: Text( + _error!, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ), + if (_loading) + const Padding( + padding: EdgeInsets.all(24), + child: Center(child: CircularProgressIndicator()), + ), + ..._endpoints.map( + (endpoint) => Card( + child: ListTile( + leading: Icon(_iconFor(endpoint.kind)), + title: Text(endpoint.name), + subtitle: Text( + '${endpoint.kind.name} • ${endpoint.baseUrl.host}', + ), + trailing: endpoint.enabled + ? const Icon(Icons.check_circle_outline) + : const Icon(Icons.pause_circle_outline), + ), + ), + ), + if (_releases.isNotEmpty) + const Padding( + padding: EdgeInsets.only(top: 20, bottom: 8), + child: Text('Results'), + ), + ..._releases.map( + (release) => Card( + child: ListTile( + title: Text(release.title), + subtitle: Text( + '${release.indexer}${release.seeders == null ? '' : ' • ${release.seeders} seeders'}', + ), + trailing: PopupMenuButton( + itemBuilder: (_) => downloadClients + .map( + (client) => PopupMenuItem( + value: client, + child: Text('Send to ${client.name}'), + ), + ) + .toList(), + onSelected: (client) async { + final token = _token; + if (token == null) return; + await _client.submitRelease( + accessToken: token, + endpointId: client.id, + release: release, + ); + if (mounted) + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Release submitted.')), + ); + }, + ), + ), + ), + ), + ], + ), + ), + ); + } + + IconData _iconFor(AcquisitionEndpointKind kind) => switch (kind) { + AcquisitionEndpointKind.qbittorrent || + AcquisitionEndpointKind.transmission || + AcquisitionEndpointKind.deluge => Icons.downloading_outlined, + AcquisitionEndpointKind.prowlarr || + AcquisitionEndpointKind.torznab || + AcquisitionEndpointKind.newznab => Icons.travel_explore, + _ => Icons.auto_awesome_motion_outlined, + }; +} diff --git a/app/lib/pages/profile_page.dart b/app/lib/pages/profile_page.dart index 9875984..4d9aeb7 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), @@ -213,12 +221,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 +237,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 +262,29 @@ 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), + ), + 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 +444,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 +492,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 +511,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 +524,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 +635,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 +669,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 +698,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 +729,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 +740,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 +785,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 +802,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 +816,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 +836,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 +853,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 +868,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 +896,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 +920,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 +947,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 +959,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 +985,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), @@ -936,15 +1043,35 @@ class _ProfilePageState extends State { 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), Wrap( @@ -963,6 +1090,14 @@ class _ProfilePageState extends State { icon: const Icon(Icons.dns_outlined, size: IconSizes.small), label: const Text('Manage servers'), ), + 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 +1107,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 +1121,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 +1133,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 +1152,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 +1185,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 +1256,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 +1294,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 +1313,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 +1412,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 +1433,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 +1450,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 +1496,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 +1525,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 +1537,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 +1547,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 +1583,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 +1635,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 +1653,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 +1682,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 +1704,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 +1736,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 +1771,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 +1837,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 +1872,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 +1926,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 +1940,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 +2008,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 +2041,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 +2063,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 +2082,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 +2093,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 +2112,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..afd5d7e 100644 --- a/app/lib/providers/auth_provider.dart +++ b/app/lib/providers/auth_provider.dart @@ -25,6 +25,8 @@ class AuthProvider extends ChangeNotifier { bool get isSignedIn => _user != null && _status == AuthStatus.signedIn; + String? get accessToken => _repository.accessToken; + bool get isLoading { return _status == AuthStatus.bootstrapping || _status == AuthStatus.authenticating || @@ -34,8 +36,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 +48,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 +79,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 +97,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 +111,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 +135,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 +174,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 +197,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); }); From 25e86ab4797db5ea887a49e64443adb9c9d445d0 Mon Sep 17 00:00:00 2001 From: "Matheus Henrique(Aka: TheusHen)" Date: Tue, 14 Jul 2026 17:30:11 -0300 Subject: [PATCH 3/8] Complete torrent acquisition settings flow --- .../acquisition/acquisition_api_client.dart | 55 +- app/lib/acquisition/acquisition_models.dart | 73 +- app/lib/auth/auth_repository.dart | 73 +- app/lib/config/app_router.dart | 10 +- app/lib/main.dart | 104 +- app/lib/pages/acquisition_page.dart | 918 ++++++++++++++---- app/lib/pages/profile_page.dart | 35 +- app/lib/providers/auth_provider.dart | 17 +- app/lib/providers/preferences_provider.dart | 27 +- .../acquisition_api_client_test.dart | 69 ++ .../acquisition/acquisition_models_test.dart | 33 + app/test/config/app_router_test.dart | 83 +- app/test/pages/profile_storage_sync_test.dart | 399 +++++--- 13 files changed, 1478 insertions(+), 418 deletions(-) create mode 100644 app/test/acquisition/acquisition_api_client_test.dart diff --git a/app/lib/acquisition/acquisition_api_client.dart b/app/lib/acquisition/acquisition_api_client.dart index dc74865..52ad23f 100644 --- a/app/lib/acquisition/acquisition_api_client.dart +++ b/app/lib/acquisition/acquisition_api_client.dart @@ -8,9 +8,25 @@ 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(); + : _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( @@ -44,6 +60,43 @@ class AcquisitionApiClient { 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, diff --git a/app/lib/acquisition/acquisition_models.dart b/app/lib/acquisition/acquisition_models.dart index bc15689..801530d 100644 --- a/app/lib/acquisition/acquisition_models.dart +++ b/app/lib/acquisition/acquisition_models.dart @@ -4,7 +4,6 @@ enum AcquisitionEndpointKind { deluge, prowlarr, torznab, - newznab, readarr, sonarr, radarr, @@ -12,6 +11,78 @@ enum AcquisitionEndpointKind { 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 { 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 a5b5cff..b07b7c9 100644 --- a/app/lib/config/app_router.dart +++ b/app/lib/config/app_router.dart @@ -25,18 +25,20 @@ 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: '/', @@ -291,6 +293,10 @@ class AppRouter { 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 index 0051919..5802d64 100644 --- a/app/lib/pages/acquisition_page.dart +++ b/app/lib/pages/acquisition_page.dart @@ -1,8 +1,12 @@ 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 { @@ -14,282 +18,724 @@ class AcquisitionPage extends StatefulWidget { 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 initState() { - super.initState(); - _loadEndpoints(); + 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 _client => AcquisitionApiClient( - config: context.read().activeApiConfig, - ); + AcquisitionApiClient get _apiClient => _client!; - String? get _token => context.read().accessToken; + Future _authenticated(Future Function(String accessToken) action) { + return context.read().withFreshAccessToken(action); + } - Future _loadEndpoints() async { - final token = _token; - if (token == null) { - setState(() { - _loading = false; - _error = 'Sign in to manage acquisition integrations.'; - }); + Future _load() async { + if (!context.read().acquisitionEnabled) { + context.go('/profile'); return; } + + setState(() { + _loading = true; + _error = null; + }); + try { - final endpoints = await _client.listEndpoints(token); - if (mounted) setState(() => _endpoints = endpoints); + 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) - setState(() => _error = 'Could not load acquisition integrations.'); + 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 token = _token; final query = _queryController.text.trim(); - if (token == null || query.isEmpty) return; - setState(() => _loading = true); + if (query.isEmpty || _capabilities == null) return; + + setState(() { + _searching = true; + _error = null; + _releases = []; + }); + try { - final releases = await _client.search(accessToken: token, query: query); + 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 the configured indexers.', + 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(() => _loading = false); + 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 _addEndpoint() async { - final name = TextEditingController(); - final url = TextEditingController(); + 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 = AcquisitionEndpointKind.qbittorrent; - final submitted = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Add integration'), - content: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextField( - controller: name, - decoration: const InputDecoration(labelText: 'Name'), - ), - DropdownButtonFormField( - initialValue: kind, - items: AcquisitionEndpointKind.values - .map( - (value) => DropdownMenuItem( - value: value, - child: Text(value.name), - ), - ) - .toList(), - onChanged: (value) => kind = value ?? kind, - decoration: const InputDecoration(labelText: 'Type'), - ), - TextField( - controller: url, - decoration: const InputDecoration(labelText: 'Server URL'), - ), - TextField( - controller: apiKey, - decoration: const InputDecoration( - labelText: 'API key (if applicable)', - ), + 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), + ), + ], ), - TextField( - controller: username, - decoration: const InputDecoration( - labelText: 'Username (if applicable)', - ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), ), - TextField( - controller: password, - obscureText: true, - decoration: const InputDecoration( - labelText: 'Password (if applicable)', - ), + 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: () async { - final token = _token; - final baseUrl = Uri.tryParse(url.text.trim()); - if (token == null || - name.text.trim().isEmpty || - baseUrl == null || - !baseUrl.hasScheme) - return; - try { - await _client.createEndpoint( - accessToken: token, - name: name.text.trim(), - kind: kind, - baseUrl: baseUrl, - apiKey: apiKey.text.trim().isEmpty - ? null - : apiKey.text.trim(), - username: username.text.trim().isEmpty - ? null - : username.text.trim(), - password: password.text.isEmpty ? null : password.text, - ); - 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'), + onPressed: () => Navigator.pop(context, true), + child: const Text('Remove'), ), ], ), ); - name.dispose(); - url.dispose(); - apiKey.dispose(); - username.dispose(); - password.dispose(); - if (submitted == true) _loadEndpoints(); + 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 downloadClients = _endpoints.where( - (endpoint) => const { - AcquisitionEndpointKind.qbittorrent, - AcquisitionEndpointKind.transmission, - AcquisitionEndpointKind.deluge, - }.contains(endpoint.kind), - ); + 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('Acquisition')), - floatingActionButton: FloatingActionButton.extended( - onPressed: _addEndpoint, - icon: const Icon(Icons.add), - label: const Text('Integration'), - ), + 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: _loadEndpoints, + onRefresh: _load, child: ListView( - padding: const EdgeInsets.all(16), + 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( - 'Torrent & automation', - style: Theme.of(context).textTheme.headlineSmall, - ), - const SizedBox(height: 8), - const Text( - 'Configure indexers, download clients, and Readarr or other Arr applications.', + 'Search torrents', + style: Theme.of(context).textTheme.titleMedium, ), - const SizedBox(height: 16), + const SizedBox(height: Spacing.sm), TextField( - controller: _queryController, - onSubmitted: (_) => _search(), + controller: queryController, + enabled: canSearch && !searching, + onSubmitted: (_) => onSearch(), decoration: InputDecoration( - labelText: 'Search releases', - suffixIcon: IconButton( - icon: const Icon(Icons.search), - onPressed: _search, - ), + 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 (_error != null) - Padding( - padding: const EdgeInsets.only(top: 12), + if (!canSearch) + const Padding( + padding: EdgeInsets.only(top: Spacing.sm), child: Text( - _error!, - style: TextStyle(color: Theme.of(context).colorScheme.error), + 'Add an enabled Prowlarr or Torznab indexer first.', ), ), - if (_loading) - const Padding( - padding: EdgeInsets.all(24), - child: Center(child: CircularProgressIndicator()), + ], + ), + ), + ); + } +} + +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), ), - ..._endpoints.map( - (endpoint) => Card( - child: ListTile( - leading: Icon(_iconFor(endpoint.kind)), - title: Text(endpoint.name), - subtitle: Text( - '${endpoint.kind.name} • ${endpoint.baseUrl.host}', - ), - trailing: endpoint.enabled - ? const Icon(Icons.check_circle_outline) - : const Icon(Icons.pause_circle_outline), - ), + ) + else + ...endpoints.map( + (endpoint) => _EndpointTile( + endpoint: endpoint, + onEdit: onEdit, + onDelete: onDelete, ), ), - if (_releases.isNotEmpty) - const Padding( - padding: EdgeInsets.only(top: 20, bottom: 8), - child: Text('Results'), + ], + ), + ); + } +} + +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'), ), - ..._releases.map( - (release) => Card( - child: ListTile( - title: Text(release.title), - subtitle: Text( - '${release.indexer}${release.seeders == null ? '' : ' • ${release.seeders} seeders'}', - ), - trailing: PopupMenuButton( - itemBuilder: (_) => downloadClients - .map( - (client) => PopupMenuItem( - value: client, - child: Text('Send to ${client.name}'), - ), - ) - .toList(), - onSelected: (client) async { - final token = _token; - if (token == null) return; - await _client.submitRelease( - accessToken: token, - endpointId: client.id, - release: release, - ); - if (mounted) - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Release submitted.')), - ); - }, - ), + ) + 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')), + ], + ), ], ), ), @@ -301,8 +747,80 @@ class _AcquisitionPageState extends State { AcquisitionEndpointKind.transmission || AcquisitionEndpointKind.deluge => Icons.downloading_outlined, AcquisitionEndpointKind.prowlarr || - AcquisitionEndpointKind.torznab || - AcquisitionEndpointKind.newznab => Icons.travel_explore, + 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 4d9aeb7..96f1403 100644 --- a/app/lib/pages/profile_page.dart +++ b/app/lib/pages/profile_page.dart @@ -213,6 +213,7 @@ class _ProfilePageState extends State { Widget _buildMobileStorageSyncSection(BuildContext context) { final controller = _storageSyncController(context); + final prefs = context.watch(); if (controller.isGuest) { return Column( @@ -266,10 +267,16 @@ class _ProfilePageState extends State { label: 'Manage servers', onTap: () => _showManageSyncServersSheet(context), ), - SettingsRow( - label: 'Torrent & automation', - onTap: () => context.push('/acquisition'), + 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', @@ -1037,6 +1044,7 @@ 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); @@ -1074,6 +1082,12 @@ class _ProfilePageState extends State { ), ), 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, @@ -1090,14 +1104,15 @@ class _ProfilePageState extends State { icon: const Icon(Icons.dns_outlined, size: IconSizes.small), label: const Text('Manage servers'), ), - OutlinedButton.icon( - onPressed: () => context.push('/acquisition'), - icon: const Icon( - Icons.downloading_outlined, - size: IconSizes.small, + if (prefs.acquisitionEnabled) + OutlinedButton.icon( + onPressed: () => context.push('/acquisition'), + icon: const Icon( + Icons.downloading_outlined, + size: IconSizes.small, + ), + label: const Text('Torrent & automation'), ), - label: const Text('Torrent & automation'), - ), if (controller.hasFailedMediaUploads) OutlinedButton.icon( onPressed: () => _retryFailedMediaUploads(context), diff --git a/app/lib/providers/auth_provider.dart b/app/lib/providers/auth_provider.dart index afd5d7e..0af93c7 100644 --- a/app/lib/providers/auth_provider.dart +++ b/app/lib/providers/auth_provider.dart @@ -25,8 +25,6 @@ class AuthProvider extends ChangeNotifier { bool get isSignedIn => _user != null && _status == AuthStatus.signedIn; - String? get accessToken => _repository.accessToken; - bool get isLoading { return _status == AuthStatus.bootstrapping || _status == AuthStatus.authenticating || @@ -218,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 index 284b7fa..9467421 100644 --- a/app/test/acquisition/acquisition_models_test.dart +++ b/app/test/acquisition/acquisition_models_test.dart @@ -14,4 +14,37 @@ void main() { 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() { From 90041f5cbb68d43724e366d6760a3005cc862f88 Mon Sep 17 00:00:00 2001 From: Eoic Date: Fri, 17 Jul 2026 23:23:19 +0300 Subject: [PATCH 4/8] fix: consume acquisition capability and job outcomes --- .../acquisition/acquisition_api_client.dart | 56 ++++++++---- app/lib/acquisition/acquisition_models.dart | 66 ++++++++++---- .../acquisition_api_client_test.dart | 88 +++++++++++++++++-- .../acquisition/acquisition_models_test.dart | 42 +++++---- 4 files changed, 193 insertions(+), 59 deletions(-) diff --git a/app/lib/acquisition/acquisition_api_client.dart b/app/lib/acquisition/acquisition_api_client.dart index 52ad23f..e65fa89 100644 --- a/app/lib/acquisition/acquisition_api_client.dart +++ b/app/lib/acquisition/acquisition_api_client.dart @@ -29,10 +29,7 @@ class AcquisitionApiClient { } Future> listEndpoints(String accessToken) async { - final response = await _httpClient.get( - config.endpoint('/acquisition/endpoints'), - headers: _headers(accessToken), - ); + final response = await _httpClient.get(config.endpoint('/acquisition/endpoints'), headers: _headers(accessToken)); return _decodeList(response).map(AcquisitionEndpoint.fromJson).toList(); } @@ -85,10 +82,34 @@ class AcquisitionApiClient { return AcquisitionEndpoint.fromJson(_decodeObject(response)); } - Future deleteEndpoint({ + Future testEndpoint({ required String accessToken, - required String endpointId, + String? endpointId, + AcquisitionEndpointKind? kind, + Uri? baseUrl, + String? apiKey, + String? username, + String? password, }) async { + final response = await _httpClient.post( + config.endpoint('/acquisition/endpoints/test'), + headers: _headers(accessToken), + body: jsonEncode({ + if (endpointId != null) 'endpoint_id': endpointId, + if (kind != null) 'kind': kind.apiValue, + if (baseUrl != null) 'base_url': baseUrl.toString(), + if (apiKey != null) 'api_key': apiKey, + if (username != null) 'username': username, + if (password != null) 'password': password, + }), + ); + final result = _decodeObject(response); + if (result['ok'] != true) { + throw const AuthApiException(statusCode: 502, message: 'Connection test returned an invalid response'); + } + } + + Future deleteEndpoint({required String accessToken, required String endpointId}) async { final response = await _httpClient.delete( config.endpoint('/acquisition/endpoints/$endpointId'), headers: _headers(accessToken), @@ -105,15 +126,12 @@ class AcquisitionApiClient { final response = await _httpClient.post( config.endpoint('/acquisition/search'), headers: _headers(accessToken), - body: jsonEncode({ - 'query': query, - if (endpointIds != null) 'endpoint_ids': endpointIds, - }), + body: jsonEncode({'query': query, if (endpointIds != null) 'endpoint_ids': endpointIds}), ); return _decodeList(response).map(TorrentRelease.fromJson).toList(); } - Future submitRelease({ + Future submitRelease({ required String accessToken, required String endpointId, required TorrentRelease release, @@ -131,10 +149,10 @@ class AcquisitionApiClient { if (savePath != null) 'save_path': savePath, }), ); - _decodeObject(response); + return AcquisitionJob.fromJson(_decodeObject(response)); } - Future runArrCommand({ + Future runArrCommand({ required String accessToken, required String endpointId, required String command, @@ -145,7 +163,7 @@ class AcquisitionApiClient { headers: _headers(accessToken), body: jsonEncode({'command': command, 'ids': ids}), ); - _decodeObject(response); + return AcquisitionJob.fromJson(_decodeObject(response)); } Map _headers(String accessToken) => { @@ -155,15 +173,16 @@ class AcquisitionApiClient { }; Map _decodeObject(http.Response response) { - final decoded = response.body.isEmpty - ? {} - : jsonDecode(response.body) as Map; + final decoded = response.body.isEmpty ? {} : jsonDecode(response.body) as Map; if (response.statusCode >= 200 && response.statusCode < 300) return decoded; final error = decoded['error']; + final detail = decoded['detail']; throw AuthApiException( statusCode: response.statusCode, message: error is Map ? error['message'] as String? ?? 'Acquisition request failed' + : detail is String + ? detail : 'Acquisition request failed', ); } @@ -172,7 +191,6 @@ class AcquisitionApiClient { if (response.statusCode < 200 || response.statusCode >= 300) { _decodeObject(response); } - return (jsonDecode(response.body) as List) - .cast>(); + return (jsonDecode(response.body) as List).cast>(); } } diff --git a/app/lib/acquisition/acquisition_models.dart b/app/lib/acquisition/acquisition_models.dart index 801530d..aae6e22 100644 --- a/app/lib/acquisition/acquisition_models.dart +++ b/app/lib/acquisition/acquisition_models.dart @@ -48,6 +48,7 @@ enum AcquisitionEndpointKind { } class AcquisitionCapabilities { + final bool enabled; final List endpointKinds; final List indexerKinds; final List downloadClientKinds; @@ -55,6 +56,7 @@ class AcquisitionCapabilities { final Map> arrCommands; const AcquisitionCapabilities({ + required this.enabled, required this.endpointKinds, required this.indexerKinds, required this.downloadClientKinds, @@ -64,24 +66,19 @@ class AcquisitionCapabilities { factory AcquisitionCapabilities.fromJson(Map json) { return AcquisitionCapabilities( + enabled: json['enabled'] as bool? ?? true, 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(), - ), + (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(); + return ((value as List?) ?? []).cast().map(AcquisitionEndpointKind.values.byName).toList(); } } @@ -100,14 +97,13 @@ class AcquisitionEndpoint { 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, - ); + 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 { @@ -138,3 +134,41 @@ class TorrentRelease { sizeBytes: json['size_bytes'] as int?, ); } + +class AcquisitionJob { + final String id; + final String? endpointId; + final String? ruleId; + final String title; + final String downloadUrl; + final String status; + final String? clientReference; + final String? error; + final DateTime? createdAt; + + const AcquisitionJob({ + required this.id, + required this.endpointId, + required this.ruleId, + required this.title, + required this.downloadUrl, + required this.status, + required this.clientReference, + required this.error, + required this.createdAt, + }); + + bool get isSubmitted => status == 'submitted'; + + factory AcquisitionJob.fromJson(Map json) => AcquisitionJob( + id: json['job_id'] as String, + endpointId: json['endpoint_id'] as String?, + ruleId: json['rule_id'] as String?, + title: json['title'] as String, + downloadUrl: json['download_url'] as String, + status: json['status'] as String, + clientReference: json['client_reference'] as String?, + error: json['error'] as String?, + createdAt: json['created_at'] == null ? null : DateTime.parse(json['created_at'] as String), + ); +} diff --git a/app/test/acquisition/acquisition_api_client_test.dart b/app/test/acquisition/acquisition_api_client_test.dart index a5828b2..9bdb582 100644 --- a/app/test/acquisition/acquisition_api_client_test.dart +++ b/app/test/acquisition/acquisition_api_client_test.dart @@ -17,6 +17,7 @@ void main() { expect(request.headers['authorization'], 'Bearer access-token'); return http.Response( jsonEncode({ + 'enabled': true, 'endpoint_kinds': ['qbittorrent', 'prowlarr', 'readarr'], 'indexer_kinds': ['prowlarr'], 'download_client_kinds': ['qbittorrent'], @@ -33,12 +34,8 @@ void main() { final capabilities = await client.capabilities('access-token'); expect(seenPaths, ['/v1/acquisition/capabilities']); - expect(capabilities.downloadClientKinds, [ - AcquisitionEndpointKind.qbittorrent, - ]); - expect(capabilities.arrCommands[AcquisitionEndpointKind.readarr], [ - 'BookSearch', - ]); + expect(capabilities.downloadClientKinds, [AcquisitionEndpointKind.qbittorrent]); + expect(capabilities.arrCommands[AcquisitionEndpointKind.readarr], ['BookSearch']); }); test('submits a selected release to a torrent client', () async { @@ -48,11 +45,24 @@ void main() { 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); + return http.Response( + jsonEncode({ + 'job_id': 'job-1', + 'endpoint_id': 'client-1', + 'rule_id': null, + 'title': 'Example', + 'download_url': 'magnet:?xt=urn:btih:example', + 'status': 'failed', + 'client_reference': null, + 'error': 'Transmission rejected the release', + 'created_at': null, + }), + 201, + ); }), ); - await client.submitRelease( + final job = await client.submitRelease( accessToken: 'access-token', endpointId: 'client-1', release: const TorrentRelease( @@ -65,5 +75,67 @@ void main() { expect(requestBody['endpoint_id'], 'client-1'); expect(requestBody['download_url'], 'magnet:?xt=urn:btih:example'); + expect(job.isSubmitted, isFalse); + expect(job.error, 'Transmission rejected the release'); + }); + + test('tests an unsaved endpoint without sending irrelevant credentials', () 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/endpoints/test'); + requestBody = jsonDecode(request.body) as Map; + return http.Response(jsonEncode({'ok': true}), 200); + }), + ); + + await client.testEndpoint( + accessToken: 'access-token', + kind: AcquisitionEndpointKind.prowlarr, + baseUrl: Uri.parse('http://prowlarr.local:9696'), + apiKey: 'secret', + ); + + expect(requestBody, {'kind': 'prowlarr', 'base_url': 'http://prowlarr.local:9696', 'api_key': 'secret'}); + }); + + test('tests an edited endpoint with only supplied overrides', () async { + late Map requestBody; + final client = AcquisitionApiClient( + config: PapyrusApiConfig(serverBaseUri: Uri.parse('https://api.test')), + httpClient: MockClient((request) async { + requestBody = jsonDecode(request.body) as Map; + return http.Response(jsonEncode({'ok': true}), 200); + }), + ); + + await client.testEndpoint( + accessToken: 'access-token', + endpointId: 'endpoint-1', + baseUrl: Uri.parse('http://edited.local:9696'), + ); + + expect(requestBody, {'endpoint_id': 'endpoint-1', 'base_url': 'http://edited.local:9696'}); + }); + + test('surfaces FastAPI detail errors from connection tests', () async { + final client = AcquisitionApiClient( + config: PapyrusApiConfig(serverBaseUri: Uri.parse('https://api.test')), + httpClient: MockClient( + (_) async => http.Response(jsonEncode({'detail': 'Prowlarr connection test failed'}), 502), + ), + ); + + await expectLater( + client.testEndpoint( + accessToken: 'access-token', + kind: AcquisitionEndpointKind.prowlarr, + baseUrl: Uri.parse('http://prowlarr.local:9696'), + ), + throwsA( + isA().having((error) => error.toString(), 'message', contains('Prowlarr connection test failed')), + ), + ); }); } diff --git a/app/test/acquisition/acquisition_models_test.dart b/app/test/acquisition/acquisition_models_test.dart index 9467421..4efb204 100644 --- a/app/test/acquisition/acquisition_models_test.dart +++ b/app/test/acquisition/acquisition_models_test.dart @@ -17,14 +17,8 @@ void main() { test('parses torrent-only capabilities', () { final capabilities = AcquisitionCapabilities.fromJson({ - 'endpoint_kinds': [ - 'qbittorrent', - 'transmission', - 'deluge', - 'prowlarr', - 'torznab', - 'readarr', - ], + 'enabled': true, + 'endpoint_kinds': ['qbittorrent', 'transmission', 'deluge', 'prowlarr', 'torznab', 'readarr'], 'indexer_kinds': ['prowlarr', 'torznab'], 'download_client_kinds': ['qbittorrent', 'transmission', 'deluge'], 'arr_kinds': ['readarr'], @@ -33,18 +27,34 @@ void main() { }, }); - expect(capabilities.indexerKinds, [ - AcquisitionEndpointKind.prowlarr, - AcquisitionEndpointKind.torznab, - ]); + expect(capabilities.indexerKinds, [AcquisitionEndpointKind.prowlarr, AcquisitionEndpointKind.torznab]); expect(capabilities.downloadClientKinds, [ AcquisitionEndpointKind.qbittorrent, AcquisitionEndpointKind.transmission, AcquisitionEndpointKind.deluge, ]); - expect(capabilities.arrCommands[AcquisitionEndpointKind.readarr], [ - 'AuthorSearch', - 'BookSearch', - ]); + expect(capabilities.arrCommands[AcquisitionEndpointKind.readarr], ['AuthorSearch', 'BookSearch']); + expect(capabilities.enabled, isTrue); + }); + + test('parses disabled capabilities and failed jobs', () { + final capabilities = AcquisitionCapabilities.fromJson({'enabled': false}); + final job = AcquisitionJob.fromJson({ + 'job_id': 'job-1', + 'endpoint_id': null, + 'rule_id': null, + 'title': 'Release', + 'download_url': 'magnet:?xt=urn:btih:test', + 'status': 'failed', + 'client_reference': null, + 'error': 'Transmission rejected the release', + 'created_at': '2026-07-17T12:00:00Z', + }); + + expect(capabilities.enabled, isFalse); + expect(capabilities.endpointKinds, isEmpty); + expect(job.endpointId, isNull); + expect(job.isSubmitted, isFalse); + expect(job.error, 'Transmission rejected the release'); }); } From 4cf936464f91a5ac3e31cf9d553a7dba7bcefaf3 Mon Sep 17 00:00:00 2001 From: Eoic Date: Fri, 17 Jul 2026 23:29:07 +0300 Subject: [PATCH 5/8] fix: gate acquisition UI by server capability --- app/lib/config/app_router.dart | 119 ++--- app/lib/main.dart | 114 ++-- app/lib/pages/profile_page.dart | 501 ++++-------------- .../acquisition_availability_provider.dart | 113 ++++ ...cquisition_availability_provider_test.dart | 52 ++ app/test/config/app_router_test.dart | 107 ++-- app/test/pages/profile_storage_sync_test.dart | 458 ++++++++-------- 7 files changed, 623 insertions(+), 841 deletions(-) create mode 100644 app/lib/providers/acquisition_availability_provider.dart create mode 100644 app/test/acquisition/acquisition_availability_provider_test.dart diff --git a/app/lib/config/app_router.dart b/app/lib/config/app_router.dart index b07b7c9..d46d1f0 100644 --- a/app/lib/config/app_router.dart +++ b/app/lib/config/app_router.dart @@ -24,21 +24,35 @@ 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/providers/acquisition_availability_provider.dart'; import 'package:papyrus/widgets/shell/adaptive_app_shell.dart'; import 'package:papyrus/providers/preferences_provider.dart'; +import 'package:papyrus/providers/sync_settings_provider.dart'; class AppRouter { final AuthProvider authProvider; final PreferencesProvider preferencesProvider; + final SyncSettingsProvider syncSettingsProvider; + final AcquisitionAvailabilityProvider acquisitionAvailabilityProvider; final rootNavigatorKey = GlobalKey(); final shellNavigatorKey = GlobalKey(); - AppRouter({required this.authProvider, required this.preferencesProvider}); + AppRouter({ + required this.authProvider, + required this.preferencesProvider, + required this.syncSettingsProvider, + required this.acquisitionAvailabilityProvider, + }); late final GoRouter router = GoRouter( debugLogDiagnostics: true, navigatorKey: rootNavigatorKey, - refreshListenable: Listenable.merge([authProvider, preferencesProvider]), + refreshListenable: Listenable.merge([ + authProvider, + preferencesProvider, + syncSettingsProvider, + acquisitionAvailabilityProvider, + ]), routes: [ GoRoute( path: '/', @@ -49,34 +63,24 @@ 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( @@ -100,40 +104,26 @@ 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', @@ -151,34 +141,22 @@ 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', @@ -208,42 +186,29 @@ 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(), - ), + 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()), ), ], ), @@ -252,10 +217,8 @@ 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()), ), ], ), @@ -286,14 +249,14 @@ 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) { + final acquisitionAvailable = acquisitionAvailabilityProvider.isAvailableFor( + syncSettingsProvider.activeApiConfig.serverBaseUri, + ); + if (location == '/acquisition' && (!preferencesProvider.acquisitionEnabled || !acquisitionAvailable)) { return '/profile'; } diff --git a/app/lib/main.dart b/app/lib/main.dart index b8dbf31..e84c2e2 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -18,6 +18,7 @@ import 'package:papyrus/powersync/papyrus_powersync_connector.dart'; import 'package:papyrus/powersync/sync_profile_switch_queue.dart'; import 'package:papyrus/powersync/sync_state.dart'; import 'package:papyrus/providers/auth_provider.dart'; +import 'package:papyrus/providers/acquisition_availability_provider.dart'; import 'package:papyrus/providers/library_provider.dart'; import 'package:papyrus/providers/preferences_provider.dart'; import 'package:papyrus/providers/sync_settings_provider.dart'; @@ -52,6 +53,7 @@ class Papyrus extends StatefulWidget { class _PapyrusState extends State { late final DataStore _dataStore; late final AuthProvider _authProvider; + late final AcquisitionAvailabilityProvider _acquisitionAvailabilityProvider; late final PreferencesProvider _preferencesProvider; late final SyncSettingsProvider _syncSettingsProvider; late final MediaUploadQueue _mediaUploadQueue; @@ -71,36 +73,24 @@ 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(); _preferencesProvider = PreferencesProvider(widget.prefs); - _mediaUploadQueue = MediaUploadQueue( - widget.prefs, - onWorkAvailable: _processMediaUploads, - ); + _mediaUploadQueue = MediaUploadQueue(widget.prefs, onWorkAvailable: _processMediaUploads); _bookImportService = BookImportService(); _authProvider = AuthProvider(widget.prefs, repository: _authRepository); + _acquisitionAvailabilityProvider = AcquisitionAvailabilityProvider(authProvider: _authProvider); _powerSyncService = PapyrusPowerSyncService( connectorFactory: () => PapyrusPowerSyncConnector( authRepository: _authRepository, @@ -113,28 +103,31 @@ class _PapyrusState extends State { _appRouter = AppRouter( authProvider: _authProvider, preferencesProvider: _preferencesProvider, + syncSettingsProvider: _syncSettingsProvider, + acquisitionAvailabilityProvider: _acquisitionAvailabilityProvider, ); _authProvider.addListener(_syncPowerSyncAuthState); + _authProvider.addListener(_syncAcquisitionAvailability); _syncSettingsProvider.addListener(_handleSyncSettingsChanged); _syncPowerSyncAuthState(); + _syncAcquisitionAvailability(); } @override void dispose() { _authProvider.removeListener(_syncPowerSyncAuthState); + _authProvider.removeListener(_syncAcquisitionAvailability); _syncSettingsProvider.removeListener(_handleSyncSettingsChanged); unawaited(_disposeDataServices()); _bookImportService.dispose(); + _acquisitionAvailabilityProvider.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), @@ -160,11 +153,7 @@ 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'), ); }, ); @@ -181,13 +170,8 @@ 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; @@ -201,9 +185,7 @@ class _PapyrusState extends State { await _mediaUploadQueue.activateScope(null); if (!_authProvider.isBootstrapping && _powerSyncService.mode != null) { - await _powerSyncService.deactivate( - clearAuthenticated: !_switchingSyncProfile, - ); + await _powerSyncService.deactivate(clearAuthenticated: !_switchingSyncProfile); } } @@ -217,18 +199,22 @@ class _PapyrusState extends State { } void _handleSyncSettingsChanged() { + _acquisitionAvailabilityProvider.clear(); 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 { + void _syncAcquisitionAvailability() { + if (!_authProvider.isSignedIn || _authProvider.isOfflineMode) { + _acquisitionAvailabilityProvider.clear(); + return; + } + + unawaited(_acquisitionAvailabilityProvider.refresh(_syncSettingsProvider.activeApiConfig.serverBaseUri)); + } + + Future _switchActiveSyncProfile(String nextProfileKey, PapyrusApiConfig nextConfig) async { _switchingSyncProfile = true; try { await _mediaUploadQueue.waitUntilIdle(); @@ -236,10 +222,7 @@ 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; @@ -258,24 +241,16 @@ 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( @@ -298,17 +273,12 @@ 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(); } } @@ -325,12 +295,10 @@ 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.value(value: _acquisitionAvailabilityProvider), ChangeNotifierProvider(create: (_) => SidebarProvider()), ChangeNotifierProvider(create: (_) => LibraryProvider()), ChangeNotifierProvider.value(value: _preferencesProvider), @@ -352,8 +320,6 @@ 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/profile_page.dart b/app/lib/pages/profile_page.dart index 96f1403..a9d1301 100644 --- a/app/lib/pages/profile_page.dart +++ b/app/lib/pages/profile_page.dart @@ -8,6 +8,7 @@ import 'package:papyrus/media/media_upload_queue.dart'; import 'package:papyrus/powersync/powersync_service.dart'; import 'package:papyrus/powersync/storage_sync_controller.dart'; import 'package:papyrus/providers/auth_provider.dart'; +import 'package:papyrus/providers/acquisition_availability_provider.dart'; import 'package:papyrus/providers/preferences_provider.dart'; import 'package:papyrus/providers/sync_settings_provider.dart'; import 'package:papyrus/powersync/sync_state.dart'; @@ -113,11 +114,7 @@ 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)), ], ); } @@ -129,11 +126,7 @@ 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), @@ -214,6 +207,7 @@ class _ProfilePageState extends State { Widget _buildMobileStorageSyncSection(BuildContext context) { final controller = _storageSyncController(context); final prefs = context.watch(); + final acquisitionAvailable = _acquisitionAvailable(context); if (controller.isGuest) { return Column( @@ -222,15 +216,12 @@ 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( @@ -238,10 +229,7 @@ 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)), ], ); } @@ -263,35 +251,19 @@ class _ProfilePageState extends State { value: controller.failedMediaUploadLabel, onTap: () => _retryFailedMediaUploads(context), ), - SettingsRow( - label: 'Manage servers', - onTap: () => _showManageSyncServersSheet(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 (prefs.acquisitionEnabled && acquisitionAvailable) + 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)), ], ); } @@ -451,12 +423,7 @@ 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), @@ -499,9 +466,7 @@ 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), @@ -518,10 +483,7 @@ 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), @@ -531,9 +493,7 @@ class _ProfilePageState extends State { label, style: textTheme.bodyMedium?.copyWith( color: textColor, - fontWeight: isSelected - ? FontWeight.w600 - : FontWeight.normal, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, ), ), ), @@ -642,12 +602,7 @@ 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, @@ -676,11 +631,7 @@ 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), @@ -705,12 +656,7 @@ 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'), @@ -736,9 +682,7 @@ 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, ), ), @@ -747,10 +691,7 @@ 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, @@ -792,12 +733,7 @@ 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( @@ -809,13 +745,7 @@ 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), @@ -823,11 +753,7 @@ 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), @@ -843,11 +769,7 @@ 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, ), ], @@ -860,10 +782,7 @@ 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), @@ -875,10 +794,7 @@ 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: () {})], @@ -903,12 +819,7 @@ 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) { @@ -927,9 +838,7 @@ 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, ), ), ); @@ -954,11 +863,7 @@ 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), @@ -966,13 +871,7 @@ 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', @@ -992,10 +891,7 @@ 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), @@ -1045,41 +941,22 @@ class _ProfilePageState extends State { final textTheme = Theme.of(context).textTheme; final controller = _storageSyncController(context); final prefs = context.watch(); + final acquisitionAvailable = _acquisitionAvailable(context); 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( @@ -1104,13 +981,10 @@ class _ProfilePageState extends State { icon: const Icon(Icons.dns_outlined, size: IconSizes.small), label: const Text('Manage servers'), ), - if (prefs.acquisitionEnabled) + if (prefs.acquisitionEnabled && acquisitionAvailable) OutlinedButton.icon( onPressed: () => context.push('/acquisition'), - icon: const Icon( - Icons.downloading_outlined, - size: IconSizes.small, - ), + icon: const Icon(Icons.downloading_outlined, size: IconSizes.small), label: const Text('Torrent & automation'), ), if (controller.hasFailedMediaUploads) @@ -1122,10 +996,7 @@ 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'), ), ], @@ -1136,9 +1007,7 @@ 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', @@ -1148,10 +1017,7 @@ 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.', @@ -1167,18 +1033,12 @@ 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( @@ -1200,49 +1060,35 @@ class _ProfilePageState extends State { syncState: context.watch(), fileStorageUsedBytes: _fileStorageUsedBytes(context.watch()), mediaStorageUsage: context.watch().storageUsage, - failedMediaUploadCount: _failedMediaUploadCount( - context.watch(), - ), + failedMediaUploadCount: _failedMediaUploadCount(context.watch()), ); } + bool _acquisitionAvailable(BuildContext context) { + final serverBaseUri = context.watch().activeApiConfig.serverBaseUri; + return context.watch().isAvailableFor(serverBaseUri); + } + 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)), ], @@ -1271,9 +1117,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), ), ), ], @@ -1309,17 +1155,13 @@ 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( @@ -1328,16 +1170,12 @@ 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), ), ), ], @@ -1427,10 +1265,7 @@ 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); @@ -1448,11 +1283,7 @@ 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) { @@ -1465,41 +1296,26 @@ 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), @@ -1511,9 +1327,7 @@ 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); } @@ -1540,10 +1354,7 @@ 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); @@ -1552,9 +1363,7 @@ 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'), @@ -1562,26 +1371,18 @@ 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'), @@ -1598,24 +1399,16 @@ 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) { @@ -1650,9 +1443,7 @@ 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 { @@ -1668,13 +1459,9 @@ 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'))); } } @@ -1697,13 +1484,9 @@ 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'))); } } @@ -1719,14 +1502,8 @@ 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)), ], ), ) ?? @@ -1751,12 +1528,7 @@ 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, @@ -1786,21 +1558,13 @@ 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) { @@ -1852,12 +1616,7 @@ 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, ); @@ -1887,11 +1646,7 @@ 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, ); @@ -1941,10 +1696,7 @@ 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, ); @@ -1955,12 +1707,7 @@ 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, ); @@ -2023,26 +1770,17 @@ 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')), ), ], ); @@ -2056,10 +1794,7 @@ 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( @@ -2078,10 +1813,7 @@ 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), ), ), ); @@ -2097,9 +1829,7 @@ 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( @@ -2108,10 +1838,7 @@ 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( @@ -2127,17 +1854,9 @@ 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/acquisition_availability_provider.dart b/app/lib/providers/acquisition_availability_provider.dart new file mode 100644 index 0000000..2a16c7a --- /dev/null +++ b/app/lib/providers/acquisition_availability_provider.dart @@ -0,0 +1,113 @@ +import 'package:flutter/foundation.dart'; +import 'package:papyrus/acquisition/acquisition_api_client.dart'; +import 'package:papyrus/acquisition/acquisition_models.dart'; +import 'package:papyrus/auth/papyrus_api_config.dart'; +import 'package:papyrus/providers/auth_provider.dart'; + +enum AcquisitionAvailabilityState { unknown, loading, available, unavailable } + +typedef AcquisitionCapabilitiesLoader = Future Function(Uri serverBaseUri); + +class AcquisitionAvailabilityProvider extends ChangeNotifier { + final AuthProvider? _authProvider; + final AcquisitionCapabilitiesLoader? _loadCapabilities; + + AcquisitionApiClient? _apiClient; + Uri? _serverBaseUri; + Future? _refreshOperation; + int _generation = 0; + AcquisitionAvailabilityState _state = AcquisitionAvailabilityState.unknown; + + AcquisitionAvailabilityProvider({AuthProvider? authProvider, AcquisitionCapabilitiesLoader? loadCapabilities}) + : assert((authProvider == null) != (loadCapabilities == null), 'Provide either authProvider or loadCapabilities'), + _authProvider = authProvider, + _loadCapabilities = loadCapabilities; + + AcquisitionAvailabilityState get state => _state; + + bool isAvailableFor(Uri serverBaseUri) { + return _serverBaseUri == serverBaseUri && _state == AcquisitionAvailabilityState.available; + } + + Future refresh(Uri serverBaseUri, {bool force = false}) { + if (_serverBaseUri == serverBaseUri) { + if (_state == AcquisitionAvailabilityState.loading) { + return _refreshOperation ?? Future.value(); + } + if (!force && _state != AcquisitionAvailabilityState.unknown) { + return Future.value(); + } + } + + if (_serverBaseUri != serverBaseUri) { + _replaceServer(serverBaseUri); + } + + final generation = ++_generation; + _state = AcquisitionAvailabilityState.loading; + notifyListeners(); + + final operation = _load(serverBaseUri, generation); + _refreshOperation = operation; + return operation.whenComplete(() { + if (identical(_refreshOperation, operation)) { + _refreshOperation = null; + } + }); + } + + void clear() { + _generation += 1; + _apiClient?.close(); + _apiClient = null; + _serverBaseUri = null; + _refreshOperation = null; + + if (_state != AcquisitionAvailabilityState.unknown) { + _state = AcquisitionAvailabilityState.unknown; + notifyListeners(); + } + } + + Future _load(Uri serverBaseUri, int generation) async { + try { + final capabilities = await (_loadCapabilities != null + ? _loadCapabilities(serverBaseUri) + : _loadFromServer(serverBaseUri)); + if (generation != _generation || _serverBaseUri != serverBaseUri) { + return; + } + + _state = capabilities.enabled ? AcquisitionAvailabilityState.available : AcquisitionAvailabilityState.unavailable; + } catch (_) { + if (generation != _generation || _serverBaseUri != serverBaseUri) { + return; + } + + _state = AcquisitionAvailabilityState.unavailable; + } + + notifyListeners(); + } + + Future _loadFromServer(Uri serverBaseUri) { + final apiClient = _apiClient ??= AcquisitionApiClient(config: PapyrusApiConfig(serverBaseUri: serverBaseUri)); + return _authProvider!.withFreshAccessToken(apiClient.capabilities); + } + + void _replaceServer(Uri serverBaseUri) { + _generation += 1; + _apiClient?.close(); + _apiClient = null; + _serverBaseUri = serverBaseUri; + _refreshOperation = null; + _state = AcquisitionAvailabilityState.unknown; + } + + @override + void dispose() { + _generation += 1; + _apiClient?.close(); + super.dispose(); + } +} diff --git a/app/test/acquisition/acquisition_availability_provider_test.dart b/app/test/acquisition/acquisition_availability_provider_test.dart new file mode 100644 index 0000000..75ab5a5 --- /dev/null +++ b/app/test/acquisition/acquisition_availability_provider_test.dart @@ -0,0 +1,52 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/acquisition/acquisition_models.dart'; +import 'package:papyrus/providers/acquisition_availability_provider.dart'; + +void main() { + test('loads and caches enabled capability state by server', () async { + final calls = []; + final provider = AcquisitionAvailabilityProvider( + loadCapabilities: (serverBaseUri) async { + calls.add(serverBaseUri); + return const AcquisitionCapabilities( + enabled: true, + endpointKinds: [], + indexerKinds: [], + downloadClientKinds: [], + arrKinds: [], + arrCommands: {}, + ); + }, + ); + final server = Uri.parse('https://api.test'); + + await provider.refresh(server); + await provider.refresh(server); + + expect(provider.state, AcquisitionAvailabilityState.available); + expect(provider.isAvailableFor(server), isTrue); + expect(calls, [server]); + }); + + test('treats disabled, failed, and unknown servers as unavailable', () async { + final provider = AcquisitionAvailabilityProvider( + loadCapabilities: (_) async => const AcquisitionCapabilities( + enabled: false, + endpointKinds: [], + indexerKinds: [], + downloadClientKinds: [], + arrKinds: [], + arrCommands: {}, + ), + ); + final server = Uri.parse('https://api.test'); + + expect(provider.isAvailableFor(server), isFalse); + + await provider.refresh(server); + + expect(provider.state, AcquisitionAvailabilityState.unavailable); + expect(provider.isAvailableFor(server), isFalse); + expect(provider.isAvailableFor(Uri.parse('https://another.test')), isFalse); + }); +} diff --git a/app/test/config/app_router_test.dart b/app/test/config/app_router_test.dart index e038228..c9a1c12 100644 --- a/app/test/config/app_router_test.dart +++ b/app/test/config/app_router_test.dart @@ -1,12 +1,15 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/acquisition/acquisition_models.dart'; import 'package:papyrus/auth/auth_api_client.dart'; import 'package:papyrus/auth/auth_models.dart'; import 'package:papyrus/auth/auth_repository.dart'; import 'package:papyrus/auth/papyrus_api_config.dart'; import 'package:papyrus/auth/token_store.dart'; import 'package:papyrus/config/app_router.dart'; +import 'package:papyrus/providers/acquisition_availability_provider.dart'; import 'package:papyrus/providers/auth_provider.dart'; import 'package:papyrus/providers/preferences_provider.dart'; +import 'package:papyrus/providers/sync_settings_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; class MemoryRefreshTokenStorage implements RefreshTokenStorage { @@ -29,11 +32,7 @@ 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()), ); @@ -52,18 +51,11 @@ 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, - preferencesProvider: PreferencesProvider(prefs), - ); + final appRouter = await _buildRouter(authProvider: provider, prefs: prefs); expect(appRouter.redirectForPath('/library/books'), '/'); expect(appRouter.redirectForPath('/login'), isNull); @@ -73,18 +65,11 @@ 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, - preferencesProvider: PreferencesProvider(prefs), - ); + final appRouter = await _buildRouter(authProvider: provider, prefs: prefs); expect(appRouter.redirectForPath('/login'), '/library/books'); expect(appRouter.redirectForPath('/reset-password'), '/library/books'); @@ -93,69 +78,85 @@ 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, - preferencesProvider: PreferencesProvider(prefs), - ); + final appRouter = await _buildRouter(authProvider: provider, prefs: 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, - preferencesProvider: PreferencesProvider(prefs), - ); + final provider = AuthProvider(prefs, repository: FakeAuthRepository(), bootstrapOnCreate: false); + final appRouter = await _buildRouter(authProvider: provider, prefs: prefs); - expect( - appRouter.router.namedLocation( - 'BOOK_EDIT', - pathParameters: {'bookId': 'book-1'}, - ), - '/library/edit/book-1', - ); + 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 provider = AuthProvider(prefs, repository: repository, bootstrapOnCreate: false); final preferences = PreferencesProvider(prefs); + final syncSettings = _syncSettings(prefs); + final availability = AcquisitionAvailabilityProvider( + loadCapabilities: (_) async => const AcquisitionCapabilities( + enabled: true, + endpointKinds: [], + indexerKinds: [], + downloadClientKinds: [], + arrKinds: [], + arrCommands: {}, + ), + ); await provider.bootstrap(); final appRouter = AppRouter( authProvider: provider, preferencesProvider: preferences, + syncSettingsProvider: syncSettings, + acquisitionAvailabilityProvider: availability, ); expect(appRouter.redirectForPath('/acquisition'), '/profile'); preferences.acquisitionEnabled = true; + expect(appRouter.redirectForPath('/acquisition'), '/profile'); + + await availability.refresh(syncSettings.activeApiConfig.serverBaseUri); + expect(appRouter.redirectForPath('/acquisition'), isNull); }); } +Future _buildRouter({required AuthProvider authProvider, required SharedPreferences prefs}) async { + final syncSettings = _syncSettings(prefs); + return AppRouter( + authProvider: authProvider, + preferencesProvider: PreferencesProvider(prefs), + syncSettingsProvider: syncSettings, + acquisitionAvailabilityProvider: AcquisitionAvailabilityProvider( + loadCapabilities: (_) async => const AcquisitionCapabilities( + enabled: false, + endpointKinds: [], + indexerKinds: [], + downloadClientKinds: [], + arrKinds: [], + arrCommands: {}, + ), + ), + ); +} + +SyncSettingsProvider _syncSettings(SharedPreferences prefs) { + return SyncSettingsProvider(prefs, officialConfig: PapyrusApiConfig(serverBaseUri: Uri.parse('http://server.test'))); +} + AuthTokens _tokens() { return AuthTokens( accessToken: 'access-token', diff --git a/app/test/pages/profile_storage_sync_test.dart b/app/test/pages/profile_storage_sync_test.dart index 66aab8c..2560b81 100644 --- a/app/test/pages/profile_storage_sync_test.dart +++ b/app/test/pages/profile_storage_sync_test.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/acquisition/acquisition_models.dart'; import 'package:papyrus/auth/auth_api_client.dart'; import 'package:papyrus/auth/auth_models.dart'; import 'package:papyrus/auth/auth_repository.dart'; @@ -13,6 +14,7 @@ import 'package:papyrus/pages/profile_page.dart'; import 'package:papyrus/powersync/powersync_service.dart'; import 'package:papyrus/powersync/sync_state.dart'; import 'package:papyrus/providers/auth_provider.dart'; +import 'package:papyrus/providers/acquisition_availability_provider.dart'; import 'package:papyrus/providers/preferences_provider.dart'; import 'package:papyrus/providers/sync_settings_provider.dart'; import 'package:powersync/powersync.dart'; @@ -39,11 +41,7 @@ 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()), ); @@ -67,13 +65,8 @@ 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; @@ -111,20 +104,13 @@ 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); @@ -139,6 +125,7 @@ void main() { SyncSettingsProvider? syncSettingsProvider, DataStore? dataStore, MediaUploadQueue? mediaUploadQueue, + AcquisitionAvailabilityProvider? acquisitionAvailabilityProvider, }) async { final prefs = await SharedPreferences.getInstance(); final config = PapyrusApiConfig( @@ -148,26 +135,29 @@ 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.value( + value: + acquisitionAvailabilityProvider ?? + AcquisitionAvailabilityProvider( + loadCapabilities: (_) async => const AcquisitionCapabilities( + enabled: false, + endpointKinds: [], + indexerKinds: [], + downloadClientKinds: [], + arrKinds: [], + arrCommands: {}, + ), + ), ), + ChangeNotifierProvider(create: (_) => PreferencesProvider(prefs)), ], child: MaterialApp( home: MediaQuery( @@ -178,229 +168,213 @@ 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(); - - 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); - }, - ); + await tester.pumpWidget(await buildPage(authProvider: auth, powerSyncService: service)); + await tester.pumpAndSettle(); + await tester.scrollUntilVisible(find.text('Storage'), 400); + await tester.pumpAndSettle(); - 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(), - ); + 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); + }); - 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 profile hides acquisition management when server is unavailable', (tester) async { + final auth = await buildAuthProvider(signedIn: true); + final service = _FakePowerSyncService( + currentMode: LibraryDatabaseMode.authenticated, + currentSyncState: const SyncState(connected: true), + ); + final availability = AcquisitionAvailabilityProvider( + loadCapabilities: (_) async => const AcquisitionCapabilities( + enabled: false, + endpointKinds: [], + indexerKinds: [], + downloadClientKinds: [], + arrKinds: [], + arrCommands: {}, + ), + ); + await availability.refresh(Uri.parse('https://api.test')); - 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, acquisitionAvailabilityProvider: availability), + ); + await tester.pumpAndSettle(); + await tester.scrollUntilVisible(find.text('Storage'), 400); + await tester.pumpAndSettle(); - 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); - }, - ); + expect(find.text('Torrent acquisition'), 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), - ); + await tester.tap(find.text('Torrent acquisition')); + await tester.pumpAndSettle(); - 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); - }, - ); + expect(find.text('Torrent & automation'), findsNothing); + }); + + testWidgets('offline desktop storage sync is local-first and hides sync internals', (tester) async { + final auth = await buildAuthProvider(guest: true); + final service = _FakePowerSyncService(currentMode: LibraryDatabaseMode.guest, currentSyncState: const SyncState()); + + await tester.pumpWidget( + await buildPage(authProvider: auth, powerSyncService: service, screenSize: const Size(1200, 900)), + ); + await tester.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('storage sync UI shows pending writes and sync errors', ( - tester, - ) async { + 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: const SyncState( - connected: true, - hasPendingWrites: true, - uploadError: 'upload failed', + currentSyncState: SyncState(connected: true, lastSyncedAt: DateTime.utc(2026, 6, 27, 10, 30)), + ); + final availability = AcquisitionAvailabilityProvider( + loadCapabilities: (_) async => const AcquisitionCapabilities( + enabled: true, + endpointKinds: [], + indexerKinds: [], + downloadClientKinds: [], + arrKinds: [], + arrCommands: {}, ), ); + await availability.refresh(Uri.parse('https://api.test')); await tester.pumpWidget( await buildPage( authProvider: auth, powerSyncService: service, screenSize: const Size(1200, 900), + dataStore: dataStore, + acquisitionAvailabilityProvider: availability, + ), + ); + 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), + ); + + 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); await tester.pumpAndSettle(); @@ -418,13 +392,7 @@ 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() { From 122be953c81d5419a0be0e814078526fa90060ec Mon Sep 17 00:00:00 2001 From: Eoic Date: Fri, 17 Jul 2026 23:33:02 +0300 Subject: [PATCH 6/8] feat: test acquisition integrations from the client --- app/lib/pages/acquisition_page.dart | 633 ++++++++++++---------- app/test/pages/acquisition_page_test.dart | 187 +++++++ 2 files changed, 529 insertions(+), 291 deletions(-) create mode 100644 app/test/pages/acquisition_page_test.dart diff --git a/app/lib/pages/acquisition_page.dart b/app/lib/pages/acquisition_page.dart index 5802d64..4c210da 100644 --- a/app/lib/pages/acquisition_page.dart +++ b/app/lib/pages/acquisition_page.dart @@ -3,14 +3,19 @@ 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/auth/papyrus_api_config.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'; +typedef AcquisitionApiClientFactory = AcquisitionApiClient Function(PapyrusApiConfig config); + class AcquisitionPage extends StatefulWidget { - const AcquisitionPage({super.key}); + final AcquisitionApiClientFactory? clientFactory; + + const AcquisitionPage({super.key, this.clientFactory}); @override State createState() => _AcquisitionPageState(); @@ -34,7 +39,7 @@ class _AcquisitionPageState extends State { final config = context.read().activeApiConfig; if (_clientBaseUri == config.serverBaseUri) return; _client?.close(); - _client = AcquisitionApiClient(config: config); + _client = widget.clientFactory?.call(config) ?? AcquisitionApiClient(config: config); _clientBaseUri = config.serverBaseUri; _load(); } @@ -77,8 +82,7 @@ class _AcquisitionPageState extends State { } catch (_) { if (!mounted) return; setState(() { - _error = - 'This Papyrus server does not expose the torrent acquisition API.'; + _error = 'This Papyrus server does not expose the torrent acquisition API.'; }); } finally { if (mounted) setState(() => _loading = false); @@ -101,11 +105,7 @@ class _AcquisitionPageState extends State { .map((endpoint) => endpoint.id) .toList(); final releases = await _authenticated((token) { - return _apiClient.search( - accessToken: token, - query: query, - endpointIds: indexerIds.isEmpty ? null : indexerIds, - ); + return _apiClient.search(accessToken: token, query: query, endpointIds: indexerIds.isEmpty ? null : indexerIds); }); if (mounted) setState(() => _releases = releases); } on AuthApiException catch (error) { @@ -119,29 +119,18 @@ class _AcquisitionPageState extends State { } } - Future _submitRelease( - TorrentRelease release, - AcquisitionEndpoint client, - ) async { + Future _submitRelease(TorrentRelease release, AcquisitionEndpoint client) async { setState(() => _submitting = true); try { await _authenticated((token) { - return _apiClient.submitRelease( - accessToken: token, - endpointId: client.id, - release: release, - ); + return _apiClient.submitRelease(accessToken: token, endpointId: client.id, release: release); }); if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('Sent to ${client.name}.'))); + 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.')), - ); + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Could not submit this release.'))); } } finally { if (mounted) setState(() => _submitting = false); @@ -162,43 +151,28 @@ class _AcquisitionPageState extends State { setState(() => _submitting = true); try { await _authenticated((token) { - return _apiClient.runArrCommand( - accessToken: token, - endpointId: endpoint.id, - command: command, - ids: ids, - ); + 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}.')), - ); + 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.')), - ); + 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, - ) { + 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), - ), + ListTile(title: Text(endpoint.name), subtitle: Text(endpoint.kind.label)), ...commands.map( (command) => ListTile( leading: const Icon(Icons.play_arrow_outlined), @@ -230,10 +204,7 @@ class _AcquisitionPageState extends State { autofocus: true, ), actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Cancel'), - ), + TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')), FilledButton( onPressed: () { final ids = controller.text @@ -255,137 +226,57 @@ class _AcquisitionPageState extends State { Future _showEndpointDialog({AcquisitionEndpoint? endpoint}) async { final capabilities = _capabilities; - if (capabilities == null) return; + if (capabilities == null || capabilities.endpointKinds.isEmpty) 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; + final saved = await showDialog( + context: context, + builder: (context) => _EndpointDialog( + endpoint: endpoint, + endpointKinds: capabilities.endpointKinds, + onTest: ({required kind, required baseUrl, apiKey, username, password}) { + return _authenticated((token) { + return _apiClient.testEndpoint( + accessToken: token, + endpointId: endpoint?.id, + kind: endpoint == null ? kind : null, + baseUrl: baseUrl, + apiKey: apiKey, + username: username, + password: password, + ); + }); + }, + onSave: ({required name, required kind, required baseUrl, required enabled, apiKey, username, password}) async { + await _authenticated((token) async { + if (endpoint == null) { + await _apiClient.createEndpoint( + accessToken: token, + name: name, + kind: kind, + baseUrl: baseUrl, + apiKey: apiKey, + username: username, + password: password, + ); + return; + } + + await _apiClient.updateEndpoint( + accessToken: token, + endpointId: endpoint.id, + name: name, + baseUrl: baseUrl, + apiKey: apiKey, + username: username, + password: password, + enabled: enabled, + ); + }); + }, + ), + ); - 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(); - } + if (saved == true) await _load(); } Future _deleteEndpoint(AcquisitionEndpoint endpoint) async { @@ -393,18 +284,10 @@ class _AcquisitionPageState extends State { context: context, builder: (context) => AlertDialog( title: Text('Remove ${endpoint.name}?'), - content: const Text( - 'Saved credentials for this integration will be removed.', - ), + 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'), - ), + TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')), + FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text('Remove')), ], ), ); @@ -412,17 +295,12 @@ class _AcquisitionPageState extends State { try { await _authenticated((token) { - return _apiClient.deleteEndpoint( - accessToken: token, - endpointId: endpoint.id, - ); + 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.')), - ); + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Could not remove this integration.'))); } } } @@ -430,15 +308,9 @@ class _AcquisitionPageState extends State { @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(); + 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')), @@ -475,9 +347,7 @@ class _AcquisitionPageState extends State { _EndpointSection( title: 'Download clients', emptyLabel: 'No download clients configured', - endpoints: _endpoints - .where((endpoint) => endpoint.kind.isDownloadClient) - .toList(), + endpoints: _endpoints.where((endpoint) => endpoint.kind.isDownloadClient).toList(), onEdit: (endpoint) => _showEndpointDialog(endpoint: endpoint), onDelete: _deleteEndpoint, ), @@ -492,11 +362,7 @@ class _AcquisitionPageState extends State { Text('Results', style: Theme.of(context).textTheme.titleMedium), const SizedBox(height: Spacing.sm), ..._releases.map( - (release) => _ReleaseTile( - release: release, - clients: clients, - onSubmit: _submitRelease, - ), + (release) => _ReleaseTile(release: release, clients: clients, onSubmit: _submitRelease), ), ] else if (!_searching && _queryController.text.trim().isNotEmpty) const Padding( @@ -517,11 +383,6 @@ class _AcquisitionPageState extends State { 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', @@ -537,6 +398,251 @@ class _AcquisitionPageState extends State { }; } +typedef _EndpointTestCallback = + Future Function({ + required AcquisitionEndpointKind kind, + required Uri baseUrl, + String? apiKey, + String? username, + String? password, + }); + +typedef _EndpointSaveCallback = + Future Function({ + required String name, + required AcquisitionEndpointKind kind, + required Uri baseUrl, + required bool enabled, + String? apiKey, + String? username, + String? password, + }); + +class _EndpointDialog extends StatefulWidget { + const _EndpointDialog({ + required this.endpoint, + required this.endpointKinds, + required this.onTest, + required this.onSave, + }); + + final AcquisitionEndpoint? endpoint; + final List endpointKinds; + final _EndpointTestCallback onTest; + final _EndpointSaveCallback onSave; + + @override + State<_EndpointDialog> createState() => _EndpointDialogState(); +} + +class _EndpointDialogState extends State<_EndpointDialog> { + late final TextEditingController _nameController; + late final TextEditingController _urlController; + final _apiKeyController = TextEditingController(); + final _usernameController = TextEditingController(); + final _passwordController = TextEditingController(); + late AcquisitionEndpointKind _kind; + late bool _enabled; + bool _testing = false; + bool _saving = false; + String? _message; + bool _messageIsError = false; + + bool get _busy => _testing || _saving; + + bool get _usesApiKey => _kind.isIndexer || _kind.isArr; + + bool get _usesUsername => + _kind == AcquisitionEndpointKind.qbittorrent || _kind == AcquisitionEndpointKind.transmission; + + bool get _usesPassword => _usesUsername || _kind == AcquisitionEndpointKind.deluge; + + @override + void initState() { + super.initState(); + _nameController = TextEditingController(text: widget.endpoint?.name ?? ''); + _urlController = TextEditingController(text: widget.endpoint?.baseUrl.toString() ?? ''); + _kind = widget.endpoint?.kind ?? widget.endpointKinds.first; + _enabled = widget.endpoint?.enabled ?? true; + } + + @override + void dispose() { + _nameController.dispose(); + _urlController.dispose(); + _apiKeyController.dispose(); + _usernameController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return AlertDialog( + title: Text(widget.endpoint == null ? 'Add integration' : 'Edit integration'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: _nameController, + enabled: !_busy, + decoration: const InputDecoration(labelText: 'Name'), + ), + DropdownButtonFormField( + initialValue: _kind, + items: widget.endpointKinds + .map((value) => DropdownMenuItem(value: value, child: Text(value.label))) + .toList(), + onChanged: widget.endpoint == null && !_busy ? (value) => setState(() => _kind = value ?? _kind) : null, + decoration: const InputDecoration(labelText: 'Type'), + ), + TextField( + controller: _urlController, + enabled: !_busy, + decoration: const InputDecoration(labelText: 'Server URL'), + keyboardType: TextInputType.url, + ), + if (_usesApiKey) + TextField( + controller: _apiKeyController, + enabled: !_busy, + obscureText: true, + decoration: const InputDecoration(labelText: 'API key'), + ), + if (_usesUsername) + TextField( + controller: _usernameController, + enabled: !_busy, + decoration: const InputDecoration(labelText: 'Username'), + ), + if (_usesPassword) + TextField( + controller: _passwordController, + enabled: !_busy, + obscureText: true, + decoration: const InputDecoration(labelText: 'Password'), + ), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Enabled'), + value: _enabled, + onChanged: _busy ? null : (value) => setState(() => _enabled = value), + ), + if (_testing) const LinearProgressIndicator(), + if (_message != null) + Padding( + padding: const EdgeInsets.only(top: Spacing.sm), + child: Text(_message!, style: TextStyle(color: _messageIsError ? colorScheme.error : null)), + ), + ], + ), + ), + actions: [ + TextButton(onPressed: _busy ? null : () => Navigator.pop(context, false), child: const Text('Cancel')), + OutlinedButton( + key: const Key('acquisition-test-connection'), + onPressed: _busy ? null : _testConnection, + child: const Text('Test connection'), + ), + FilledButton(key: const Key('acquisition-save'), onPressed: _busy ? null : _save, child: const Text('Save')), + ], + ); + } + + Future _testConnection() async { + final baseUrl = _baseUrl(); + if (baseUrl == null) return; + + setState(() { + _testing = true; + _message = null; + }); + + try { + await widget.onTest( + kind: _kind, + baseUrl: baseUrl, + apiKey: _usesApiKey ? _optional(_apiKeyController.text) : null, + username: _usesUsername ? _optional(_usernameController.text) : null, + password: _usesPassword ? _optional(_passwordController.text) : null, + ); + if (!mounted) return; + setState(() { + _message = 'Connection successful.'; + _messageIsError = false; + }); + } catch (error) { + if (!mounted) return; + setState(() { + _message = error is AuthApiException ? error.message : 'Could not connect to this integration.'; + _messageIsError = true; + }); + } finally { + if (mounted) setState(() => _testing = false); + } + } + + Future _save() async { + final name = _nameController.text.trim(); + final baseUrl = _baseUrl(); + if (name.isEmpty || baseUrl == null) { + if (name.isEmpty) { + setState(() { + _message = 'Name is required.'; + _messageIsError = true; + }); + } + return; + } + + setState(() { + _saving = true; + _message = null; + }); + + try { + await widget.onSave( + name: name, + kind: _kind, + baseUrl: baseUrl, + enabled: _enabled, + apiKey: _usesApiKey ? _optional(_apiKeyController.text) : null, + username: _usesUsername ? _optional(_usernameController.text) : null, + password: _usesPassword ? _optional(_passwordController.text) : null, + ); + if (mounted) Navigator.pop(context, true); + } catch (error) { + if (!mounted) return; + setState(() { + _message = error is AuthApiException ? error.message : 'Could not save this integration.'; + _messageIsError = true; + }); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + Uri? _baseUrl() { + final baseUrl = Uri.tryParse(_urlController.text.trim()); + if (baseUrl == null || !baseUrl.hasScheme || !baseUrl.hasAuthority || baseUrl.userInfo.isNotEmpty) { + setState(() { + _message = 'Enter a valid server URL without embedded credentials.'; + _messageIsError = true; + }); + return null; + } + return baseUrl; + } + + String? _optional(String value) { + final trimmed = value.trim(); + return trimmed.isEmpty ? null : trimmed; + } +} + class _SearchCard extends StatelessWidget { const _SearchCard({ required this.queryController, @@ -558,10 +664,7 @@ class _SearchCard extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - 'Search torrents', - style: Theme.of(context).textTheme.titleMedium, - ), + Text('Search torrents', style: Theme.of(context).textTheme.titleMedium), const SizedBox(height: Spacing.sm), TextField( controller: queryController, @@ -572,24 +675,15 @@ class _SearchCard extends StatelessWidget { suffixIcon: searching ? const Padding( padding: EdgeInsets.all(12), - child: SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ), + child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)), ) - : IconButton( - icon: const Icon(Icons.search), - onPressed: canSearch ? onSearch : null, - ), + : 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.', - ), + child: Text('Add an enabled Prowlarr or Torznab indexer first.'), ), ], ), @@ -624,19 +718,10 @@ class _EndpointSection extends StatelessWidget { const SizedBox(height: Spacing.sm), if (endpoints.isEmpty) Card( - child: ListTile( - leading: const Icon(Icons.info_outline), - title: Text(emptyLabel), - ), + child: ListTile(leading: const Icon(Icons.info_outline), title: Text(emptyLabel)), ) else - ...endpoints.map( - (endpoint) => _EndpointTile( - endpoint: endpoint, - onEdit: onEdit, - onDelete: onDelete, - ), - ), + ...endpoints.map((endpoint) => _EndpointTile(endpoint: endpoint, onEdit: onEdit, onDelete: onDelete)), ], ), ); @@ -644,12 +729,7 @@ class _EndpointSection extends StatelessWidget { } class _ArrSection extends StatelessWidget { - const _ArrSection({ - required this.endpoints, - required this.onRun, - required this.onEdit, - required this.onDelete, - }); + const _ArrSection({required this.endpoints, required this.onRun, required this.onEdit, required this.onDelete}); final List endpoints; final ValueChanged onRun; @@ -663,17 +743,11 @@ class _ArrSection extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - 'Arr applications', - style: Theme.of(context).textTheme.titleMedium, - ), + 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'), - ), + child: ListTile(leading: Icon(Icons.info_outline), title: Text('No Arr applications configured')), ) else ...endpoints.map( @@ -695,12 +769,7 @@ class _ArrSection extends StatelessWidget { } class _EndpointTile extends StatelessWidget { - const _EndpointTile({ - required this.endpoint, - required this.onEdit, - required this.onDelete, - this.trailingAction, - }); + const _EndpointTile({required this.endpoint, required this.onEdit, required this.onDelete, this.trailingAction}); final AcquisitionEndpoint endpoint; final ValueChanged onEdit; @@ -719,12 +788,8 @@ class _EndpointTile extends StatelessWidget { 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, + endpoint.enabled ? Icons.check_circle_outline : Icons.pause_circle_outline, + color: endpoint.enabled ? Theme.of(context).colorScheme.primary : null, ), PopupMenuButton( onSelected: (value) { @@ -746,23 +811,17 @@ class _EndpointTile extends StatelessWidget { AcquisitionEndpointKind.qbittorrent || AcquisitionEndpointKind.transmission || AcquisitionEndpointKind.deluge => Icons.downloading_outlined, - AcquisitionEndpointKind.prowlarr || - AcquisitionEndpointKind.torznab => Icons.travel_explore, + 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, - }); + 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; + final void Function(TorrentRelease release, AcquisitionEndpoint client) onSubmit; @override Widget build(BuildContext context) { @@ -781,12 +840,8 @@ class _ReleaseTile extends StatelessWidget { 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(), + itemBuilder: (context) => + clients.map((client) => PopupMenuItem(value: client, child: Text(client.name))).toList(), onSelected: (client) => onSubmit(release, client), ), ), @@ -794,8 +849,7 @@ class _ReleaseTile extends StatelessWidget { } String _formatBytes(int bytes) { - if (bytes >= 1073741824) - return '${(bytes / 1073741824).toStringAsFixed(1)} GB'; + 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'; @@ -815,10 +869,7 @@ class _ErrorBanner extends StatelessWidget { color: colorScheme.errorContainer, child: ListTile( leading: Icon(Icons.error_outline, color: colorScheme.onErrorContainer), - title: Text( - message, - style: TextStyle(color: colorScheme.onErrorContainer), - ), + title: Text(message, style: TextStyle(color: colorScheme.onErrorContainer)), trailing: TextButton(onPressed: onRetry, child: const Text('Retry')), ), ); diff --git a/app/test/pages/acquisition_page_test.dart b/app/test/pages/acquisition_page_test.dart new file mode 100644 index 0000000..ac071f2 --- /dev/null +++ b/app/test/pages/acquisition_page_test.dart @@ -0,0 +1,187 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/testing.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/auth/auth_models.dart'; +import 'package:papyrus/auth/auth_repository.dart'; +import 'package:papyrus/auth/papyrus_api_config.dart'; +import 'package:papyrus/auth/token_store.dart'; +import 'package:papyrus/pages/acquisition_page.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:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class _MemoryRefreshTokenStorage implements RefreshTokenStorage { + @override + Future delete() async {} + + @override + Future read() async => null; + + @override + Future write(String refreshToken) async {} +} + +class _FakeAuthRepository extends AuthRepository { + _FakeAuthRepository() + : super( + apiClient: AuthApiClient(config: _config), + tokenStore: TokenStore(_MemoryRefreshTokenStorage()), + ); + + @override + Future bootstrap() async => _tokens(); + + @override + Future withFreshAccessToken(Future Function(String accessToken) action) { + return action('access-token'); + } +} + +class _FakeAcquisitionApiClient extends AcquisitionApiClient { + _FakeAcquisitionApiClient() : super(config: _config, httpClient: MockClient((_) async => throw UnimplementedError())); + + final capabilitiesResult = const AcquisitionCapabilities( + enabled: true, + endpointKinds: [ + AcquisitionEndpointKind.prowlarr, + AcquisitionEndpointKind.qbittorrent, + AcquisitionEndpointKind.deluge, + ], + indexerKinds: [AcquisitionEndpointKind.prowlarr], + downloadClientKinds: [AcquisitionEndpointKind.qbittorrent, AcquisitionEndpointKind.deluge], + arrKinds: [], + arrCommands: {}, + ); + Completer? connectionTestCompleter; + int connectionTestCalls = 0; + + @override + Future capabilities(String accessToken) async { + return capabilitiesResult; + } + + @override + Future> listEndpoints(String accessToken) async { + return []; + } + + @override + Future testEndpoint({ + required String accessToken, + String? endpointId, + AcquisitionEndpointKind? kind, + Uri? baseUrl, + String? apiKey, + String? username, + String? password, + }) { + connectionTestCalls += 1; + return connectionTestCompleter?.future ?? Future.value(); + } +} + +final _config = PapyrusApiConfig(serverBaseUri: Uri.parse('https://api.test')); + +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({'acquisition_enabled': true}); + }); + + testWidgets('integration dialog shows credentials required by type', (tester) async { + final apiClient = _FakeAcquisitionApiClient(); + await tester.pumpWidget(await _buildPage(apiClient)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Integration')); + await tester.pumpAndSettle(); + + expect(find.widgetWithText(TextField, 'API key'), findsOneWidget); + expect(find.widgetWithText(TextField, 'Username'), findsNothing); + expect(find.widgetWithText(TextField, 'Password'), findsNothing); + + await tester.tap(find.byType(DropdownButtonFormField)); + await tester.pumpAndSettle(); + await tester.tap(find.text('qBittorrent').last); + await tester.pumpAndSettle(); + + expect(find.widgetWithText(TextField, 'API key'), findsNothing); + expect(find.widgetWithText(TextField, 'Username'), findsOneWidget); + expect(find.widgetWithText(TextField, 'Password'), findsOneWidget); + + await tester.tap(find.byType(DropdownButtonFormField)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Deluge').last); + await tester.pumpAndSettle(); + + expect(find.widgetWithText(TextField, 'API key'), findsNothing); + expect(find.widgetWithText(TextField, 'Username'), findsNothing); + expect(find.widgetWithText(TextField, 'Password'), findsOneWidget); + }); + + testWidgets('integration dialog locks actions and renders test errors', (tester) async { + final apiClient = _FakeAcquisitionApiClient(); + final completer = Completer(); + apiClient.connectionTestCompleter = completer; + await tester.pumpWidget(await _buildPage(apiClient)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Integration')); + await tester.pumpAndSettle(); + await tester.enterText(find.widgetWithText(TextField, 'Server URL'), 'http://prowlarr.local:9696'); + await tester.tap(find.byKey(const Key('acquisition-test-connection'))); + await tester.pump(); + + expect(apiClient.connectionTestCalls, 1); + expect(tester.widget(find.byKey(const Key('acquisition-test-connection'))).onPressed, isNull); + expect(tester.widget(find.byKey(const Key('acquisition-save'))).onPressed, isNull); + + await tester.tap(find.byKey(const Key('acquisition-test-connection')), warnIfMissed: false); + await tester.pump(); + expect(apiClient.connectionTestCalls, 1); + + completer.completeError(const AuthApiException(statusCode: 502, message: 'Prowlarr connection test failed')); + await tester.pumpAndSettle(); + + expect(find.text('Prowlarr connection test failed'), findsOneWidget); + }); +} + +Future _buildPage(_FakeAcquisitionApiClient apiClient) async { + final prefs = await SharedPreferences.getInstance(); + final authProvider = AuthProvider(prefs, repository: _FakeAuthRepository(), bootstrapOnCreate: false); + await authProvider.bootstrap(); + + return MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: authProvider), + ChangeNotifierProvider(create: (_) => PreferencesProvider(prefs)), + ChangeNotifierProvider(create: (_) => SyncSettingsProvider(prefs, officialConfig: _config)), + ], + child: MaterialApp(home: AcquisitionPage(clientFactory: (_) => apiClient)), + ); +} + +AuthTokens _tokens() { + return AuthTokens( + accessToken: 'access-token', + refreshToken: 'refresh-token', + tokenType: 'Bearer', + expiresIn: 3600, + user: PapyrusUser( + userId: '11111111-1111-1111-1111-111111111111', + email: 'reader@example.com', + displayName: 'Reader', + avatarUrl: null, + emailVerified: true, + createdAt: null, + lastLoginAt: null, + ), + ); +} From aa475c8caf88747f85f9e2c019d62e43f9b9d2a2 Mon Sep 17 00:00:00 2001 From: Eoic Date: Fri, 17 Jul 2026 23:40:39 +0300 Subject: [PATCH 7/8] fix: complete acquisition search and submission states --- .../acquisition/acquisition_api_client.dart | 36 ++-- app/lib/pages/acquisition_page.dart | 180 ++++++++++++++---- app/test/pages/acquisition_page_test.dart | 170 ++++++++++++++++- 3 files changed, 329 insertions(+), 57 deletions(-) diff --git a/app/lib/acquisition/acquisition_api_client.dart b/app/lib/acquisition/acquisition_api_client.dart index e65fa89..4a6c638 100644 --- a/app/lib/acquisition/acquisition_api_client.dart +++ b/app/lib/acquisition/acquisition_api_client.dart @@ -49,9 +49,9 @@ class AcquisitionApiClient { '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, + 'api_key': ?apiKey, + 'username': ?username, + 'password': ?password, }), ); return AcquisitionEndpoint.fromJson(_decodeObject(response)); @@ -71,12 +71,12 @@ class AcquisitionApiClient { 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, + 'name': ?name, + 'base_url': ?baseUrl?.toString(), + 'api_key': ?apiKey, + 'username': ?username, + 'password': ?password, + 'enabled': ?enabled, }), ); return AcquisitionEndpoint.fromJson(_decodeObject(response)); @@ -95,12 +95,12 @@ class AcquisitionApiClient { config.endpoint('/acquisition/endpoints/test'), headers: _headers(accessToken), body: jsonEncode({ - if (endpointId != null) 'endpoint_id': endpointId, - if (kind != null) 'kind': kind.apiValue, - if (baseUrl != null) 'base_url': baseUrl.toString(), - if (apiKey != null) 'api_key': apiKey, - if (username != null) 'username': username, - if (password != null) 'password': password, + 'endpoint_id': ?endpointId, + 'kind': ?kind?.apiValue, + 'base_url': ?baseUrl?.toString(), + 'api_key': ?apiKey, + 'username': ?username, + 'password': ?password, }), ); final result = _decodeObject(response); @@ -126,7 +126,7 @@ class AcquisitionApiClient { final response = await _httpClient.post( config.endpoint('/acquisition/search'), headers: _headers(accessToken), - body: jsonEncode({'query': query, if (endpointIds != null) 'endpoint_ids': endpointIds}), + body: jsonEncode({'query': query, 'endpoint_ids': ?endpointIds}), ); return _decodeList(response).map(TorrentRelease.fromJson).toList(); } @@ -145,8 +145,8 @@ class AcquisitionApiClient { 'endpoint_id': endpointId, 'title': release.title, 'download_url': release.downloadUrl, - if (category != null) 'category': category, - if (savePath != null) 'save_path': savePath, + 'category': ?category, + 'save_path': ?savePath, }), ); return AcquisitionJob.fromJson(_decodeObject(response)); diff --git a/app/lib/pages/acquisition_page.dart b/app/lib/pages/acquisition_page.dart index 4c210da..3a2a4ff 100644 --- a/app/lib/pages/acquisition_page.dart +++ b/app/lib/pages/acquisition_page.dart @@ -28,9 +28,11 @@ class _AcquisitionPageState extends State { AcquisitionCapabilities? _capabilities; List _endpoints = []; List _releases = []; + Set _selectedIndexerIds = {}; + final Set _submittingKeys = {}; + bool _hasExplicitIndexerSelection = false; bool _loading = true; bool _searching = false; - bool _submitting = false; String? _error; @override @@ -41,6 +43,8 @@ class _AcquisitionPageState extends State { _client?.close(); _client = widget.clientFactory?.call(config) ?? AcquisitionApiClient(config: config); _clientBaseUri = config.serverBaseUri; + _selectedIndexerIds = {}; + _hasExplicitIndexerSelection = false; _load(); } @@ -72,9 +76,18 @@ class _AcquisitionPageState extends State { final capabilities = await _authenticated(_apiClient.capabilities); final endpoints = await _authenticated(_apiClient.listEndpoints); if (!mounted) return; + final enabledIndexerIds = endpoints + .where((endpoint) => endpoint.enabled && endpoint.kind.isIndexer) + .map((endpoint) => endpoint.id) + .toSet(); setState(() { _capabilities = capabilities; _endpoints = endpoints; + if (_hasExplicitIndexerSelection) { + _selectedIndexerIds = _selectedIndexerIds.intersection(enabledIndexerIds); + } else { + _selectedIndexerIds = enabledIndexerIds; + } }); } on AuthApiException catch (error) { if (!mounted) return; @@ -91,7 +104,12 @@ class _AcquisitionPageState extends State { Future _search() async { final query = _queryController.text.trim(); - if (query.isEmpty || _capabilities == null) return; + final indexerIds = _endpoints + .where((endpoint) => endpoint.enabled && endpoint.kind.isIndexer && _selectedIndexerIds.contains(endpoint.id)) + .map((endpoint) => endpoint.id) + .toList(); + final hasClient = _endpoints.any((endpoint) => endpoint.enabled && endpoint.kind.isDownloadClient); + if (query.isEmpty || _capabilities == null || indexerIds.isEmpty || !hasClient) return; setState(() { _searching = true; @@ -100,12 +118,8 @@ class _AcquisitionPageState extends State { }); 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); + return _apiClient.search(accessToken: token, query: query, endpointIds: indexerIds); }); if (mounted) setState(() => _releases = releases); } on AuthApiException catch (error) { @@ -120,20 +134,23 @@ class _AcquisitionPageState extends State { } Future _submitRelease(TorrentRelease release, AcquisitionEndpoint client) async { - setState(() => _submitting = true); + final submissionKey = _submissionKey(release, client); + if (_submittingKeys.contains(submissionKey)) return; + + setState(() => _submittingKeys.add(submissionKey)); try { - await _authenticated((token) { + final job = 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}.'))); - } + if (!mounted) return; + + _showMessage(job.isSubmitted ? 'Sent to ${client.name}.' : job.error ?? 'Submission failed.'); + } on AuthApiException catch (error) { + if (mounted) _showMessage(error.message); } catch (_) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Could not submit this release.'))); - } + if (mounted) _showMessage('Could not submit this release.'); } finally { - if (mounted) setState(() => _submitting = false); + if (mounted) setState(() => _submittingKeys.remove(submissionKey)); } } @@ -148,20 +165,23 @@ class _AcquisitionPageState extends State { final ids = await _askForIds(command); if (ids == null) return; - setState(() => _submitting = true); + final submissionKey = 'arr:${endpoint.id}'; + if (_submittingKeys.contains(submissionKey)) return; + + setState(() => _submittingKeys.add(submissionKey)); try { - await _authenticated((token) { + final job = 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}.'))); - } + if (!mounted) return; + + _showMessage(job.isSubmitted ? '$command sent to ${endpoint.name}.' : job.error ?? 'Arr action failed.'); + } on AuthApiException catch (error) { + if (mounted) _showMessage(error.message); } catch (_) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Could not run this Arr action.'))); - } + if (mounted) _showMessage('Could not run this Arr action.'); } finally { - if (mounted) setState(() => _submitting = false); + if (mounted) setState(() => _submittingKeys.remove(submissionKey)); } } @@ -310,7 +330,10 @@ class _AcquisitionPageState extends State { final capabilities = _capabilities; final clients = _endpoints.where((endpoint) => endpoint.enabled && endpoint.kind.isDownloadClient).toList(); final indexers = _endpoints.where((endpoint) => endpoint.kind.isIndexer).toList(); + final enabledIndexers = indexers.where((endpoint) => endpoint.enabled).toList(); final arrApps = _endpoints.where((endpoint) => endpoint.kind.isArr).toList(); + final canSearch = + clients.isNotEmpty && enabledIndexers.any((endpoint) => _selectedIndexerIds.contains(endpoint.id)); return Scaffold( appBar: AppBar(title: const Text('Torrent acquisition')), @@ -327,13 +350,17 @@ class _AcquisitionPageState extends State { padding: const EdgeInsets.all(Spacing.md), children: [ if (_loading) const LinearProgressIndicator(), - if (_submitting) const LinearProgressIndicator(), + if (_submittingKeys.isNotEmpty) 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), + canSearch: canSearch, + indexers: enabledIndexers, + selectedIndexerIds: _selectedIndexerIds, + hasEnabledClient: clients.isNotEmpty, + onIndexerSelected: _setIndexerSelected, onSearch: _search, ), const SizedBox(height: Spacing.md), @@ -353,6 +380,7 @@ class _AcquisitionPageState extends State { ), _ArrSection( endpoints: arrApps, + submittingKeys: _submittingKeys, onRun: _runArrCommand, onEdit: (endpoint) => _showEndpointDialog(endpoint: endpoint), onDelete: _deleteEndpoint, @@ -362,7 +390,12 @@ class _AcquisitionPageState extends State { Text('Results', style: Theme.of(context).textTheme.titleMedium), const SizedBox(height: Spacing.sm), ..._releases.map( - (release) => _ReleaseTile(release: release, clients: clients, onSubmit: _submitRelease), + (release) => _ReleaseTile( + release: release, + clients: clients, + submittingKeys: _submittingKeys, + onSubmit: _submitRelease, + ), ), ] else if (!_searching && _queryController.text.trim().isNotEmpty) const Padding( @@ -383,6 +416,21 @@ class _AcquisitionPageState extends State { return error.message; } + void _setIndexerSelected(AcquisitionEndpoint endpoint, bool selected) { + setState(() { + _hasExplicitIndexerSelection = true; + if (selected) { + _selectedIndexerIds.add(endpoint.id); + } else { + _selectedIndexerIds.remove(endpoint.id); + } + }); + } + + void _showMessage(String message) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); + } + String _arrCommandLabel(String command) => switch (command) { 'AuthorSearch' => 'Search authors', 'BookSearch' => 'Search books', @@ -648,12 +696,20 @@ class _SearchCard extends StatelessWidget { required this.queryController, required this.searching, required this.canSearch, + required this.indexers, + required this.selectedIndexerIds, + required this.hasEnabledClient, + required this.onIndexerSelected, required this.onSearch, }); final TextEditingController queryController; final bool searching; final bool canSearch; + final List indexers; + final Set selectedIndexerIds; + final bool hasEnabledClient; + final void Function(AcquisitionEndpoint endpoint, bool selected) onIndexerSelected; final VoidCallback onSearch; @override @@ -666,6 +722,22 @@ class _SearchCard extends StatelessWidget { children: [ Text('Search torrents', style: Theme.of(context).textTheme.titleMedium), const SizedBox(height: Spacing.sm), + if (indexers.isNotEmpty) ...[ + Wrap( + spacing: Spacing.sm, + runSpacing: Spacing.xs, + children: indexers + .map( + (endpoint) => FilterChip( + label: Text(endpoint.name), + selected: selectedIndexerIds.contains(endpoint.id), + onSelected: searching ? null : (selected) => onIndexerSelected(endpoint, selected), + ), + ) + .toList(), + ), + const SizedBox(height: Spacing.sm), + ], TextField( controller: queryController, enabled: canSearch && !searching, @@ -681,9 +753,15 @@ class _SearchCard extends StatelessWidget { ), ), if (!canSearch) - const Padding( - padding: EdgeInsets.only(top: Spacing.sm), - child: Text('Add an enabled Prowlarr or Torznab indexer first.'), + Padding( + padding: const EdgeInsets.only(top: Spacing.sm), + child: Text( + indexers.isEmpty + ? 'Add an enabled Prowlarr or Torznab indexer first.' + : !hasEnabledClient + ? 'Add an enabled download client before searching.' + : 'Select at least one torrent indexer.', + ), ), ], ), @@ -729,9 +807,16 @@ class _EndpointSection extends StatelessWidget { } class _ArrSection extends StatelessWidget { - const _ArrSection({required this.endpoints, required this.onRun, required this.onEdit, required this.onDelete}); + const _ArrSection({ + required this.endpoints, + required this.submittingKeys, + required this.onRun, + required this.onEdit, + required this.onDelete, + }); final List endpoints; + final Set submittingKeys; final ValueChanged onRun; final ValueChanged onEdit; final ValueChanged onDelete; @@ -758,7 +843,9 @@ class _ArrSection extends StatelessWidget { trailingAction: IconButton( tooltip: 'Run action', icon: const Icon(Icons.play_arrow_outlined), - onPressed: endpoint.enabled ? () => onRun(endpoint) : null, + onPressed: endpoint.enabled && !submittingKeys.contains('arr:${endpoint.id}') + ? () => onRun(endpoint) + : null, ), ), ), @@ -786,7 +873,7 @@ class _EndpointTile extends StatelessWidget { trailing: Row( mainAxisSize: MainAxisSize.min, children: [ - if (trailingAction != null) trailingAction!, + ?trailingAction, Icon( endpoint.enabled ? Icons.check_circle_outline : Icons.pause_circle_outline, color: endpoint.enabled ? Theme.of(context).colorScheme.primary : null, @@ -817,10 +904,16 @@ class _EndpointTile extends StatelessWidget { } class _ReleaseTile extends StatelessWidget { - const _ReleaseTile({required this.release, required this.clients, required this.onSubmit}); + const _ReleaseTile({ + required this.release, + required this.clients, + required this.submittingKeys, + required this.onSubmit, + }); final TorrentRelease release; final List clients; + final Set submittingKeys; final void Function(TorrentRelease release, AcquisitionEndpoint client) onSubmit; @override @@ -837,11 +930,18 @@ class _ReleaseTile extends StatelessWidget { ].join(' • '), ), trailing: PopupMenuButton( - enabled: clients.isNotEmpty, + enabled: clients.any((client) => !submittingKeys.contains(_submissionKey(release, client))), icon: const Icon(Icons.send_outlined), tooltip: 'Send to client', - itemBuilder: (context) => - clients.map((client) => PopupMenuItem(value: client, child: Text(client.name))).toList(), + itemBuilder: (context) => clients.map((client) { + final key = _submissionKey(release, client); + return PopupMenuItem( + key: Key('submission:$key'), + value: client, + enabled: !submittingKeys.contains(key), + child: Text(client.name), + ); + }).toList(), onSelected: (client) => onSubmit(release, client), ), ), @@ -856,6 +956,10 @@ class _ReleaseTile extends StatelessWidget { } } +String _submissionKey(TorrentRelease release, AcquisitionEndpoint client) { + return '${release.downloadUrl}:${client.id}'; +} + class _ErrorBanner extends StatelessWidget { const _ErrorBanner({required this.message, required this.onRetry}); diff --git a/app/test/pages/acquisition_page_test.dart b/app/test/pages/acquisition_page_test.dart index ac071f2..b4b49b7 100644 --- a/app/test/pages/acquisition_page_test.dart +++ b/app/test/pages/acquisition_page_test.dart @@ -61,6 +61,10 @@ class _FakeAcquisitionApiClient extends AcquisitionApiClient { ); Completer? connectionTestCompleter; int connectionTestCalls = 0; + List endpointsResult = []; + List releasesResult = []; + final searchEndpointIds = ?>[]; + final submissionCompleters = >{}; @override Future capabilities(String accessToken) async { @@ -69,7 +73,7 @@ class _FakeAcquisitionApiClient extends AcquisitionApiClient { @override Future> listEndpoints(String accessToken) async { - return []; + return endpointsResult; } @override @@ -85,6 +89,28 @@ class _FakeAcquisitionApiClient extends AcquisitionApiClient { connectionTestCalls += 1; return connectionTestCompleter?.future ?? Future.value(); } + + @override + Future> search({ + required String accessToken, + required String query, + List? endpointIds, + }) async { + searchEndpointIds.add(endpointIds); + return releasesResult; + } + + @override + Future submitRelease({ + required String accessToken, + required String endpointId, + required TorrentRelease release, + String? category, + String? savePath, + }) { + final key = '${release.downloadUrl}:$endpointId'; + return submissionCompleters.putIfAbsent(key, Completer.new).future; + } } final _config = PapyrusApiConfig(serverBaseUri: Uri.parse('https://api.test')); @@ -151,6 +177,148 @@ void main() { expect(find.text('Prowlarr connection test failed'), findsOneWidget); }); + + testWidgets('search and submission requires selected indexer and client', (tester) async { + final apiClient = _FakeAcquisitionApiClient()..endpointsResult = [_indexerOne, _indexerTwo, _clientOne]; + await tester.pumpWidget(await _buildPage(apiClient)); + await tester.pumpAndSettle(); + + expect(find.byType(FilterChip), findsNWidgets(2)); + + await tester.tap(find.widgetWithText(FilterChip, 'Indexer One')); + await tester.pump(); + await tester.tap(find.widgetWithText(FilterChip, 'Indexer Two')); + await tester.pump(); + + expect(tester.widget(_searchField).enabled, isFalse); + + await tester.tap(find.widgetWithText(FilterChip, 'Indexer One')); + await tester.pump(); + await tester.enterText(_searchField, 'book'); + await tester.tap(find.byIcon(Icons.search)); + await tester.pumpAndSettle(); + + expect(apiClient.searchEndpointIds, [ + ['indexer-1'], + ]); + }); + + testWidgets('search and submission requires an enabled download client', (tester) async { + final apiClient = _FakeAcquisitionApiClient()..endpointsResult = [_indexerOne]; + await tester.pumpWidget(await _buildPage(apiClient)); + await tester.pumpAndSettle(); + + expect(tester.widget(_searchField).enabled, isFalse); + }); + + testWidgets('search and submission scopes progress and shows job failure', (tester) async { + final apiClient = _FakeAcquisitionApiClient() + ..endpointsResult = [_indexerOne, _clientOne, _clientTwo] + ..releasesResult = [_releaseOne, _releaseTwo]; + await tester.pumpWidget(await _buildPage(apiClient)); + await tester.pumpAndSettle(); + await tester.enterText(_searchField, 'book'); + await tester.tap(find.byIcon(Icons.search)); + await tester.pumpAndSettle(); + await tester.scrollUntilVisible(find.text('Release One'), 400, scrollable: find.byType(Scrollable).first); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.send_outlined).first); + await tester.pumpAndSettle(); + await tester.tap(find.text('Client One').last); + await tester.pump(); + + await tester.tap(find.byIcon(Icons.send_outlined).first); + await tester.pumpAndSettle(); + expect(_submissionItem(tester, _releaseOne, _clientOne).enabled, isFalse); + expect(_submissionItem(tester, _releaseOne, _clientTwo).enabled, isTrue); + + await tester.tapAt(Offset.zero); + await tester.pumpAndSettle(); + await tester.scrollUntilVisible(find.text('Release Two'), 300, scrollable: find.byType(Scrollable).first); + await tester.pumpAndSettle(); + final releaseTwoTile = find.ancestor(of: find.text('Release Two'), matching: find.byType(ListTile)); + final releaseTwoMenu = find.descendant( + of: releaseTwoTile, + matching: find.byType(PopupMenuButton), + ); + expect(tester.widget>(releaseTwoMenu).enabled, isTrue); + + apiClient.submissionCompleters['${_releaseOne.downloadUrl}:${_clientOne.id}']!.complete( + _job(status: 'failed', error: 'Transmission rejected the release'), + ); + await tester.pumpAndSettle(); + + expect(find.text('Transmission rejected the release'), findsOneWidget); + expect(find.text('Sent to Client One.'), findsNothing); + }); +} + +final _searchField = find.widgetWithText(TextField, 'Title, author, movie, album, or series'); + +PopupMenuItem _submissionItem( + WidgetTester tester, + TorrentRelease release, + AcquisitionEndpoint client, +) { + return tester.widget>( + find.byKey(Key('submission:${release.downloadUrl}:${client.id}')), + ); +} + +final _indexerOne = AcquisitionEndpoint( + id: 'indexer-1', + name: 'Indexer One', + kind: AcquisitionEndpointKind.prowlarr, + baseUrl: Uri.parse('http://indexer-one.local'), + enabled: true, +); +final _indexerTwo = AcquisitionEndpoint( + id: 'indexer-2', + name: 'Indexer Two', + kind: AcquisitionEndpointKind.prowlarr, + baseUrl: Uri.parse('http://indexer-two.local'), + enabled: true, +); +final _clientOne = AcquisitionEndpoint( + id: 'client-1', + name: 'Client One', + kind: AcquisitionEndpointKind.transmission, + baseUrl: Uri.parse('http://client-one.local'), + enabled: true, +); +final _clientTwo = AcquisitionEndpoint( + id: 'client-2', + name: 'Client Two', + kind: AcquisitionEndpointKind.qbittorrent, + baseUrl: Uri.parse('http://client-two.local'), + enabled: true, +); +const _releaseOne = TorrentRelease( + title: 'Release One', + downloadUrl: 'magnet:?xt=urn:btih:release-one', + protocol: 'torrent', + indexer: 'Indexer One', +); +const _releaseTwo = TorrentRelease( + title: 'Release Two', + downloadUrl: 'magnet:?xt=urn:btih:release-two', + protocol: 'torrent', + indexer: 'Indexer One', +); + +AcquisitionJob _job({required String status, String? error}) { + return AcquisitionJob( + id: 'job-1', + endpointId: 'client-1', + ruleId: null, + title: 'Release One', + downloadUrl: _releaseOne.downloadUrl, + status: status, + clientReference: null, + error: error, + createdAt: null, + ); } Future _buildPage(_FakeAcquisitionApiClient apiClient) async { From 672814d20122dca8b453afbc15c618c34f97e9a0 Mon Sep 17 00:00:00 2001 From: Eoic Date: Fri, 17 Jul 2026 23:42:13 +0300 Subject: [PATCH 8/8] style: format client sources --- app/lib/auth/auth_repository.dart | 69 +++++---------------- app/lib/providers/auth_provider.dart | 55 ++++------------ app/lib/providers/preferences_provider.dart | 21 +++---- 3 files changed, 32 insertions(+), 113 deletions(-) diff --git a/app/lib/auth/auth_repository.dart b/app/lib/auth/auth_repository.dart index 8381b49..d074a29 100644 --- a/app/lib/auth/auth_repository.dart +++ b/app/lib/auth/auth_repository.dart @@ -24,8 +24,7 @@ class AuthRepository { return false; } - return defaultTargetPlatform == TargetPlatform.linux || - defaultTargetPlatform == TargetPlatform.windows; + return defaultTargetPlatform == TargetPlatform.linux || defaultTargetPlatform == TargetPlatform.windows; } Future bootstrap() async { @@ -84,10 +83,7 @@ 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); @@ -101,10 +97,7 @@ class AuthRepository { } } - Future signInWithGoogle({ - required String clientType, - String? deviceLabel, - }) async { + Future signInWithGoogle({required String clientType, String? deviceLabel}) async { await apiClient.ensureServerReachable(); final redirectUri = kIsWeb @@ -122,25 +115,15 @@ 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) { @@ -150,17 +133,10 @@ 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; @@ -183,27 +159,17 @@ 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); } @@ -255,9 +221,7 @@ class AuthRepository { return tokenStore.clear(); } - Future withFreshAccessToken( - Future Function(String accessToken) action, - ) { + Future withFreshAccessToken(Future Function(String accessToken) action) { return _withFreshAccessToken(action); } @@ -273,15 +237,10 @@ 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/providers/auth_provider.dart b/app/lib/providers/auth_provider.dart index 0af93c7..9a9bd67 100644 --- a/app/lib/providers/auth_provider.dart +++ b/app/lib/providers/auth_provider.dart @@ -34,11 +34,8 @@ 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) { @@ -46,10 +43,7 @@ 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; @@ -77,11 +71,7 @@ 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, @@ -95,12 +85,7 @@ 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); }); } @@ -109,10 +94,7 @@ 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; @@ -133,11 +115,7 @@ 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); }); } @@ -172,15 +150,9 @@ 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; @@ -195,10 +167,7 @@ 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); }); @@ -216,9 +185,7 @@ class AuthProvider extends ChangeNotifier { return _repository.downloadMedia(assetId); } - Future withFreshAccessToken( - Future Function(String accessToken) action, - ) async { + Future withFreshAccessToken(Future Function(String accessToken) action) async { try { return await _repository.withFreshAccessToken(action); } catch (error) { diff --git a/app/lib/providers/preferences_provider.dart b/app/lib/providers/preferences_provider.dart index 81b338a..2f501ed 100644 --- a/app/lib/providers/preferences_provider.dart +++ b/app/lib/providers/preferences_provider.dart @@ -135,8 +135,7 @@ 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); @@ -154,8 +153,7 @@ 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); @@ -163,8 +161,7 @@ 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); @@ -172,8 +169,7 @@ 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); @@ -196,8 +192,7 @@ 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); @@ -244,16 +239,14 @@ 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; + bool get acquisitionEnabled => _prefs.getBool(_keyAcquisitionEnabled) ?? false; set acquisitionEnabled(bool value) { _prefs.setBool(_keyAcquisitionEnabled, value);