diff --git a/lib/core/services/api_service.dart b/lib/core/services/api_service.dart index c6abacf..44c217b 100644 --- a/lib/core/services/api_service.dart +++ b/lib/core/services/api_service.dart @@ -427,6 +427,7 @@ class ApiService { /// - `queryParams`: Optional query parameters /// - `followRedirects`: If false, will throw a [RedirectException] on 301/302 status codes. Future isReachable({ + String endpoint = '/', Duration timeout = const Duration(seconds: 8), }) async { if (_baseUrl == null || _baseUrl!.isEmpty) return false; @@ -437,7 +438,7 @@ class ApiService { httpClient.badCertificateCallback = (cert, host, port) => true; } try { - final uri = _buildUri(endpoint: '/'); + final uri = _buildUri(endpoint: endpoint); final headers = getAuthHeaders(authMethod: AuthMethod.auto); if (_userAgent != null) headers['User-Agent'] = _userAgent!; diff --git a/lib/core/services/connectivity_service.dart b/lib/core/services/connectivity_service.dart index 984baf2..cc7b6e3 100644 --- a/lib/core/services/connectivity_service.dart +++ b/lib/core/services/connectivity_service.dart @@ -1,4 +1,5 @@ import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:calibre_web_companion/core/services/api_service.dart'; @@ -30,9 +31,26 @@ class ConnectivityService { final baseUrl = apiService.getBaseUrl(); if (baseUrl.isEmpty) return false; - return await apiService.isReachable(); + return await apiService.isReachable(endpoint: await _probeEndpoint()); } catch (_) { return false; } } + + Future _probeEndpoint() async { + final prefs = await SharedPreferences.getInstance(); + final override = prefs.getString('reachability_probe_endpoint')?.trim(); + if (override != null && override.isNotEmpty) return override; + + switch (prefs.getString('server_type')) { + case 'calibre': + return '/ajax/library-info'; + case 'grimmory': + return '/catalog'; + case 'opds': + return ''; + default: + return '/ajax/listbooks?limit=1'; + } + } } diff --git a/lib/features/homepage/presentation/pages/home_page.dart b/lib/features/homepage/presentation/pages/home_page.dart index 68eb045..1a72a29 100644 --- a/lib/features/homepage/presentation/pages/home_page.dart +++ b/lib/features/homepage/presentation/pages/home_page.dart @@ -15,7 +15,7 @@ import 'package:calibre_web_companion/features/download_service/presentation/pag import 'package:calibre_web_companion/features/settings/bloc/settings_bloc.dart'; import 'package:calibre_web_companion/features/settings/bloc/settings_state.dart'; import 'package:calibre_web_companion/features/offline/cubit/connectivity_cubit.dart'; -import 'package:calibre_web_companion/features/offline/presentation/pages/offline_library_page.dart'; +import 'package:calibre_web_companion/features/offline/presentation/pages/offline_home_page.dart'; class HomePage extends StatelessWidget { const HomePage({super.key}); @@ -27,57 +27,13 @@ class HomePage extends StatelessWidget { return BlocBuilder( builder: (context, connectivity) { if (connectivity == ConnectivityStatus.offline) { - return _buildOffline(context, localizations); + return const OfflineHomePage(); } return _buildOnline(context, localizations); }, ); } - Widget _buildOffline(BuildContext context, AppLocalizations localizations) { - final theme = Theme.of(context); - return Scaffold( - appBar: AppBar( - title: Text(localizations.offline), - actions: [ - IconButton( - icon: const Icon(Icons.refresh_rounded), - tooltip: localizations.retry, - onPressed: () => context.read().recheck(), - ), - ], - ), - body: Column( - children: [ - Container( - width: double.infinity, - color: theme.colorScheme.secondaryContainer, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - child: Row( - children: [ - Icon( - Icons.cloud_off_rounded, - size: 20, - color: theme.colorScheme.onSecondaryContainer, - ), - const SizedBox(width: 10), - Expanded( - child: Text( - localizations.offlineBannerMessage, - style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onSecondaryContainer, - ), - ), - ), - ], - ), - ), - const Expanded(child: OfflineLibraryPage()), - ], - ), - ); - } - Widget _buildOnline(BuildContext context, AppLocalizations localizations) { return BlocBuilder( builder: (context, homeState) { diff --git a/lib/features/login/presentation/pages/login_page.dart b/lib/features/login/presentation/pages/login_page.dart index 63af393..30e4390 100644 --- a/lib/features/login/presentation/pages/login_page.dart +++ b/lib/features/login/presentation/pages/login_page.dart @@ -10,6 +10,7 @@ import 'package:calibre_web_companion/features/homepage/presentation/pages/home_ import 'package:calibre_web_companion/core/services/snackbar.dart'; import 'package:calibre_web_companion/l10n/app_localizations.dart'; import 'package:calibre_web_companion/features/login/presentation/widgets/login_form_widget.dart'; +import 'package:calibre_web_companion/features/offline/cubit/connectivity_cubit.dart'; class LoginPage extends StatelessWidget { const LoginPage({super.key}); @@ -23,6 +24,7 @@ class LoginPage extends StatelessWidget { body: BlocListener( listener: (context, state) { if (state.status == LoginStatus.success) { + context.read().reportSuccess(); Navigator.of(context).pushReplacement( MaterialPageRoute(builder: (context) => HomePage()), ); diff --git a/lib/features/login_settings/bloc/login_settings_bloc.dart b/lib/features/login_settings/bloc/login_settings_bloc.dart index adce33c..32c4404 100644 --- a/lib/features/login_settings/bloc/login_settings_bloc.dart +++ b/lib/features/login_settings/bloc/login_settings_bloc.dart @@ -21,6 +21,7 @@ class LoginSettingsBloc extends Bloc { on(_onUpdateCustomHeaderKey); on(_onUpdateCustomHeaderValue); on(_onUpdateBasePath); + on(_onUpdateReachabilityProbe); on(_onUpdateAllowSelfSigned); } @@ -34,6 +35,8 @@ class LoginSettingsBloc extends Bloc { final headers = await loginSettingsRepository.getCustomHeaders(); final basePath = await loginSettingsRepository.getBasePath(); // Base Path laden + final reachabilityProbe = + await loginSettingsRepository.getReachabilityProbe(); final allowSelfSigned = await loginSettingsRepository.getAllowSelfSigned(); @@ -41,6 +44,7 @@ class LoginSettingsBloc extends Bloc { state.copyWith( customHeaders: headers, basePath: basePath, + reachabilityProbe: reachabilityProbe, allowSelfSigned: allowSelfSigned, isLoading: false, ), @@ -141,6 +145,18 @@ class LoginSettingsBloc extends Bloc { } } + Future _onUpdateReachabilityProbe( + UpdateReachabilityProbe event, + Emitter emit, + ) async { + emit(state.copyWith(reachabilityProbe: event.endpoint)); + try { + await loginSettingsRepository.saveReachabilityProbe(event.endpoint.trim()); + } catch (e) { + _logger.e('Error saving reachability probe endpoint: $e'); + } + } + Future _onUpdateAllowSelfSigned( UpdateAllowSelfSigned event, Emitter emit, diff --git a/lib/features/login_settings/bloc/login_settings_event.dart b/lib/features/login_settings/bloc/login_settings_event.dart index 1f19319..79c1bb2 100644 --- a/lib/features/login_settings/bloc/login_settings_event.dart +++ b/lib/features/login_settings/bloc/login_settings_event.dart @@ -53,6 +53,15 @@ class UpdateBasePath extends LoginSettingsEvent { List get props => [basePath]; } +class UpdateReachabilityProbe extends LoginSettingsEvent { + final String endpoint; + + const UpdateReachabilityProbe(this.endpoint); + + @override + List get props => [endpoint]; +} + class UpdateAllowSelfSigned extends LoginSettingsEvent { final bool allowSelfSigned; diff --git a/lib/features/login_settings/bloc/login_settings_state.dart b/lib/features/login_settings/bloc/login_settings_state.dart index 9f25206..d91ad1b 100644 --- a/lib/features/login_settings/bloc/login_settings_state.dart +++ b/lib/features/login_settings/bloc/login_settings_state.dart @@ -5,6 +5,7 @@ import 'package:calibre_web_companion/features/login_settings/data/models/custom class LoginSettingsState extends Equatable { final List customHeaders; final String basePath; + final String reachabilityProbe; final bool isLoading; final bool isSaved; final bool allowSelfSigned; @@ -13,6 +14,7 @@ class LoginSettingsState extends Equatable { const LoginSettingsState({ this.customHeaders = const [], this.basePath = '', + this.reachabilityProbe = '', this.isLoading = false, this.isSaved = false, this.allowSelfSigned = false, @@ -22,6 +24,7 @@ class LoginSettingsState extends Equatable { LoginSettingsState copyWith({ List? customHeaders, String? basePath, + String? reachabilityProbe, bool? isLoading, bool? isSaved, bool? allowSelfSigned, @@ -30,6 +33,7 @@ class LoginSettingsState extends Equatable { return LoginSettingsState( customHeaders: customHeaders ?? this.customHeaders, basePath: basePath ?? this.basePath, + reachabilityProbe: reachabilityProbe ?? this.reachabilityProbe, isLoading: isLoading ?? this.isLoading, isSaved: isSaved ?? this.isSaved, allowSelfSigned: allowSelfSigned ?? this.allowSelfSigned, @@ -41,6 +45,7 @@ class LoginSettingsState extends Equatable { List get props => [ customHeaders, basePath, + reachabilityProbe, isLoading, isSaved, allowSelfSigned, diff --git a/lib/features/login_settings/data/datasources/login_settings_local_datasource.dart b/lib/features/login_settings/data/datasources/login_settings_local_datasource.dart index 508937d..f395e44 100644 --- a/lib/features/login_settings/data/datasources/login_settings_local_datasource.dart +++ b/lib/features/login_settings/data/datasources/login_settings_local_datasource.dart @@ -18,6 +18,7 @@ class LoginSettingsLocalDataSource { static const String _customHeadersKey = 'custom_login_headers'; static const String _basePathKey = 'base_path'; + static const String _reachabilityProbeKey = 'reachability_probe_endpoint'; Future> getCustomHeaders() async { try { @@ -75,6 +76,30 @@ class LoginSettingsLocalDataSource { } } + Future getReachabilityProbe() async { + try { + return preferences.getString(_reachabilityProbeKey) ?? ''; + } catch (e) { + logger.e('Error loading reachability probe endpoint: $e'); + return ''; + } + } + + Future saveReachabilityProbe(String endpoint) async { + try { + final trimmed = endpoint.trim(); + if (trimmed.isEmpty) { + await preferences.remove(_reachabilityProbeKey); + } else { + await preferences.setString(_reachabilityProbeKey, trimmed); + } + logger.i('Saved reachability probe endpoint: $trimmed'); + } catch (e) { + logger.e('Error saving reachability probe endpoint: $e'); + throw Exception('Failed to save reachability probe endpoint: $e'); + } + } + Future getAllowSelfSigned() async { try { final bool allowSelfSigned = diff --git a/lib/features/login_settings/data/repositories/login_settings_repository.dart b/lib/features/login_settings/data/repositories/login_settings_repository.dart index 46b2c5d..907a3d5 100644 --- a/lib/features/login_settings/data/repositories/login_settings_repository.dart +++ b/lib/features/login_settings/data/repositories/login_settings_repository.dart @@ -53,6 +53,22 @@ class LoginSettingsRepository { } } + Future getReachabilityProbe() async { + try { + return await loginSettingsLocalDataSource.getReachabilityProbe(); + } catch (e) { + return ''; + } + } + + Future saveReachabilityProbe(String endpoint) async { + try { + await loginSettingsLocalDataSource.saveReachabilityProbe(endpoint); + } catch (e) { + rethrow; + } + } + Future getAllowSelfSigned() async { try { return await loginSettingsLocalDataSource.getAllowSelfSigned(); diff --git a/lib/features/login_settings/presentation/pages/login_settings_page.dart b/lib/features/login_settings/presentation/pages/login_settings_page.dart index 42c5541..7a4125e 100644 --- a/lib/features/login_settings/presentation/pages/login_settings_page.dart +++ b/lib/features/login_settings/presentation/pages/login_settings_page.dart @@ -119,6 +119,16 @@ class _LoginSettingsPage extends State { ), _buildBasePathSection(context, state, localizations), + _buildSectionTitle( + context, + localizations.reachabilityCheckTitle, + ), + _buildReachabilityProbeSection( + context, + state, + localizations, + ), + _buildSectionTitle(context, localizations.sslSettings), _buildSSLSettingsSection(context, localizations, state), @@ -266,6 +276,77 @@ class _LoginSettingsPage extends State { ); } + Widget _buildReachabilityProbeSection( + BuildContext context, + LoginSettingsState state, + AppLocalizations localizations, + ) { + return Card( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + elevation: 3, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8.0)), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.network_check_rounded, + size: 28, + color: Theme.of(context).colorScheme.secondary, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + localizations.reachabilityCheckTitle, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 4), + Text( + localizations.reachabilityCheckDescription, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 24), + TextFormField( + initialValue: state.reachabilityProbe, + onChanged: (value) { + context.read().add( + UpdateReachabilityProbe(value), + ); + }, + decoration: InputDecoration( + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12.0), + ), + labelText: localizations.reachabilityCheckLabel, + hintText: localizations.reachabilityCheckHint, + prefixIcon: const Icon(Icons.wifi_tethering_rounded), + filled: true, + fillColor: Theme.of(context).colorScheme.surface, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 14.0, + ), + ), + ), + ], + ), + ), + ); + } + Widget _buildBasePathSection( BuildContext context, LoginSettingsState state, diff --git a/lib/features/offline/presentation/pages/offline_home_page.dart b/lib/features/offline/presentation/pages/offline_home_page.dart new file mode 100644 index 0000000..0d56d89 --- /dev/null +++ b/lib/features/offline/presentation/pages/offline_home_page.dart @@ -0,0 +1,200 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:calibre_web_companion/l10n/app_localizations.dart'; +import 'package:calibre_web_companion/features/offline/cubit/connectivity_cubit.dart'; +import 'package:calibre_web_companion/features/offline/presentation/pages/offline_library_page.dart'; +import 'package:calibre_web_companion/features/login_settings/presentation/pages/login_settings_page.dart'; +import 'package:calibre_web_companion/features/login_settings/presentation/pages/connection_diagnostics_page.dart'; +import 'package:calibre_web_companion/features/login/presentation/pages/login_page.dart'; + +class OfflineHomePage extends StatefulWidget { + const OfflineHomePage({super.key}); + + @override + State createState() => _OfflineHomePageState(); +} + +class _OfflineHomePageState extends State { + int _index = 0; + + @override + Widget build(BuildContext context) { + final localizations = AppLocalizations.of(context)!; + + return Scaffold( + appBar: AppBar( + title: Text(localizations.offline), + actions: [ + IconButton( + icon: const Icon(Icons.refresh_rounded), + tooltip: localizations.retry, + onPressed: () => context.read().recheck(), + ), + ], + ), + body: IndexedStack( + index: _index, + children: [ + _buildLibraryTab(context, localizations), + const _OfflineMenu(), + ], + ), + bottomNavigationBar: NavigationBar( + selectedIndex: _index, + labelBehavior: NavigationDestinationLabelBehavior.alwaysShow, + onDestinationSelected: (i) => setState(() => _index = i), + destinations: [ + NavigationDestination( + icon: const Icon(Icons.book_rounded), + label: localizations.books, + ), + NavigationDestination( + icon: const Icon(Icons.settings_rounded), + label: localizations.settings, + ), + ], + ), + ); + } + + Widget _buildLibraryTab( + BuildContext context, + AppLocalizations localizations, + ) { + final theme = Theme.of(context); + return Column( + children: [ + Container( + width: double.infinity, + color: theme.colorScheme.secondaryContainer, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row( + children: [ + Icon( + Icons.cloud_off_rounded, + size: 20, + color: theme.colorScheme.onSecondaryContainer, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + localizations.offlineBannerMessage, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSecondaryContainer, + ), + ), + ), + ], + ), + ), + const Expanded(child: OfflineLibraryPage()), + ], + ); + } +} + +class _OfflineMenu extends StatelessWidget { + const _OfflineMenu(); + + @override + Widget build(BuildContext context) { + final localizations = AppLocalizations.of(context)!; + + return ListView( + padding: const EdgeInsets.symmetric(vertical: 8), + children: [ + _MenuCard( + icon: Icons.refresh_rounded, + title: localizations.tryAgain, + onTap: () => context.read().recheck(), + ), + _MenuCard( + icon: Icons.link_rounded, + title: localizations.connectionSettings, + onTap: + () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const LoginSettingsPage()), + ), + ), + _MenuCard( + icon: Icons.troubleshoot_rounded, + title: localizations.connectionDiagnostics, + subtitle: localizations.connectionDiagnosticsSubtitle, + onTap: + () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const ConnectionDiagnosticsPage(), + ), + ), + ), + _MenuCard( + icon: Icons.manage_accounts_rounded, + title: localizations.accounts, + onTap: + () => Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => const LoginPage())), + ), + ], + ); + } +} + +class _MenuCard extends StatelessWidget { + final IconData icon; + final String title; + final String? subtitle; + final VoidCallback onTap; + + const _MenuCard({ + required this.icon, + required this.title, + required this.onTap, + this.subtitle, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Card( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + elevation: 3, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8.0)), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Icon(icon, size: 28, color: theme.colorScheme.secondary), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: theme.textTheme.titleMedium), + if (subtitle != null) ...[ + const SizedBox(height: 4), + Text( + subtitle!, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ), + ), + Icon( + Icons.chevron_right_rounded, + color: theme.colorScheme.onSurfaceVariant, + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 75df2fe..4dfa3a6 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -325,6 +325,10 @@ "basePathLabel": "Base Path", "basePathHint": "e.g., /opds or /calibre", "basePathDescription": "Define a custom base path for API requests", + "reachabilityCheckTitle": "Reachability Check", + "reachabilityCheckLabel": "Probe endpoint", + "reachabilityCheckHint": "e.g., /ajax/listbooks?limit=1", + "reachabilityCheckDescription": "The endpoint used to test whether the server is online. Leave empty to use the default for your server type.", "bookCover": "Book cover", "currentCover": "Current cover", "newCover": "New cover",