diff --git a/lib/api/api_provider.dart b/lib/api/api_provider.dart index 1dc396c..daffff8 100644 --- a/lib/api/api_provider.dart +++ b/lib/api/api_provider.dart @@ -2,6 +2,7 @@ import 'dart:convert'; +import 'package:http/http.dart'; import 'package:logging/logging.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:shelfsdk/audiobookshelf_api.dart'; @@ -10,6 +11,12 @@ import 'package:vaani/settings/api_settings_provider.dart'; part 'api_provider.g.dart'; +// TODO: workaround for https://github.com/rrousselGit/riverpod/issues/3718 +typedef ResponseErrorHandler = void Function( + Response response, [ + Object? error, +]); + final _logger = Logger('api_provider'); Uri makeBaseUrl(String address) { @@ -52,25 +59,33 @@ AudiobookshelfApi authenticatedApi(AuthenticatedApiRef ref) { /// ping the server to check if it is reachable @riverpod FutureOr isServerAlive(IsServerAliveRef ref, String address) async { - // return (await ref.watch(audiobookshelfApiProvider).server.ping()) ?? false; - // if address not starts with http or https, add https - - // !remove this line - // return true; - if (address.isEmpty) { return false; } - if (!address.startsWith('http') && !address.startsWith('https')) { - address = 'https://$address'; - } - // check url is valid - if (!Uri.parse(address).isAbsolute) { + try { + return await AudiobookshelfApi(baseUrl: makeBaseUrl(address)) + .server + .ping() ?? + false; + } catch (e) { return false; } - return await AudiobookshelfApi(baseUrl: Uri.parse(address)).server.ping() ?? - false; +} + +/// fetch status of server +@riverpod +FutureOr serverStatus( + ServerStatusRef ref, + Uri baseUrl, [ + ResponseErrorHandler? responseErrorHandler, +]) async { + _logger.fine('fetching server status: $baseUrl'); + final api = ref.watch(audiobookshelfApiProvider(baseUrl)); + final res = + await api.server.status(responseErrorHandler: responseErrorHandler); + _logger.fine('server status: $res'); + return res; } /// fetch the personalized view @@ -97,13 +112,18 @@ class PersonalizedView extends _$PersonalizedView { ) ?? await apiResponseCacheManager.getFileFromCache(key); if (cachedRes != null) { - final resJson = jsonDecode(await cachedRes.file.readAsString()) as List; - final res = [ - for (final item in resJson) - Shelf.fromJson(item as Map), - ]; - _logger.fine('reading from cache: $cachedRes'); - yield res; + _logger.fine('reading from cache: $cachedRes for key: $key'); + try { + final resJson = jsonDecode(await cachedRes.file.readAsString()) as List; + final res = [ + for (final item in resJson) + Shelf.fromJson(item as Map), + ]; + _logger.fine('successfully read from cache key: $key'); + yield res; + } catch (e) { + _logger.warning('error reading from cache: $e\n$cachedRes'); + } } // ! exagerated delay @@ -112,14 +132,20 @@ class PersonalizedView extends _$PersonalizedView { .getPersonalized(libraryId: apiSettings.activeLibraryId!); // debugPrint('personalizedView: ${res!.map((e) => e).toSet()}'); // save to cache - final newFile = await apiResponseCacheManager.putFile( - key, - utf8.encode(jsonEncode(res)), - fileExtension: 'json', - key: key, - ); - _logger.fine('writing to cache: $newFile'); - yield res!; + if (res != null) { + final newFile = await apiResponseCacheManager.putFile( + key, + utf8.encode(jsonEncode(res)), + fileExtension: 'json', + key: key, + ); + _logger.fine('writing to cache: $newFile'); + yield res; + } else { + _logger.warning('failed to fetch personalized view'); + yield []; + } + } // method to force refresh the view and ignore the cache diff --git a/lib/api/api_provider.g.dart b/lib/api/api_provider.g.dart index 3c86618..67e86e2 100644 --- a/lib/api/api_provider.g.dart +++ b/lib/api/api_provider.g.dart @@ -187,7 +187,7 @@ final authenticatedApiProvider = Provider.internal( ); typedef AuthenticatedApiRef = ProviderRef; -String _$isServerAliveHash() => r'f839350795fbdeb0ca1d5f0c84a9065cac4dd40a'; +String _$isServerAliveHash() => r'6ff90b6e0febd2cd4a4d3a5209a59afc778cd3b6'; /// ping the server to check if it is reachable /// @@ -327,6 +327,166 @@ class _IsServerAliveProviderElement String get address => (origin as IsServerAliveProvider).address; } +String _$serverStatusHash() => r'2739906a1862d09b098588ebd16749a09032ee99'; + +/// fetch status of server +/// +/// Copied from [serverStatus]. +@ProviderFor(serverStatus) +const serverStatusProvider = ServerStatusFamily(); + +/// fetch status of server +/// +/// Copied from [serverStatus]. +class ServerStatusFamily extends Family> { + /// fetch status of server + /// + /// Copied from [serverStatus]. + const ServerStatusFamily(); + + /// fetch status of server + /// + /// Copied from [serverStatus]. + ServerStatusProvider call( + Uri baseUrl, [ + void Function(Response, [Object?])? responseErrorHandler, + ]) { + return ServerStatusProvider( + baseUrl, + responseErrorHandler, + ); + } + + @override + ServerStatusProvider getProviderOverride( + covariant ServerStatusProvider provider, + ) { + return call( + provider.baseUrl, + provider.responseErrorHandler, + ); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'serverStatusProvider'; +} + +/// fetch status of server +/// +/// Copied from [serverStatus]. +class ServerStatusProvider + extends AutoDisposeFutureProvider { + /// fetch status of server + /// + /// Copied from [serverStatus]. + ServerStatusProvider( + Uri baseUrl, [ + void Function(Response, [Object?])? responseErrorHandler, + ]) : this._internal( + (ref) => serverStatus( + ref as ServerStatusRef, + baseUrl, + responseErrorHandler, + ), + from: serverStatusProvider, + name: r'serverStatusProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$serverStatusHash, + dependencies: ServerStatusFamily._dependencies, + allTransitiveDependencies: + ServerStatusFamily._allTransitiveDependencies, + baseUrl: baseUrl, + responseErrorHandler: responseErrorHandler, + ); + + ServerStatusProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.baseUrl, + required this.responseErrorHandler, + }) : super.internal(); + + final Uri baseUrl; + final void Function(Response, [Object?])? responseErrorHandler; + + @override + Override overrideWith( + FutureOr Function(ServerStatusRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: ServerStatusProvider._internal( + (ref) => create(ref as ServerStatusRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + baseUrl: baseUrl, + responseErrorHandler: responseErrorHandler, + ), + ); + } + + @override + AutoDisposeFutureProviderElement createElement() { + return _ServerStatusProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is ServerStatusProvider && + other.baseUrl == baseUrl && + other.responseErrorHandler == responseErrorHandler; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, baseUrl.hashCode); + hash = _SystemHash.combine(hash, responseErrorHandler.hashCode); + + return _SystemHash.finish(hash); + } +} + +mixin ServerStatusRef on AutoDisposeFutureProviderRef { + /// The parameter `baseUrl` of this provider. + Uri get baseUrl; + + /// The parameter `responseErrorHandler` of this provider. + void Function(Response, [Object?])? get responseErrorHandler; +} + +class _ServerStatusProviderElement + extends AutoDisposeFutureProviderElement + with ServerStatusRef { + _ServerStatusProviderElement(super.provider); + + @override + Uri get baseUrl => (origin as ServerStatusProvider).baseUrl; + @override + void Function(Response, [Object?])? get responseErrorHandler => + (origin as ServerStatusProvider).responseErrorHandler; +} + String _$fetchContinueListeningHash() => r'f65fe3ac3a31b8ac074330525c5d2cc4b526802d'; @@ -361,7 +521,7 @@ final meProvider = AutoDisposeFutureProvider.internal( ); typedef MeRef = AutoDisposeFutureProviderRef; -String _$personalizedViewHash() => r'dada8d72845ffd516f731f88193941f7ebdd47ed'; +String _$personalizedViewHash() => r'4c392ece4650bdc36d7195a0ddb8810e8fe4caa9'; /// fetch the personalized view /// diff --git a/lib/api/authenticated_user_provider.dart b/lib/api/authenticated_user_provider.dart index 263f5f2..c10f799 100644 --- a/lib/api/authenticated_user_provider.dart +++ b/lib/api/authenticated_user_provider.dart @@ -52,8 +52,16 @@ class AuthenticatedUser extends _$AuthenticatedUser { _logger.fine('writing state to box: $state'); } - void addUser(model.AuthenticatedUser user) { + void addUser(model.AuthenticatedUser user, {bool setActive = false}) { state = state..add(user); + if (setActive) { + final apiSettings = ref.read(apiSettingsProvider); + ref.read(apiSettingsProvider.notifier).updateState( + apiSettings.copyWith( + activeUser: user, + ), + ); + } } void removeUsersOfServer(AudiobookShelfServer registeredServer) { diff --git a/lib/api/authenticated_user_provider.g.dart b/lib/api/authenticated_user_provider.g.dart index cd41f52..7fca094 100644 --- a/lib/api/authenticated_user_provider.g.dart +++ b/lib/api/authenticated_user_provider.g.dart @@ -6,7 +6,7 @@ part of 'authenticated_user_provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$authenticatedUserHash() => r'8578d7fda1755ecacce6853076da4149e4ebe3e7'; +String _$authenticatedUserHash() => r'308f19b33ae04af6340fb83167fa64aa23400a09'; /// provides with a set of authenticated users /// diff --git a/lib/features/onboarding/view/onboarding_single_page.dart b/lib/features/onboarding/view/onboarding_single_page.dart index 1539d64..5ceff08 100644 --- a/lib/features/onboarding/view/onboarding_single_page.dart +++ b/lib/features/onboarding/view/onboarding_single_page.dart @@ -1,15 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:vaani/api/api_provider.dart'; -import 'package:vaani/api/authenticated_user_provider.dart'; -import 'package:vaani/api/server_provider.dart'; import 'package:vaani/features/onboarding/view/user_login.dart'; -import 'package:vaani/router/router.dart'; import 'package:vaani/settings/api_settings_provider.dart'; -import 'package:vaani/settings/models/models.dart' as model; import 'package:vaani/shared/utils.dart'; import 'package:vaani/shared/widgets/add_new_server.dart'; @@ -26,80 +21,22 @@ class OnboardingSinglePage extends HookConsumerWidget { ); var audiobookshelfUri = makeBaseUrl(serverUriController.text); - final api = ref.watch(audiobookshelfApiProvider(audiobookshelfUri)); + final canUserLogin = useState(apiSettings.activeServer != null); - final isUserLoginAvailable = useState(apiSettings.activeServer != null); - - final usernameController = useTextEditingController(); - final passwordController = useTextEditingController(); - - void addServer() { - var newServer = serverUriController.text.isEmpty - ? null - : model.AudiobookShelfServer( - serverUrl: Uri.parse(serverUriController.text), - ); - try { - // add the server to the list of servers - if (newServer != null) { - ref.read(audiobookShelfServerProvider.notifier).addServer( - newServer, - ); - } - // else remove the server from the list of servers - else if (apiSettings.activeServer != null) { - ref - .read(audiobookShelfServerProvider.notifier) - .removeServer(apiSettings.activeServer!); - } - } on ServerAlreadyExistsException catch (e) { - newServer = e.server; - } finally { - ref.read(apiSettingsProvider.notifier).updateState( - apiSettings.copyWith( - activeServer: newServer, - ), - ); - } - } - - /// Login to the server and save the user - Future loginAndSave() async { - final username = usernameController.text; - final password = passwordController.text; - final success = await api.login(username: username, password: password); - if (success != null) { - // save the server - addServer(); - var authenticatedUser = model.AuthenticatedUser( - server: model.AudiobookShelfServer( - serverUrl: Uri.parse(serverUriController.text), - ), - id: success.user.id, - password: password, - username: username, - authToken: api.token!, - ); - // add the user to the list of users - ref.read(authenticatedUserProvider.notifier).addUser(authenticatedUser); - - // set the active user - ref.read(apiSettingsProvider.notifier).updateState( - apiSettings.copyWith( - activeUser: authenticatedUser, - ), - ); - - // redirect to the library page - GoRouter.of(context).goNamed(Routes.home.name); - } else { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Login failed. Please check your credentials.'), - ), - ); - // give focus back to the username field - } + fadeSlideTransitionBuilder( + Widget child, + Animation animation, + ) { + return FadeTransition( + opacity: animation, + child: SlideTransition( + position: Tween( + begin: const Offset(0, 0.3), + end: const Offset(0, 0), + ).animate(animation), + child: child, + ), + ); } return Scaffold( @@ -118,9 +55,20 @@ class OnboardingSinglePage extends HookConsumerWidget { ), Padding( padding: const EdgeInsets.all(8.0), - child: Text( - 'Please enter the URL of your AudiobookShelf Server', - style: Theme.of(context).textTheme.bodyMedium, + child: AnimatedSwitcher( + duration: 500.ms, + transitionBuilder: fadeSlideTransitionBuilder, + child: canUserLogin.value + ? Text( + 'Server connected, please login', + key: const ValueKey('connected'), + style: Theme.of(context).textTheme.bodyMedium, + ) + : Text( + 'Please enter the URL of your AudiobookShelf Server', + key: const ValueKey('not_connected'), + style: Theme.of(context).textTheme.bodyMedium, + ), ), ), Padding( @@ -129,30 +77,16 @@ class OnboardingSinglePage extends HookConsumerWidget { controller: serverUriController, allowEmpty: true, onPressed: () { - isUserLoginAvailable.value = - serverUriController.text.isNotEmpty; + canUserLogin.value = serverUriController.text.isNotEmpty; }, ), ), AnimatedSwitcher( duration: 500.ms, - transitionBuilder: (child, animation) { - return FadeTransition( - opacity: animation, - child: SlideTransition( - position: Tween( - begin: const Offset(0, 0.3), - end: const Offset(0, 0), - ).animate(animation), - child: child, - ), - ); - }, - child: isUserLoginAvailable.value - ? UserLogin( - passwordController: passwordController, - usernameController: usernameController, - onPressed: loginAndSave, + transitionBuilder: fadeSlideTransitionBuilder, + child: canUserLogin.value + ? UserLoginWidget( + server: audiobookshelfUri, ) // ).animate().fade(duration: 600.ms).slideY(begin: 0.3, end: 0) : const RedirectToABS().animate().fadeIn().slideY( diff --git a/lib/features/onboarding/view/user_login.dart b/lib/features/onboarding/view/user_login.dart index 4d904f0..3df652d 100644 --- a/lib/features/onboarding/view/user_login.dart +++ b/lib/features/onboarding/view/user_login.dart @@ -1,30 +1,343 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lottie/lottie.dart'; +import 'package:shelfsdk/audiobookshelf_api.dart'; +import 'package:vaani/api/api_provider.dart'; +import 'package:vaani/api/authenticated_user_provider.dart'; +import 'package:vaani/api/server_provider.dart'; import 'package:vaani/hacks/fix_autofill_losing_focus.dart'; +import 'package:vaani/models/error_response.dart'; +import 'package:vaani/router/router.dart'; +import 'package:vaani/settings/api_settings_provider.dart'; +import 'package:vaani/settings/models/models.dart' as model; -class UserLogin extends HookConsumerWidget { - UserLogin({ +class UserLoginWidget extends HookConsumerWidget { + UserLoginWidget({ super.key, - this.usernameController, - this.passwordController, + required this.server, + }); + + final Uri server; + final serverStatusError = ErrorResponse(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final serverStatus = + ref.watch(serverStatusProvider(server, serverStatusError.storeError)); + + final api = ref.watch(audiobookshelfApiProvider(server)); + + return serverStatus.when( + data: (value) { + if (value == null) { + // check the error message + return Text(serverStatusError.response.body); + } + // check available authentication methods and return the correct widget + return UserLoginMultipleAuth( + server: server, + localAvailable: + value.authMethods?.contains(AuthMethod.local) ?? false, + openidAvailable: + value.authMethods?.contains(AuthMethod.openid) ?? false, + ); + }, + loading: () { + return const Center( + child: CircularProgressIndicator(), + ); + }, + error: (error, _) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('Server is not reachable: $error'), + ElevatedButton( + onPressed: () { + ref.invalidate( + serverStatusProvider( + server, + serverStatusError.storeError, + ), + ); + }, + child: const Text('Try again'), + ), + ], + ), + ); + }, + ); + } +} + +enum AuthMethodChoice { + local, + openid, + authToken, +} + +class UserLoginMultipleAuth extends HookConsumerWidget { + const UserLoginMultipleAuth({ + super.key, + required this.server, + this.localAvailable = false, + this.openidAvailable = false, this.onPressed, }); - TextEditingController? usernameController; - TextEditingController? passwordController; + final Uri server; + final bool localAvailable; + final bool openidAvailable; final void Function()? onPressed; @override Widget build(BuildContext context, WidgetRef ref) { - usernameController ??= useTextEditingController(); - passwordController ??= useTextEditingController(); + // will show choice chips for the available authentication methods + // authToken method is always available + final methodChoice = useState( + localAvailable ? AuthMethodChoice.local : AuthMethodChoice.authToken, + ); + + final apiSettings = ref.watch(apiSettingsProvider); + + model.AudiobookShelfServer addServer() { + var newServer = model.AudiobookShelfServer( + serverUrl: server, + ); + try { + // add the server to the list of servers + ref.read(audiobookShelfServerProvider.notifier).addServer( + newServer, + ); + } on ServerAlreadyExistsException catch (e) { + newServer = e.server; + } finally { + ref.read(apiSettingsProvider.notifier).updateState( + apiSettings.copyWith( + activeServer: newServer, + ), + ); + } + return newServer; + } + + return Center( + child: InactiveFocusScopeObserver( + child: AutofillGroup( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + children: [ + Wrap( + // mainAxisAlignment: MainAxisAlignment.center, + spacing: 10, + runAlignment: WrapAlignment.center, + runSpacing: 10, + alignment: WrapAlignment.center, + children: [ + // a small label to show the user what to do + if (localAvailable) + ChoiceChip( + label: const Text('Local'), + selected: methodChoice.value == AuthMethodChoice.local, + onSelected: (selected) { + if (selected) { + methodChoice.value = AuthMethodChoice.local; + } + }, + ), + if (openidAvailable) + ChoiceChip( + label: const Text('OpenID'), + selected: methodChoice.value == AuthMethodChoice.openid, + onSelected: (selected) { + if (selected) { + methodChoice.value = AuthMethodChoice.openid; + } + }, + ), + ChoiceChip( + label: const Text('Token'), + selected: + methodChoice.value == AuthMethodChoice.authToken, + onSelected: (selected) { + if (selected) { + methodChoice.value = AuthMethodChoice.authToken; + } + }, + ), + ], + ), + const SizedBox.square( + dimension: 8, + ), + switch (methodChoice.value) { + AuthMethodChoice.local => UserLoginWithPassword( + server: server, + addServer: addServer, + ), + AuthMethodChoice.openid => _UserLoginWithOpenID( + server: server, + addServer: addServer, + ), + AuthMethodChoice.authToken => UserLoginWithToken( + server: server, + addServer: addServer, + ), + }, + ], + ), + ), + ), + ), + ); + } +} + +class _UserLoginWithOpenID extends HookConsumerWidget { + const _UserLoginWithOpenID({ + super.key, + required this.server, + required this.addServer, + }); + + final Uri server; + final model.AudiobookShelfServer Function() addServer; + + @override + Widget build(BuildContext context, WidgetRef ref) { + // TODO: implement build + return const Text('OpenID'); + } +} + +class UserLoginWithToken extends HookConsumerWidget { + UserLoginWithToken({ + super.key, + required this.server, + required this.addServer, + }); + + final Uri server; + final model.AudiobookShelfServer Function() addServer; + final serverErrorResponse = ErrorResponse(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final authTokensController = useTextEditingController(); + + final api = ref.watch(audiobookshelfApiProvider(server)); + Future loginAndSave() async { + api.token = authTokensController.text; + model.AuthenticatedUser? authenticatedUser; + LoginResponse? success; + try { + success = await api.misc.authorize( + responseErrorHandler: serverErrorResponse.storeError, + ); + if (success == null) { + throw StateError('No response from server'); + } + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Login failed. Got response: ${serverErrorResponse.response.body} (${serverErrorResponse.response.statusCode})', + ), + action: SnackBarAction( + label: 'See Error', + onPressed: () { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Error'), + content: Text(e.toString()), + ), + ); + }, + ), + ), + ); + return; + } + authenticatedUser = model.AuthenticatedUser( + server: addServer(), + id: success.user.id, + username: success.user.username, + authToken: api.token!, + ); + + ref + .read(authenticatedUserProvider.notifier) + .addUser(authenticatedUser, setActive: true); + + context.goNamed(Routes.home.name); + } + + return Form( + child: Column( + children: [ + TextFormField( + controller: authTokensController, + autofocus: true, + textInputAction: TextInputAction.done, + maxLines: 10, + minLines: 1, + decoration: InputDecoration( + labelText: 'API Token', + labelStyle: TextStyle( + color: Theme.of(context).colorScheme.onSurface.withOpacity(0.8), + ), + border: const OutlineInputBorder(), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter an API token'; + } + return null; + }, + onFieldSubmitted: (_) async { + await loginAndSave(); + }, + ), + const SizedBox(height: 10), + ElevatedButton( + onPressed: loginAndSave, + child: const Text('Login'), + ), + ], + ), + ); + } +} + +// class _UserLoginWithToken extends HookConsumerWidget { + +class UserLoginWithPassword extends HookConsumerWidget { + UserLoginWithPassword({ + super.key, + required this.server, + required this.addServer, + }); + + final Uri server; + final model.AudiobookShelfServer Function() addServer; + final serverErrorResponse = ErrorResponse(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final usernameController = useTextEditingController(); + final passwordController = useTextEditingController(); final isPasswordVisibleAnimationController = useAnimationController( duration: const Duration(milliseconds: 500), ); var isPasswordVisible = useState(false); + final api = ref.watch(audiobookshelfApiProvider(server)); // forward animation when the password visibility changes useEffect( @@ -39,6 +352,61 @@ class UserLogin extends HookConsumerWidget { [isPasswordVisible.value], ); + /// Login to the server and save the user + Future loginAndSave() async { + final username = usernameController.text; + final password = passwordController.text; + + LoginResponse? success; + try { + success = await api.login( + username: username, + password: password, + responseErrorHandler: serverErrorResponse.storeError, + ); + if (success == null) { + throw StateError('No response from server'); + } + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Login failed. Got response: ${serverErrorResponse.response.body} (${serverErrorResponse.response.statusCode})', + ), + action: SnackBarAction( + label: 'See Error', + onPressed: () { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Error'), + content: Text(e.toString()), + ), + ); + }, + ), + ), + ); + + return; + } + // save the server + final authenticatedUser = model.AuthenticatedUser( + server: addServer(), + id: success.user.id, + password: password, + username: username, + authToken: api.token!, + ); + // add the user to the list of users + ref + .read(authenticatedUserProvider.notifier) + .addUser(authenticatedUser, setActive: true); + + // redirect to the library page + GoRouter.of(context).goNamed(Routes.home.name); + } + return Center( child: InactiveFocusScopeObserver( child: AutofillGroup( @@ -69,11 +437,9 @@ class UserLogin extends HookConsumerWidget { autofillHints: const [AutofillHints.password], textInputAction: TextInputAction.done, obscureText: !isPasswordVisible.value, - onFieldSubmitted: onPressed != null - ? (_) { - onPressed!(); - } - : null, + onFieldSubmitted: (_) { + loginAndSave(); + }, decoration: InputDecoration( labelText: 'Password', labelStyle: TextStyle( @@ -109,7 +475,7 @@ class UserLogin extends HookConsumerWidget { ), const SizedBox(height: 30), ElevatedButton( - onPressed: onPressed, + onPressed: loginAndSave, child: const Text('Login'), ), ], diff --git a/lib/features/you/view/server_manager.dart b/lib/features/you/view/server_manager.dart index a7d7184..3ac254b 100644 --- a/lib/features/you/view/server_manager.dart +++ b/lib/features/you/view/server_manager.dart @@ -5,6 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:vaani/api/api_provider.dart'; import 'package:vaani/api/authenticated_user_provider.dart'; import 'package:vaani/api/server_provider.dart'; +import 'package:vaani/models/error_response.dart'; import 'package:vaani/router/router.dart'; import 'package:vaani/settings/api_settings_provider.dart'; import 'package:vaani/settings/models/models.dart' as model; @@ -227,7 +228,7 @@ class ServerManagerPage extends HookConsumerWidget { if (formKey.currentState!.validate()) { try { final newServer = model.AudiobookShelfServer( - serverUrl: Uri.parse(serverURIController.text), + serverUrl: makeBaseUrl(serverURIController.text), ); ref .read(audiobookShelfServerProvider.notifier) @@ -285,12 +286,16 @@ class _AddUserDialog extends HookConsumerWidget { final formKey = GlobalKey(); + final serverErrorResponse = ErrorResponse(); + /// Login to the server and save the user Future loginAndSave() async { model.AuthenticatedUser? authenticatedUser; if (isMethodAuth.value) { api.token = authTokensController.text; - final success = await api.misc.authorize(); + final success = await api.misc.authorize( + responseErrorHandler: serverErrorResponse.storeError, + ); if (success != null) { authenticatedUser = model.AuthenticatedUser( server: server, @@ -302,7 +307,11 @@ class _AddUserDialog extends HookConsumerWidget { } else { final username = usernameController.text; final password = passwordController.text; - final success = await api.login(username: username, password: password); + final success = await api.login( + username: username, + password: password, + responseErrorHandler: serverErrorResponse.storeError, + ); if (success != null) { authenticatedUser = model.AuthenticatedUser( server: server, @@ -317,8 +326,10 @@ class _AddUserDialog extends HookConsumerWidget { ref.read(authenticatedUserProvider.notifier).addUser(authenticatedUser); } else { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Login failed. Please check your credentials.'), + SnackBar( + content: Text( + 'Login failed. Got response: ${serverErrorResponse.response.body} (${serverErrorResponse.response.statusCode})', + ), ), ); } @@ -326,7 +337,23 @@ class _AddUserDialog extends HookConsumerWidget { } return AlertDialog( - title: const Text('Add User'), + // title: Text('Add User for ${server.serverUrl}'), + title: Text.rich( + TextSpan( + children: [ + TextSpan( + text: 'Add User for ', + style: Theme.of(context).textTheme.labelLarge, + ), + TextSpan( + text: server.serverUrl.toString(), + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: Theme.of(context).colorScheme.primary, + ), + ), + ], + ), + ), content: Form( key: formKey, child: Column( diff --git a/lib/models/error_response.dart b/lib/models/error_response.dart new file mode 100644 index 0000000..d6021c5 --- /dev/null +++ b/lib/models/error_response.dart @@ -0,0 +1,21 @@ +import 'package:http/http.dart' as http; +import 'package:logging/logging.dart'; + +final _logger = Logger('ErrorResponse'); + +class ErrorResponse { + String? name; + http.Response _response; + + ErrorResponse({ + this.name, + http.Response? response, + }) : _response = response ?? http.Response('', 418); + + void storeError(http.Response response, [Object? error]) { + _logger.warning('for $name got response: $response'); + _response = response; + } + + http.Response get response => _response; +} diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index 1dd9be8..ce78886 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -39,6 +39,23 @@ class HomePage extends HookConsumerWidget { body: Container( child: views.when( data: (data) { + if (data.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('No shelves to display'), + // try again button + ElevatedButton( + onPressed: () { + ref.invalidate(personalizedViewProvider); + }, + child: const Text('Try again'), + ), + ], + ), + ); + } final shelvesToDisplay = data // .where((element) => !element.id.contains('discover')) .map((shelf) { diff --git a/lib/router/models/library_item_extras.freezed.dart b/lib/router/models/library_item_extras.freezed.dart index b27f20b..00c6385 100644 --- a/lib/router/models/library_item_extras.freezed.dart +++ b/lib/router/models/library_item_extras.freezed.dart @@ -20,7 +20,9 @@ mixin _$LibraryItemExtras { String get heroTagSuffix => throw _privateConstructorUsedError; Uint8List? get coverImage => throw _privateConstructorUsedError; - @JsonKey(ignore: true) + /// Create a copy of LibraryItemExtras + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) $LibraryItemExtrasCopyWith get copyWith => throw _privateConstructorUsedError; } @@ -44,6 +46,8 @@ class _$LibraryItemExtrasCopyWithImpl<$Res, $Val extends LibraryItemExtras> // ignore: unused_field final $Res Function($Val) _then; + /// Create a copy of LibraryItemExtras + /// with the given fields replaced by the non-null parameter values. @pragma('vm:prefer-inline') @override $Res call({ @@ -87,6 +91,8 @@ class __$$LibraryItemExtrasImplCopyWithImpl<$Res> $Res Function(_$LibraryItemExtrasImpl) _then) : super(_value, _then); + /// Create a copy of LibraryItemExtras + /// with the given fields replaced by the non-null parameter values. @pragma('vm:prefer-inline') @override $Res call({ @@ -146,7 +152,9 @@ class _$LibraryItemExtrasImpl implements _LibraryItemExtras { int get hashCode => Object.hash(runtimeType, book, heroTagSuffix, const DeepCollectionEquality().hash(coverImage)); - @JsonKey(ignore: true) + /// Create a copy of LibraryItemExtras + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) @override @pragma('vm:prefer-inline') _$$LibraryItemExtrasImplCopyWith<_$LibraryItemExtrasImpl> get copyWith => @@ -166,8 +174,11 @@ abstract class _LibraryItemExtras implements LibraryItemExtras { String get heroTagSuffix; @override Uint8List? get coverImage; + + /// Create a copy of LibraryItemExtras + /// with the given fields replaced by the non-null parameter values. @override - @JsonKey(ignore: true) + @JsonKey(includeFromJson: false, includeToJson: false) _$$LibraryItemExtrasImplCopyWith<_$LibraryItemExtrasImpl> get copyWith => throw _privateConstructorUsedError; } diff --git a/lib/settings/models/api_settings.freezed.dart b/lib/settings/models/api_settings.freezed.dart index ca20663..2f3c6e5 100644 --- a/lib/settings/models/api_settings.freezed.dart +++ b/lib/settings/models/api_settings.freezed.dart @@ -24,8 +24,12 @@ mixin _$ApiSettings { AuthenticatedUser? get activeUser => throw _privateConstructorUsedError; String? get activeLibraryId => throw _privateConstructorUsedError; + /// Serializes this ApiSettings to a JSON map. Map toJson() => throw _privateConstructorUsedError; - @JsonKey(ignore: true) + + /// Create a copy of ApiSettings + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) $ApiSettingsCopyWith get copyWith => throw _privateConstructorUsedError; } @@ -55,6 +59,8 @@ class _$ApiSettingsCopyWithImpl<$Res, $Val extends ApiSettings> // ignore: unused_field final $Res Function($Val) _then; + /// Create a copy of ApiSettings + /// with the given fields replaced by the non-null parameter values. @pragma('vm:prefer-inline') @override $Res call({ @@ -78,6 +84,8 @@ class _$ApiSettingsCopyWithImpl<$Res, $Val extends ApiSettings> ) as $Val); } + /// Create a copy of ApiSettings + /// with the given fields replaced by the non-null parameter values. @override @pragma('vm:prefer-inline') $AudiobookShelfServerCopyWith<$Res>? get activeServer { @@ -90,6 +98,8 @@ class _$ApiSettingsCopyWithImpl<$Res, $Val extends ApiSettings> }); } + /// Create a copy of ApiSettings + /// with the given fields replaced by the non-null parameter values. @override @pragma('vm:prefer-inline') $AuthenticatedUserCopyWith<$Res>? get activeUser { @@ -130,6 +140,8 @@ class __$$ApiSettingsImplCopyWithImpl<$Res> _$ApiSettingsImpl _value, $Res Function(_$ApiSettingsImpl) _then) : super(_value, _then); + /// Create a copy of ApiSettings + /// with the given fields replaced by the non-null parameter values. @pragma('vm:prefer-inline') @override $Res call({ @@ -188,12 +200,14 @@ class _$ApiSettingsImpl implements _ApiSettings { other.activeLibraryId == activeLibraryId)); } - @JsonKey(ignore: true) + @JsonKey(includeFromJson: false, includeToJson: false) @override int get hashCode => Object.hash(runtimeType, activeServer, activeUser, activeLibraryId); - @JsonKey(ignore: true) + /// Create a copy of ApiSettings + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) @override @pragma('vm:prefer-inline') _$$ApiSettingsImplCopyWith<_$ApiSettingsImpl> get copyWith => @@ -222,8 +236,11 @@ abstract class _ApiSettings implements ApiSettings { AuthenticatedUser? get activeUser; @override String? get activeLibraryId; + + /// Create a copy of ApiSettings + /// with the given fields replaced by the non-null parameter values. @override - @JsonKey(ignore: true) + @JsonKey(includeFromJson: false, includeToJson: false) _$$ApiSettingsImplCopyWith<_$ApiSettingsImpl> get copyWith => throw _privateConstructorUsedError; } diff --git a/lib/settings/models/app_settings.freezed.dart b/lib/settings/models/app_settings.freezed.dart index 80544cc..17311fb 100644 --- a/lib/settings/models/app_settings.freezed.dart +++ b/lib/settings/models/app_settings.freezed.dart @@ -24,8 +24,12 @@ mixin _$AppSettings { PlayerSettings get playerSettings => throw _privateConstructorUsedError; DownloadSettings get downloadSettings => throw _privateConstructorUsedError; + /// Serializes this AppSettings to a JSON map. Map toJson() => throw _privateConstructorUsedError; - @JsonKey(ignore: true) + + /// Create a copy of AppSettings + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) $AppSettingsCopyWith get copyWith => throw _privateConstructorUsedError; } @@ -56,6 +60,8 @@ class _$AppSettingsCopyWithImpl<$Res, $Val extends AppSettings> // ignore: unused_field final $Res Function($Val) _then; + /// Create a copy of AppSettings + /// with the given fields replaced by the non-null parameter values. @pragma('vm:prefer-inline') @override $Res call({ @@ -79,6 +85,8 @@ class _$AppSettingsCopyWithImpl<$Res, $Val extends AppSettings> ) as $Val); } + /// Create a copy of AppSettings + /// with the given fields replaced by the non-null parameter values. @override @pragma('vm:prefer-inline') $ThemeSettingsCopyWith<$Res> get themeSettings { @@ -87,6 +95,8 @@ class _$AppSettingsCopyWithImpl<$Res, $Val extends AppSettings> }); } + /// Create a copy of AppSettings + /// with the given fields replaced by the non-null parameter values. @override @pragma('vm:prefer-inline') $PlayerSettingsCopyWith<$Res> get playerSettings { @@ -95,6 +105,8 @@ class _$AppSettingsCopyWithImpl<$Res, $Val extends AppSettings> }); } + /// Create a copy of AppSettings + /// with the given fields replaced by the non-null parameter values. @override @pragma('vm:prefer-inline') $DownloadSettingsCopyWith<$Res> get downloadSettings { @@ -133,6 +145,8 @@ class __$$AppSettingsImplCopyWithImpl<$Res> _$AppSettingsImpl _value, $Res Function(_$AppSettingsImpl) _then) : super(_value, _then); + /// Create a copy of AppSettings + /// with the given fields replaced by the non-null parameter values. @pragma('vm:prefer-inline') @override $Res call({ @@ -196,12 +210,14 @@ class _$AppSettingsImpl implements _AppSettings { other.downloadSettings == downloadSettings)); } - @JsonKey(ignore: true) + @JsonKey(includeFromJson: false, includeToJson: false) @override int get hashCode => Object.hash(runtimeType, themeSettings, playerSettings, downloadSettings); - @JsonKey(ignore: true) + /// Create a copy of AppSettings + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) @override @pragma('vm:prefer-inline') _$$AppSettingsImplCopyWith<_$AppSettingsImpl> get copyWith => @@ -230,8 +246,11 @@ abstract class _AppSettings implements AppSettings { PlayerSettings get playerSettings; @override DownloadSettings get downloadSettings; + + /// Create a copy of AppSettings + /// with the given fields replaced by the non-null parameter values. @override - @JsonKey(ignore: true) + @JsonKey(includeFromJson: false, includeToJson: false) _$$AppSettingsImplCopyWith<_$AppSettingsImpl> get copyWith => throw _privateConstructorUsedError; } @@ -247,8 +266,12 @@ mixin _$ThemeSettings { bool get useCurrentPlayerThemeThroughoutApp => throw _privateConstructorUsedError; + /// Serializes this ThemeSettings to a JSON map. Map toJson() => throw _privateConstructorUsedError; - @JsonKey(ignore: true) + + /// Create a copy of ThemeSettings + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) $ThemeSettingsCopyWith get copyWith => throw _privateConstructorUsedError; } @@ -275,6 +298,8 @@ class _$ThemeSettingsCopyWithImpl<$Res, $Val extends ThemeSettings> // ignore: unused_field final $Res Function($Val) _then; + /// Create a copy of ThemeSettings + /// with the given fields replaced by the non-null parameter values. @pragma('vm:prefer-inline') @override $Res call({ @@ -322,6 +347,8 @@ class __$$ThemeSettingsImplCopyWithImpl<$Res> _$ThemeSettingsImpl _value, $Res Function(_$ThemeSettingsImpl) _then) : super(_value, _then); + /// Create a copy of ThemeSettings + /// with the given fields replaced by the non-null parameter values. @pragma('vm:prefer-inline') @override $Res call({ @@ -390,12 +417,14 @@ class _$ThemeSettingsImpl implements _ThemeSettings { useCurrentPlayerThemeThroughoutApp)); } - @JsonKey(ignore: true) + @JsonKey(includeFromJson: false, includeToJson: false) @override int get hashCode => Object.hash(runtimeType, isDarkMode, useMaterialThemeOnItemPage, useCurrentPlayerThemeThroughoutApp); - @JsonKey(ignore: true) + /// Create a copy of ThemeSettings + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) @override @pragma('vm:prefer-inline') _$$ThemeSettingsImplCopyWith<_$ThemeSettingsImpl> get copyWith => @@ -424,8 +453,11 @@ abstract class _ThemeSettings implements ThemeSettings { bool get useMaterialThemeOnItemPage; @override bool get useCurrentPlayerThemeThroughoutApp; + + /// Create a copy of ThemeSettings + /// with the given fields replaced by the non-null parameter values. @override - @JsonKey(ignore: true) + @JsonKey(includeFromJson: false, includeToJson: false) _$$ThemeSettingsImplCopyWith<_$ThemeSettingsImpl> get copyWith => throw _privateConstructorUsedError; } @@ -447,8 +479,12 @@ mixin _$PlayerSettings { throw _privateConstructorUsedError; Duration get playbackReportInterval => throw _privateConstructorUsedError; + /// Serializes this PlayerSettings to a JSON map. Map toJson() => throw _privateConstructorUsedError; - @JsonKey(ignore: true) + + /// Create a copy of PlayerSettings + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) $PlayerSettingsCopyWith get copyWith => throw _privateConstructorUsedError; } @@ -483,6 +519,8 @@ class _$PlayerSettingsCopyWithImpl<$Res, $Val extends PlayerSettings> // ignore: unused_field final $Res Function($Val) _then; + /// Create a copy of PlayerSettings + /// with the given fields replaced by the non-null parameter values. @pragma('vm:prefer-inline') @override $Res call({ @@ -526,6 +564,8 @@ class _$PlayerSettingsCopyWithImpl<$Res, $Val extends PlayerSettings> ) as $Val); } + /// Create a copy of PlayerSettings + /// with the given fields replaced by the non-null parameter values. @override @pragma('vm:prefer-inline') $MinimizedPlayerSettingsCopyWith<$Res> get miniPlayerSettings { @@ -535,6 +575,8 @@ class _$PlayerSettingsCopyWithImpl<$Res, $Val extends PlayerSettings> }); } + /// Create a copy of PlayerSettings + /// with the given fields replaced by the non-null parameter values. @override @pragma('vm:prefer-inline') $ExpandedPlayerSettingsCopyWith<$Res> get expandedPlayerSettings { @@ -544,6 +586,8 @@ class _$PlayerSettingsCopyWithImpl<$Res, $Val extends PlayerSettings> }); } + /// Create a copy of PlayerSettings + /// with the given fields replaced by the non-null parameter values. @override @pragma('vm:prefer-inline') $SleepTimerSettingsCopyWith<$Res> get sleepTimerSettings { @@ -587,6 +631,8 @@ class __$$PlayerSettingsImplCopyWithImpl<$Res> _$PlayerSettingsImpl _value, $Res Function(_$PlayerSettingsImpl) _then) : super(_value, _then); + /// Create a copy of PlayerSettings + /// with the given fields replaced by the non-null parameter values. @pragma('vm:prefer-inline') @override $Res call({ @@ -701,7 +747,7 @@ class _$PlayerSettingsImpl implements _PlayerSettings { other.playbackReportInterval == playbackReportInterval)); } - @JsonKey(ignore: true) + @JsonKey(includeFromJson: false, includeToJson: false) @override int get hashCode => Object.hash( runtimeType, @@ -713,7 +759,9 @@ class _$PlayerSettingsImpl implements _PlayerSettings { sleepTimerSettings, playbackReportInterval); - @JsonKey(ignore: true) + /// Create a copy of PlayerSettings + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) @override @pragma('vm:prefer-inline') _$$PlayerSettingsImplCopyWith<_$PlayerSettingsImpl> get copyWith => @@ -755,8 +803,11 @@ abstract class _PlayerSettings implements PlayerSettings { SleepTimerSettings get sleepTimerSettings; @override Duration get playbackReportInterval; + + /// Create a copy of PlayerSettings + /// with the given fields replaced by the non-null parameter values. @override - @JsonKey(ignore: true) + @JsonKey(includeFromJson: false, includeToJson: false) _$$PlayerSettingsImplCopyWith<_$PlayerSettingsImpl> get copyWith => throw _privateConstructorUsedError; } @@ -771,8 +822,12 @@ mixin _$ExpandedPlayerSettings { bool get showTotalProgress => throw _privateConstructorUsedError; bool get showChapterProgress => throw _privateConstructorUsedError; + /// Serializes this ExpandedPlayerSettings to a JSON map. Map toJson() => throw _privateConstructorUsedError; - @JsonKey(ignore: true) + + /// Create a copy of ExpandedPlayerSettings + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) $ExpandedPlayerSettingsCopyWith get copyWith => throw _privateConstructorUsedError; } @@ -797,6 +852,8 @@ class _$ExpandedPlayerSettingsCopyWithImpl<$Res, // ignore: unused_field final $Res Function($Val) _then; + /// Create a copy of ExpandedPlayerSettings + /// with the given fields replaced by the non-null parameter values. @pragma('vm:prefer-inline') @override $Res call({ @@ -838,6 +895,8 @@ class __$$ExpandedPlayerSettingsImplCopyWithImpl<$Res> $Res Function(_$ExpandedPlayerSettingsImpl) _then) : super(_value, _then); + /// Create a copy of ExpandedPlayerSettings + /// with the given fields replaced by the non-null parameter values. @pragma('vm:prefer-inline') @override $Res call({ @@ -889,12 +948,14 @@ class _$ExpandedPlayerSettingsImpl implements _ExpandedPlayerSettings { other.showChapterProgress == showChapterProgress)); } - @JsonKey(ignore: true) + @JsonKey(includeFromJson: false, includeToJson: false) @override int get hashCode => Object.hash(runtimeType, showTotalProgress, showChapterProgress); - @JsonKey(ignore: true) + /// Create a copy of ExpandedPlayerSettings + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) @override @pragma('vm:prefer-inline') _$$ExpandedPlayerSettingsImplCopyWith<_$ExpandedPlayerSettingsImpl> @@ -921,8 +982,11 @@ abstract class _ExpandedPlayerSettings implements ExpandedPlayerSettings { bool get showTotalProgress; @override bool get showChapterProgress; + + /// Create a copy of ExpandedPlayerSettings + /// with the given fields replaced by the non-null parameter values. @override - @JsonKey(ignore: true) + @JsonKey(includeFromJson: false, includeToJson: false) _$$ExpandedPlayerSettingsImplCopyWith<_$ExpandedPlayerSettingsImpl> get copyWith => throw _privateConstructorUsedError; } @@ -936,8 +1000,12 @@ MinimizedPlayerSettings _$MinimizedPlayerSettingsFromJson( mixin _$MinimizedPlayerSettings { bool get useChapterInfo => throw _privateConstructorUsedError; + /// Serializes this MinimizedPlayerSettings to a JSON map. Map toJson() => throw _privateConstructorUsedError; - @JsonKey(ignore: true) + + /// Create a copy of MinimizedPlayerSettings + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) $MinimizedPlayerSettingsCopyWith get copyWith => throw _privateConstructorUsedError; } @@ -962,6 +1030,8 @@ class _$MinimizedPlayerSettingsCopyWithImpl<$Res, // ignore: unused_field final $Res Function($Val) _then; + /// Create a copy of MinimizedPlayerSettings + /// with the given fields replaced by the non-null parameter values. @pragma('vm:prefer-inline') @override $Res call({ @@ -998,6 +1068,8 @@ class __$$MinimizedPlayerSettingsImplCopyWithImpl<$Res> $Res Function(_$MinimizedPlayerSettingsImpl) _then) : super(_value, _then); + /// Create a copy of MinimizedPlayerSettings + /// with the given fields replaced by the non-null parameter values. @pragma('vm:prefer-inline') @override $Res call({ @@ -1038,11 +1110,13 @@ class _$MinimizedPlayerSettingsImpl implements _MinimizedPlayerSettings { other.useChapterInfo == useChapterInfo)); } - @JsonKey(ignore: true) + @JsonKey(includeFromJson: false, includeToJson: false) @override int get hashCode => Object.hash(runtimeType, useChapterInfo); - @JsonKey(ignore: true) + /// Create a copy of MinimizedPlayerSettings + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) @override @pragma('vm:prefer-inline') _$$MinimizedPlayerSettingsImplCopyWith<_$MinimizedPlayerSettingsImpl> @@ -1066,8 +1140,11 @@ abstract class _MinimizedPlayerSettings implements MinimizedPlayerSettings { @override bool get useChapterInfo; + + /// Create a copy of MinimizedPlayerSettings + /// with the given fields replaced by the non-null parameter values. @override - @JsonKey(ignore: true) + @JsonKey(includeFromJson: false, includeToJson: false) _$$MinimizedPlayerSettingsImplCopyWith<_$MinimizedPlayerSettingsImpl> get copyWith => throw _privateConstructorUsedError; } @@ -1109,8 +1186,12 @@ mixin _$SleepTimerSettings { Duration get autoTurnOnTime => throw _privateConstructorUsedError; Duration get autoTurnOffTime => throw _privateConstructorUsedError; + /// Serializes this SleepTimerSettings to a JSON map. Map toJson() => throw _privateConstructorUsedError; - @JsonKey(ignore: true) + + /// Create a copy of SleepTimerSettings + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) $SleepTimerSettingsCopyWith get copyWith => throw _privateConstructorUsedError; } @@ -1147,6 +1228,8 @@ class _$SleepTimerSettingsCopyWithImpl<$Res, $Val extends SleepTimerSettings> // ignore: unused_field final $Res Function($Val) _then; + /// Create a copy of SleepTimerSettings + /// with the given fields replaced by the non-null parameter values. @pragma('vm:prefer-inline') @override $Res call({ @@ -1253,6 +1336,8 @@ class __$$SleepTimerSettingsImplCopyWithImpl<$Res> $Res Function(_$SleepTimerSettingsImpl) _then) : super(_value, _then); + /// Create a copy of SleepTimerSettings + /// with the given fields replaced by the non-null parameter values. @pragma('vm:prefer-inline') @override $Res call({ @@ -1456,7 +1541,7 @@ class _$SleepTimerSettingsImpl implements _SleepTimerSettings { other.autoTurnOffTime == autoTurnOffTime)); } - @JsonKey(ignore: true) + @JsonKey(includeFromJson: false, includeToJson: false) @override int get hashCode => Object.hash( runtimeType, @@ -1474,7 +1559,9 @@ class _$SleepTimerSettingsImpl implements _SleepTimerSettings { autoTurnOnTime, autoTurnOffTime); - @JsonKey(ignore: true) + /// Create a copy of SleepTimerSettings + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) @override @pragma('vm:prefer-inline') _$$SleepTimerSettingsImplCopyWith<_$SleepTimerSettingsImpl> get copyWith => @@ -1512,10 +1599,10 @@ abstract class _SleepTimerSettings implements SleepTimerSettings { Duration get defaultDuration; @override SleepTimerShakeSenseMode get shakeSenseMode; - @override /// the duration in which the shake is detected before the end of the timer and after the timer ends /// only used if [shakeSenseMode] is [SleepTimerShakeSenseMode.nearEnds] + @override Duration get shakeSenseDuration; @override bool get vibrateWhenReset; @@ -1525,32 +1612,35 @@ abstract class _SleepTimerSettings implements SleepTimerSettings { bool get fadeOutAudio; @override double get shakeDetectThreshold; - @override /// if true, the player will automatically rewind the audio when the sleep timer is stopped - bool get autoRewindWhenStopped; @override + bool get autoRewindWhenStopped; /// the key is the duration in minutes - Map get autoRewindDurations; @override + Map get autoRewindDurations; /// auto turn on timer settings - bool get autoTurnOnTimer; @override + bool get autoTurnOnTimer; /// always auto turn on timer settings or during specific times - bool get alwaysAutoTurnOnTimer; @override + bool get alwaysAutoTurnOnTimer; /// auto timer settings, only used if [alwaysAutoTurnOnTimer] is false /// /// duration is the time from 00:00 + @override Duration get autoTurnOnTime; @override Duration get autoTurnOffTime; + + /// Create a copy of SleepTimerSettings + /// with the given fields replaced by the non-null parameter values. @override - @JsonKey(ignore: true) + @JsonKey(includeFromJson: false, includeToJson: false) _$$SleepTimerSettingsImplCopyWith<_$SleepTimerSettingsImpl> get copyWith => throw _privateConstructorUsedError; } @@ -1568,8 +1658,12 @@ mixin _$DownloadSettings { int get maxConcurrentByHost => throw _privateConstructorUsedError; int get maxConcurrentByGroup => throw _privateConstructorUsedError; + /// Serializes this DownloadSettings to a JSON map. Map toJson() => throw _privateConstructorUsedError; - @JsonKey(ignore: true) + + /// Create a copy of DownloadSettings + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) $DownloadSettingsCopyWith get copyWith => throw _privateConstructorUsedError; } @@ -1599,6 +1693,8 @@ class _$DownloadSettingsCopyWithImpl<$Res, $Val extends DownloadSettings> // ignore: unused_field final $Res Function($Val) _then; + /// Create a copy of DownloadSettings + /// with the given fields replaced by the non-null parameter values. @pragma('vm:prefer-inline') @override $Res call({ @@ -1663,6 +1759,8 @@ class __$$DownloadSettingsImplCopyWithImpl<$Res> $Res Function(_$DownloadSettingsImpl) _then) : super(_value, _then); + /// Create a copy of DownloadSettings + /// with the given fields replaced by the non-null parameter values. @pragma('vm:prefer-inline') @override $Res call({ @@ -1758,12 +1856,14 @@ class _$DownloadSettingsImpl implements _DownloadSettings { other.maxConcurrentByGroup == maxConcurrentByGroup)); } - @JsonKey(ignore: true) + @JsonKey(includeFromJson: false, includeToJson: false) @override int get hashCode => Object.hash(runtimeType, requiresWiFi, retries, allowPause, maxConcurrent, maxConcurrentByHost, maxConcurrentByGroup); - @JsonKey(ignore: true) + /// Create a copy of DownloadSettings + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) @override @pragma('vm:prefer-inline') _$$DownloadSettingsImplCopyWith<_$DownloadSettingsImpl> get copyWith => @@ -1802,8 +1902,11 @@ abstract class _DownloadSettings implements DownloadSettings { int get maxConcurrentByHost; @override int get maxConcurrentByGroup; + + /// Create a copy of DownloadSettings + /// with the given fields replaced by the non-null parameter values. @override - @JsonKey(ignore: true) + @JsonKey(includeFromJson: false, includeToJson: false) _$$DownloadSettingsImplCopyWith<_$DownloadSettingsImpl> get copyWith => throw _privateConstructorUsedError; } diff --git a/lib/settings/models/audiobookshelf_server.freezed.dart b/lib/settings/models/audiobookshelf_server.freezed.dart index 8d68178..39f095a 100644 --- a/lib/settings/models/audiobookshelf_server.freezed.dart +++ b/lib/settings/models/audiobookshelf_server.freezed.dart @@ -22,8 +22,12 @@ AudiobookShelfServer _$AudiobookShelfServerFromJson(Map json) { mixin _$AudiobookShelfServer { Uri get serverUrl => throw _privateConstructorUsedError; + /// Serializes this AudiobookShelfServer to a JSON map. Map toJson() => throw _privateConstructorUsedError; - @JsonKey(ignore: true) + + /// Create a copy of AudiobookShelfServer + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) $AudiobookShelfServerCopyWith get copyWith => throw _privateConstructorUsedError; } @@ -48,6 +52,8 @@ class _$AudiobookShelfServerCopyWithImpl<$Res, // ignore: unused_field final $Res Function($Val) _then; + /// Create a copy of AudiobookShelfServer + /// with the given fields replaced by the non-null parameter values. @pragma('vm:prefer-inline') @override $Res call({ @@ -81,6 +87,8 @@ class __$$AudiobookShelfServerImplCopyWithImpl<$Res> $Res Function(_$AudiobookShelfServerImpl) _then) : super(_value, _then); + /// Create a copy of AudiobookShelfServer + /// with the given fields replaced by the non-null parameter values. @pragma('vm:prefer-inline') @override $Res call({ @@ -120,11 +128,13 @@ class _$AudiobookShelfServerImpl implements _AudiobookShelfServer { other.serverUrl == serverUrl)); } - @JsonKey(ignore: true) + @JsonKey(includeFromJson: false, includeToJson: false) @override int get hashCode => Object.hash(runtimeType, serverUrl); - @JsonKey(ignore: true) + /// Create a copy of AudiobookShelfServer + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) @override @pragma('vm:prefer-inline') _$$AudiobookShelfServerImplCopyWith<_$AudiobookShelfServerImpl> @@ -149,8 +159,11 @@ abstract class _AudiobookShelfServer implements AudiobookShelfServer { @override Uri get serverUrl; + + /// Create a copy of AudiobookShelfServer + /// with the given fields replaced by the non-null parameter values. @override - @JsonKey(ignore: true) + @JsonKey(includeFromJson: false, includeToJson: false) _$$AudiobookShelfServerImplCopyWith<_$AudiobookShelfServerImpl> get copyWith => throw _privateConstructorUsedError; } diff --git a/lib/settings/models/authenticated_user.freezed.dart b/lib/settings/models/authenticated_user.freezed.dart index 9859db6..e582928 100644 --- a/lib/settings/models/authenticated_user.freezed.dart +++ b/lib/settings/models/authenticated_user.freezed.dart @@ -26,8 +26,12 @@ mixin _$AuthenticatedUser { String? get username => throw _privateConstructorUsedError; String? get password => throw _privateConstructorUsedError; + /// Serializes this AuthenticatedUser to a JSON map. Map toJson() => throw _privateConstructorUsedError; - @JsonKey(ignore: true) + + /// Create a copy of AuthenticatedUser + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) $AuthenticatedUserCopyWith get copyWith => throw _privateConstructorUsedError; } @@ -58,6 +62,8 @@ class _$AuthenticatedUserCopyWithImpl<$Res, $Val extends AuthenticatedUser> // ignore: unused_field final $Res Function($Val) _then; + /// Create a copy of AuthenticatedUser + /// with the given fields replaced by the non-null parameter values. @pragma('vm:prefer-inline') @override $Res call({ @@ -91,6 +97,8 @@ class _$AuthenticatedUserCopyWithImpl<$Res, $Val extends AuthenticatedUser> ) as $Val); } + /// Create a copy of AuthenticatedUser + /// with the given fields replaced by the non-null parameter values. @override @pragma('vm:prefer-inline') $AudiobookShelfServerCopyWith<$Res> get server { @@ -127,6 +135,8 @@ class __$$AuthenticatedUserImplCopyWithImpl<$Res> $Res Function(_$AuthenticatedUserImpl) _then) : super(_value, _then); + /// Create a copy of AuthenticatedUser + /// with the given fields replaced by the non-null parameter values. @pragma('vm:prefer-inline') @override $Res call({ @@ -205,12 +215,14 @@ class _$AuthenticatedUserImpl implements _AuthenticatedUser { other.password == password)); } - @JsonKey(ignore: true) + @JsonKey(includeFromJson: false, includeToJson: false) @override int get hashCode => Object.hash(runtimeType, server, authToken, id, username, password); - @JsonKey(ignore: true) + /// Create a copy of AuthenticatedUser + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) @override @pragma('vm:prefer-inline') _$$AuthenticatedUserImplCopyWith<_$AuthenticatedUserImpl> get copyWith => @@ -246,8 +258,11 @@ abstract class _AuthenticatedUser implements AuthenticatedUser { String? get username; @override String? get password; + + /// Create a copy of AuthenticatedUser + /// with the given fields replaced by the non-null parameter values. @override - @JsonKey(ignore: true) + @JsonKey(includeFromJson: false, includeToJson: false) _$$AuthenticatedUserImplCopyWith<_$AuthenticatedUserImpl> get copyWith => throw _privateConstructorUsedError; } diff --git a/lib/shared/widgets/add_new_server.dart b/lib/shared/widgets/add_new_server.dart index 276ab16..c388763 100644 --- a/lib/shared/widgets/add_new_server.dart +++ b/lib/shared/widgets/add_new_server.dart @@ -47,39 +47,7 @@ class AddNewServer extends HookConsumerWidget { ), border: const OutlineInputBorder(), prefixText: 'https://', - prefixIcon: Tooltip( - message: newServerURI.text.isEmpty - ? 'Server Status' - : isServerAliveValue - ? 'Server connected' - : 'Cannot connect to server', - child: newServerURI.text.isEmpty - ? Icon( - Icons.cloud_outlined, - color: Theme.of(context).colorScheme.onSurface, - ) - : isServerAlive.when( - data: (value) { - return value - ? Icon( - Icons.cloud_done_outlined, - color: Theme.of(context).colorScheme.primary, - ) - : Icon( - Icons.cloud_off_outlined, - color: Theme.of(context).colorScheme.error, - ); - }, - loading: () => Transform.scale( - scale: 0.5, - child: const CircularProgressIndicator(), - ), - error: (error, _) => Icon( - Icons.cloud_off_outlined, - color: Theme.of(context).colorScheme.error, - ), - ), - ), + prefixIcon: ServerAliveIcon(server: Uri.parse(newServerURI.text)), // add server button suffixIcon: onPressed == null @@ -105,3 +73,56 @@ class AddNewServer extends HookConsumerWidget { // add to add to existing servers } } + +class ServerAliveIcon extends HookConsumerWidget { + const ServerAliveIcon({ + super.key, + required this.server, + }); + + final Uri server; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isServerAlive = ref.watch(isServerAliveProvider(server.toString())); + bool isServerAliveValue = isServerAlive.when( + data: (value) => value, + loading: () => false, + error: (error, _) => false, + ); + + return Tooltip( + message: server.toString().isEmpty + ? 'Server Status' + : isServerAliveValue + ? 'Server connected' + : 'Cannot connect to server', + child: server.toString().isEmpty + ? Icon( + Icons.cloud_outlined, + color: Theme.of(context).colorScheme.onSurface, + ) + : isServerAlive.when( + data: (value) { + return value + ? Icon( + Icons.cloud_done_outlined, + color: Theme.of(context).colorScheme.primary, + ) + : Icon( + Icons.cloud_off_outlined, + color: Theme.of(context).colorScheme.error, + ); + }, + loading: () => Transform.scale( + scale: 0.5, + child: const CircularProgressIndicator(), + ), + error: (error, _) => Icon( + Icons.cloud_off_outlined, + color: Theme.of(context).colorScheme.error, + ), + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 23f601a..3a059cc 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -202,10 +202,10 @@ packages: dependency: "direct main" description: name: cached_network_image - sha256: "4a5d8d2c728b0f3d0245f69f921d7be90cae4c2fd5288f773088672c0893f819" + sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916" url: "https://pub.dev" source: hosted - version: "3.4.0" + version: "3.4.1" cached_network_image_platform_interface: dependency: transitive description: @@ -218,10 +218,10 @@ packages: dependency: transitive description: name: cached_network_image_web - sha256: "6322dde7a5ad92202e64df659241104a43db20ed594c41ca18de1014598d7996" + sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062" url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.3.1" characters: dependency: transitive description: @@ -410,10 +410,10 @@ packages: dependency: transitive description: name: file_picker - sha256: "825aec673606875c33cd8d3c4083f1a3c3999015a84178b317b7ef396b7384f3" + sha256: "167bb619cdddaa10ef2907609feb8a79c16dfa479d3afaf960f8e223f754bf12" url: "https://pub.dev" source: hosted - version: "8.0.7" + version: "8.1.2" fixnum: dependency: transitive description: @@ -479,10 +479,10 @@ packages: dependency: transitive description: name: flutter_plugin_android_lifecycle - sha256: "9d98bd47ef9d34e803d438f17fd32b116d31009f534a6fa5ce3a1167f189a6de" + sha256: "9ee02950848f61c4129af3d6ec84a1cfc0e47931abc746b03e7a3bc3e8ff6eda" url: "https://pub.dev" source: hosted - version: "2.0.21" + version: "2.0.22" flutter_riverpod: dependency: transitive description: @@ -561,10 +561,10 @@ packages: dependency: "direct main" description: name: go_router - sha256: "48d03a1e4887b00fe622695139246e3c778ac814eeb32421467b56d23fa64034" + sha256: "2ddb88e9ad56ae15ee144ed10e33886777eb5ca2509a914850a5faa7b52ff459" url: "https://pub.dev" source: hosted - version: "14.2.6" + version: "14.2.7" graphs: dependency: transitive description: @@ -697,10 +697,10 @@ packages: dependency: "direct main" description: name: just_audio - sha256: ee50602364ba83fa6308f5512dd560c713ec3e1f2bc75f0db43618f0d82ef71a + sha256: d8e8aaf417d33e345299c17f6457f72bd4ba0c549dc34607abb5183a354edc4d url: "https://pub.dev" source: hosted - version: "0.9.39" + version: "0.9.40" just_audio_background: dependency: "direct main" description: @@ -729,10 +729,10 @@ packages: dependency: transitive description: name: just_audio_web - sha256: "0edb481ad4aa1ff38f8c40f1a3576013c3420bf6669b686fe661627d49bc606c" + sha256: b163878529d9b028c53a6972fcd58cae2405bcd11cbfcea620b6fb9f151429d6 url: "https://pub.dev" source: hosted - version: "0.4.11" + version: "0.4.12" leak_tracker: dependency: transitive description: @@ -849,10 +849,10 @@ packages: dependency: transitive description: name: mime - sha256: "2e123074287cc9fd6c09de8336dae606d1ddb88d9ac47358826db698c176a1f2" + sha256: "801fd0b26f14a4a58ccb09d5892c3fbdeff209594300a542492cf13fba9d247a" url: "https://pub.dev" source: hosted - version: "1.0.5" + version: "1.0.6" miniplayer: dependency: "direct main" description: @@ -1107,7 +1107,7 @@ packages: description: path: "." ref: main - resolved-ref: de1ca8c4b1ec83ceafa285558244922959fe447a + resolved-ref: f5e7db74c2aa367179f5b369407f88848a020c24 url: "https://github.com/Dr-Blank/shelfsdk" source: git version: "1.0.0" @@ -1384,10 +1384,10 @@ packages: dependency: transitive description: name: web - sha256: "97da13628db363c635202ad97068d47c5b8aa555808e7a9411963c533b449b27" + sha256: d43c1d6b787bf0afad444700ae7f4db8827f701bc61c255ac8d328c6f4d52062 url: "https://pub.dev" source: hosted - version: "0.5.1" + version: "1.0.0" web_socket: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index aa19f0e..9937c49 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -92,7 +92,7 @@ dev_dependencies: sdk: flutter freezed: ^2.5.2 json_serializable: ^6.8.0 - riverpod_generator: ^2.4.0 + riverpod_generator: ^2.4.2 riverpod_lint: ^2.3.10 # For information on the generic Dart part of this file, see the