diff --git a/lib/api/authenticated_users_provider.dart b/lib/api/authenticated_user_provider.dart similarity index 86% rename from lib/api/authenticated_users_provider.dart rename to lib/api/authenticated_user_provider.dart index 5e78fab..c4f065c 100644 --- a/lib/api/authenticated_users_provider.dart +++ b/lib/api/authenticated_user_provider.dart @@ -8,15 +8,15 @@ import 'package:vaani/settings/models/audiobookshelf_server.dart'; import 'package:vaani/settings/models/authenticated_user.dart' as model; import 'package:vaani/shared/extensions/obfuscation.dart'; -part 'authenticated_users_provider.g.dart'; +part 'authenticated_user_provider.g.dart'; final _box = AvailableHiveBoxes.authenticatedUserBox; -final _logger = Logger('authenticated_users_provider'); +final _logger = Logger('authenticated_user_provider'); /// provides with a set of authenticated users @riverpod -class AuthenticatedUsers extends _$AuthenticatedUsers { +class AuthenticatedUser extends _$AuthenticatedUser { @override Set build() { ref.listenSelf((_, __) { @@ -56,7 +56,6 @@ class AuthenticatedUsers extends _$AuthenticatedUsers { void addUser(model.AuthenticatedUser user, {bool setActive = false}) { state = state..add(user); - ref.invalidateSelf(); if (setActive) { final apiSettings = ref.read(apiSettingsProvider); ref.read(apiSettingsProvider.notifier).updateState( @@ -83,12 +82,9 @@ class AuthenticatedUsers extends _$AuthenticatedUsers { // also remove the user from the active user final apiSettings = ref.read(apiSettingsProvider); if (apiSettings.activeUser == user) { - // replace the active user with the first user in the list - // or null if there are no users left - final newActiveUser = state.isNotEmpty ? state.first : null; ref.read(apiSettingsProvider.notifier).updateState( apiSettings.copyWith( - activeUser: newActiveUser, + activeUser: null, ), ); } diff --git a/lib/api/authenticated_users_provider.g.dart b/lib/api/authenticated_user_provider.g.dart similarity index 54% rename from lib/api/authenticated_users_provider.g.dart rename to lib/api/authenticated_user_provider.g.dart index 44a2610..b12f8c6 100644 --- a/lib/api/authenticated_users_provider.g.dart +++ b/lib/api/authenticated_user_provider.g.dart @@ -1,30 +1,28 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'authenticated_users_provider.dart'; +part of 'authenticated_user_provider.dart'; // ************************************************************************** // RiverpodGenerator // ************************************************************************** -String _$authenticatedUsersHash() => - r'5fdd472f62fc3b73ff8417cdce9f02e86c33d00f'; +String _$authenticatedUserHash() => r'1983527595207c63a12bc84cf0bf1a3c1d729506'; /// provides with a set of authenticated users /// -/// Copied from [AuthenticatedUsers]. -@ProviderFor(AuthenticatedUsers) -final authenticatedUsersProvider = AutoDisposeNotifierProvider< - AuthenticatedUsers, Set>.internal( - AuthenticatedUsers.new, - name: r'authenticatedUsersProvider', +/// Copied from [AuthenticatedUser]. +@ProviderFor(AuthenticatedUser) +final authenticatedUserProvider = AutoDisposeNotifierProvider>.internal( + AuthenticatedUser.new, + name: r'authenticatedUserProvider', debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') ? null - : _$authenticatedUsersHash, + : _$authenticatedUserHash, dependencies: null, allTransitiveDependencies: null, ); -typedef _$AuthenticatedUsers - = AutoDisposeNotifier>; +typedef _$AuthenticatedUser = AutoDisposeNotifier>; // ignore_for_file: type=lint // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/api/server_provider.dart b/lib/api/server_provider.dart index ef1c864..6bfefb1 100644 --- a/lib/api/server_provider.dart +++ b/lib/api/server_provider.dart @@ -1,6 +1,6 @@ import 'package:logging/logging.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; -import 'package:vaani/api/authenticated_users_provider.dart'; +import 'package:vaani/api/authenticated_user_provider.dart'; import 'package:vaani/db/storage.dart'; import 'package:vaani/settings/api_settings_provider.dart'; import 'package:vaani/settings/models/audiobookshelf_server.dart' as model; @@ -88,7 +88,7 @@ class AudiobookShelfServer extends _$AudiobookShelfServer { } // remove the users of this server if (removeUsers) { - ref.read(authenticatedUsersProvider.notifier).removeUsersOfServer(server); + ref.read(authenticatedUserProvider.notifier).removeUsersOfServer(server); } } diff --git a/lib/api/server_provider.g.dart b/lib/api/server_provider.g.dart index ff2406a..5fc811c 100644 --- a/lib/api/server_provider.g.dart +++ b/lib/api/server_provider.g.dart @@ -7,7 +7,7 @@ part of 'server_provider.dart'; // ************************************************************************** String _$audiobookShelfServerHash() => - r'31a96b431221965cd586aad670a32ca901539e41'; + r'09e7e37ddc794c45eafbaab7eba82c9dd17faa93'; /// provides with a set of servers added by the user /// diff --git a/lib/features/onboarding/view/onboarding_single_page.dart b/lib/features/onboarding/view/onboarding_single_page.dart index b9a7eb5..7f071a0 100644 --- a/lib/features/onboarding/view/onboarding_single_page.dart +++ b/lib/features/onboarding/view/onboarding_single_page.dart @@ -39,22 +39,6 @@ class OnboardingSinglePage extends HookConsumerWidget { } } -Widget 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, - ), - ); -} - class OnboardingBody extends HookConsumerWidget { const OnboardingBody({ super.key, @@ -70,6 +54,22 @@ class OnboardingBody extends HookConsumerWidget { final canUserLogin = useState(apiSettings.activeServer != null); + 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 Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center, diff --git a/lib/features/onboarding/view/user_login.dart b/lib/features/onboarding/view/user_login.dart index 8aeff14..bf65b12 100644 --- a/lib/features/onboarding/view/user_login.dart +++ b/lib/features/onboarding/view/user_login.dart @@ -1,42 +1,33 @@ import 'package:flutter/material.dart'; -import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shelfsdk/audiobookshelf_api.dart' show AuthMethod; -import 'package:vaani/api/api_provider.dart' show serverStatusProvider; -import 'package:vaani/api/server_provider.dart' - show ServerAlreadyExistsException, audiobookShelfServerProvider; -import 'package:vaani/features/onboarding/view/onboarding_single_page.dart' - show fadeSlideTransitionBuilder; -import 'package:vaani/features/onboarding/view/user_login_with_open_id.dart' - show UserLoginWithOpenID; -import 'package:vaani/features/onboarding/view/user_login_with_password.dart' - show UserLoginWithPassword; -import 'package:vaani/features/onboarding/view/user_login_with_token.dart' - show UserLoginWithToken; -import 'package:vaani/hacks/fix_autofill_losing_focus.dart' - show InactiveFocusScopeObserver; -import 'package:vaani/models/error_response.dart' show ErrorResponseHandler; -import 'package:vaani/settings/api_settings_provider.dart' - show apiSettingsProvider; +import 'package:shelfsdk/audiobookshelf_api.dart'; +import 'package:vaani/api/api_provider.dart'; +import 'package:vaani/api/server_provider.dart'; +import 'package:vaani/features/onboarding/view/user_login_with_open_id.dart'; +import 'package:vaani/features/onboarding/view/user_login_with_password.dart'; +import 'package:vaani/features/onboarding/view/user_login_with_token.dart'; +import 'package:vaani/hacks/fix_autofill_losing_focus.dart'; +import 'package:vaani/models/error_response.dart'; +import 'package:vaani/settings/api_settings_provider.dart'; import 'package:vaani/settings/models/models.dart' as model; class UserLoginWidget extends HookConsumerWidget { - const UserLoginWidget({ + UserLoginWidget({ super.key, required this.server, - this.onSuccess, }); final Uri server; - final Function(model.AuthenticatedUser)? onSuccess; + final serverStatusError = ErrorResponseHandler(); @override Widget build(BuildContext context, WidgetRef ref) { - final serverStatusError = useMemoized(() => ErrorResponseHandler(), []); final serverStatus = ref.watch(serverStatusProvider(server, serverStatusError.storeError)); + final api = ref.watch(audiobookshelfApiProvider(server)); + return serverStatus.when( data: (value) { if (value == null) { @@ -51,7 +42,6 @@ class UserLoginWidget extends HookConsumerWidget { openIDAvailable: value.authMethods?.contains(AuthMethod.openid) ?? false, openIDButtonText: value.authFormData?.authOpenIDButtonText, - onSuccess: onSuccess, ); }, loading: () { @@ -98,7 +88,6 @@ class UserLoginMultipleAuth extends HookConsumerWidget { this.openIDAvailable = false, this.onPressed, this.openIDButtonText, - this.onSuccess, }); final Uri server; @@ -106,7 +95,6 @@ class UserLoginMultipleAuth extends HookConsumerWidget { final bool openIDAvailable; final void Function()? onPressed; final String? openIDButtonText; - final Function(model.AuthenticatedUser)? onSuccess; @override Widget build(BuildContext context, WidgetRef ref) { @@ -116,6 +104,8 @@ class UserLoginMultipleAuth extends HookConsumerWidget { localAvailable ? AuthMethodChoice.local : AuthMethodChoice.authToken, ); + final apiSettings = ref.watch(apiSettingsProvider); + model.AudiobookShelfServer addServer() { var newServer = model.AudiobookShelfServer( serverUrl: server, @@ -129,9 +119,9 @@ class UserLoginMultipleAuth extends HookConsumerWidget { newServer = e.server; } finally { ref.read(apiSettingsProvider.notifier).updateState( - ref.read(apiSettingsProvider).copyWith( - activeServer: newServer, - ), + apiSettings.copyWith( + activeServer: newServer, + ), ); } return newServer; @@ -182,36 +172,26 @@ class UserLoginMultipleAuth extends HookConsumerWidget { } }, ), - ].animate(interval: 100.ms).fadeIn( - duration: 150.ms, - curve: Curves.easeIn, - ), + ], ), ), Padding( padding: const EdgeInsets.all(8.0), - child: AnimatedSwitcher( - duration: 200.ms, - transitionBuilder: fadeSlideTransitionBuilder, - child: switch (methodChoice.value) { - AuthMethodChoice.authToken => UserLoginWithToken( - server: server, - addServer: addServer, - onSuccess: onSuccess, - ), - AuthMethodChoice.local => UserLoginWithPassword( - server: server, - addServer: addServer, - onSuccess: onSuccess, - ), - AuthMethodChoice.openid => UserLoginWithOpenID( - server: server, - addServer: addServer, - openIDButtonText: openIDButtonText, - onSuccess: onSuccess, - ), - }, - ), + child: switch (methodChoice.value) { + AuthMethodChoice.authToken => UserLoginWithToken( + server: server, + addServer: addServer, + ), + AuthMethodChoice.local => UserLoginWithPassword( + server: server, + addServer: addServer, + ), + AuthMethodChoice.openid => UserLoginWithOpenID( + server: server, + addServer: addServer, + openIDButtonText: openIDButtonText, + ), + }, ), ], ), diff --git a/lib/features/onboarding/view/user_login_with_open_id.dart b/lib/features/onboarding/view/user_login_with_open_id.dart index b3a1d9e..71baa42 100644 --- a/lib/features/onboarding/view/user_login_with_open_id.dart +++ b/lib/features/onboarding/view/user_login_with_open_id.dart @@ -20,14 +20,12 @@ class UserLoginWithOpenID extends HookConsumerWidget { required this.server, required this.addServer, this.openIDButtonText, - this.onSuccess, }); final Uri server; final model.AudiobookShelfServer Function() addServer; final String? openIDButtonText; final responseErrorHandler = ErrorResponseHandler(name: 'OpenID'); - final Function(model.AuthenticatedUser)? onSuccess; @override Widget build(BuildContext context, WidgetRef ref) { diff --git a/lib/features/onboarding/view/user_login_with_password.dart b/lib/features/onboarding/view/user_login_with_password.dart index 210da77..b730cc5 100644 --- a/lib/features/onboarding/view/user_login_with_password.dart +++ b/lib/features/onboarding/view/user_login_with_password.dart @@ -5,7 +5,7 @@ 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_users_provider.dart'; +import 'package:vaani/api/authenticated_user_provider.dart'; import 'package:vaani/hacks/fix_autofill_losing_focus.dart'; import 'package:vaani/models/error_response.dart'; import 'package:vaani/router/router.dart'; @@ -18,13 +18,11 @@ class UserLoginWithPassword extends HookConsumerWidget { super.key, required this.server, required this.addServer, - this.onSuccess, }); final Uri server; final model.AudiobookShelfServer Function() addServer; final serverErrorResponse = ErrorResponseHandler(); - final Function(model.AuthenticatedUser)? onSuccess; @override Widget build(BuildContext context, WidgetRef ref) { @@ -83,16 +81,13 @@ class UserLoginWithPassword extends HookConsumerWidget { username: username, authToken: api.token!, ); + // add the user to the list of users + ref + .read(authenticatedUserProvider.notifier) + .addUser(authenticatedUser, setActive: true); - if (onSuccess != null) { - onSuccess!(authenticatedUser); - } else { - // add the user to the list of users - ref - .read(authenticatedUsersProvider.notifier) - .addUser(authenticatedUser, setActive: true); - context.goNamed(Routes.home.name); - } + // redirect to the library page + GoRouter.of(context).goNamed(Routes.home.name); } return Center( diff --git a/lib/features/onboarding/view/user_login_with_token.dart b/lib/features/onboarding/view/user_login_with_token.dart index 7d2fcfb..a67b96b 100644 --- a/lib/features/onboarding/view/user_login_with_token.dart +++ b/lib/features/onboarding/view/user_login_with_token.dart @@ -4,7 +4,7 @@ import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shelfsdk/audiobookshelf_api.dart'; import 'package:vaani/api/api_provider.dart'; -import 'package:vaani/api/authenticated_users_provider.dart'; +import 'package:vaani/api/authenticated_user_provider.dart'; import 'package:vaani/models/error_response.dart'; import 'package:vaani/router/router.dart'; import 'package:vaani/settings/models/models.dart' as model; @@ -14,13 +14,11 @@ class UserLoginWithToken extends HookConsumerWidget { super.key, required this.server, required this.addServer, - this.onSuccess, }); final Uri server; final model.AudiobookShelfServer Function() addServer; final serverErrorResponse = ErrorResponseHandler(); - final Function(model.AuthenticatedUser)? onSuccess; @override Widget build(BuildContext context, WidgetRef ref) { @@ -67,14 +65,11 @@ class UserLoginWithToken extends HookConsumerWidget { authToken: api.token!, ); - if (onSuccess != null) { - onSuccess!(authenticatedUser); - } else { - ref - .read(authenticatedUsersProvider.notifier) - .addUser(authenticatedUser, setActive: true); - context.goNamed(Routes.home.name); - } + ref + .read(authenticatedUserProvider.notifier) + .addUser(authenticatedUser, setActive: true); + + context.goNamed(Routes.home.name); } return Form( diff --git a/lib/features/player/view/widgets/chapter_selection_button.dart b/lib/features/player/view/widgets/chapter_selection_button.dart index 04cbd0e..889392a 100644 --- a/lib/features/player/view/widgets/chapter_selection_button.dart +++ b/lib/features/player/view/widgets/chapter_selection_button.dart @@ -1,18 +1,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:vaani/features/player/providers/audiobook_player.dart' - show audiobookPlayerProvider; -import 'package:vaani/features/player/providers/currently_playing_provider.dart' - show currentPlayingChapterProvider, currentlyPlayingBookProvider; -import 'package:vaani/features/player/view/player_when_expanded.dart' - show pendingPlayerModals; -import 'package:vaani/features/player/view/widgets/playing_indicator_icon.dart'; -import 'package:vaani/main.dart' show appLogger; -import 'package:vaani/shared/extensions/chapter.dart' show ChapterDuration; -import 'package:vaani/shared/extensions/duration_format.dart' - show DurationFormat; -import 'package:vaani/shared/hooks.dart' show useTimer; +import 'package:vaani/features/player/providers/audiobook_player.dart'; +import 'package:vaani/features/player/providers/currently_playing_provider.dart'; +import 'package:vaani/features/player/view/player_when_expanded.dart'; +import 'package:vaani/main.dart'; +import 'package:vaani/shared/extensions/chapter.dart'; +import 'package:vaani/shared/extensions/duration_format.dart'; +import 'package:vaani/shared/hooks.dart'; class ChapterSelectionButton extends HookConsumerWidget { const ChapterSelectionButton({ @@ -72,7 +67,6 @@ class ChapterSelectionModal extends HookConsumerWidget { useTimer(scrollToCurrentChapter, 500.ms); // useInterval(scrollToCurrentChapter, 500.ms); - final theme = Theme.of(context); return Column( children: [ ListTile( @@ -87,41 +81,24 @@ class ChapterSelectionModal extends HookConsumerWidget { child: currentBook?.chapters == null ? const Text('No chapters found') : Column( - children: currentBook!.chapters.map( - (chapter) { - final isCurrent = currentChapterIndex == chapter.id; - final isPlayed = currentChapterIndex != null && - chapter.id < currentChapterIndex; - return ListTile( - autofocus: isCurrent, - iconColor: isPlayed && !isCurrent - ? theme.disabledColor - : null, - title: Text( - chapter.title, - style: isPlayed && !isCurrent - ? TextStyle(color: theme.disabledColor) - : null, - ), - subtitle: Text( + children: [ + for (final chapter in currentBook!.chapters) + ListTile( + title: Text(chapter.title), + trailing: Text( '(${chapter.duration.smartBinaryFormat})', - style: isPlayed && !isCurrent - ? TextStyle(color: theme.disabledColor) - : null, ), - trailing: isCurrent - ? const PlayingIndicatorIcon() - : const Icon(Icons.play_arrow), - selected: isCurrent, - key: isCurrent ? chapterKey : null, + selected: currentChapterIndex == chapter.id, + key: currentChapterIndex == chapter.id + ? chapterKey + : null, onTap: () { Navigator.of(context).pop(); notifier.seek(chapter.start + 90.ms); notifier.play(); }, - ); - }, - ).toList(), + ), + ], ), ), ), diff --git a/lib/features/player/view/widgets/playing_indicator_icon.dart b/lib/features/player/view/widgets/playing_indicator_icon.dart deleted file mode 100644 index d179797..0000000 --- a/lib/features/player/view/widgets/playing_indicator_icon.dart +++ /dev/null @@ -1,194 +0,0 @@ -import 'dart:math'; -import 'package:flutter/material.dart'; -import 'package:flutter_animate/flutter_animate.dart'; - -/// An icon that animates like audio equalizer bars to indicate playback. -/// -/// Creates multiple vertical bars that independently animate their height -/// in a looping, visually dynamic pattern. -class PlayingIndicatorIcon extends StatefulWidget { - /// The number of vertical bars in the indicator. - final int barCount; - - /// The total width and height of the icon area. - final double size; - - /// The color of the bars. Defaults to the current [IconTheme] color. - final Color? color; - - /// The minimum height factor for a bar (relative to [size]). - /// When [centerSymmetric] is true, this represents the minimum height - /// extending from the center line (so total minimum height is 2 * minHeightFactor * size). - /// When false, it's the minimum height from the bottom. - final double minHeightFactor; - - /// The maximum height factor for a bar (relative to [size]). - /// When [centerSymmetric] is true, this represents the maximum height - /// extending from the center line (so total maximum height is 2 * maxHeightFactor * size). - /// When false, it's the maximum height from the bottom. - final double maxHeightFactor; - - /// Base duration for a full up/down animation cycle for a single bar. - /// Actual duration will vary slightly per bar. - final Duration baseCycleDuration; - - /// If true, the bars animate symmetrically expanding/collapsing from the - /// horizontal center line. If false (default), they expand/collapse from - /// the bottom edge. - final bool centerSymmetric; - - const PlayingIndicatorIcon({ - super.key, - this.barCount = 4, - this.size = 20.0, - this.color, - this.minHeightFactor = 0.2, - this.maxHeightFactor = 1.0, - this.baseCycleDuration = const Duration(milliseconds: 350), - this.centerSymmetric = true, - }); - - @override - State createState() => _PlayingIndicatorIconState(); -} - -class _PlayingIndicatorIconState extends State { - late List<_BarAnimationParams> _animationParams; - final _random = Random(); - - @override - void initState() { - super.initState(); - _animationParams = - List.generate(widget.barCount, _createRandomParams, growable: false); - } - - // Helper to generate random parameters for one bar's animation cycle - _BarAnimationParams _createRandomParams(int index) { - final duration1 = - (widget.baseCycleDuration * (0.8 + _random.nextDouble() * 0.4)); - final duration2 = - (widget.baseCycleDuration * (0.8 + _random.nextDouble() * 0.4)); - - // Note: These factors represent the scale relative to the *half-height* - // if centerSymmetric is true, controlled by the alignment in scaleY. - final targetHeightFactor1 = widget.minHeightFactor + - _random.nextDouble() * - (widget.maxHeightFactor - widget.minHeightFactor); - final targetHeightFactor2 = widget.minHeightFactor + - _random.nextDouble() * - (widget.maxHeightFactor - widget.minHeightFactor); - - // --- Random initial delay --- - final initialDelay = - (_random.nextDouble() * (widget.baseCycleDuration.inMilliseconds / 4)) - .ms; - - return _BarAnimationParams( - duration1: duration1, - duration2: duration2, - targetHeightFactor1: targetHeightFactor1, - targetHeightFactor2: targetHeightFactor2, - initialDelay: initialDelay, - ); - } - - @override - Widget build(BuildContext context) { - final color = widget.color ?? - IconTheme.of(context).color ?? - Theme.of(context).colorScheme.primary; - - // --- Bar geometry calculation --- - final double totalSpacing = widget.size * 0.2; - // Ensure at least 1px spacing if size is very small - final double barSpacing = max(1.0, totalSpacing / (widget.barCount + 1)); - final double availableWidthForBars = - widget.size - (barSpacing * (widget.barCount + 1)); - final double barWidth = max(1.0, availableWidthForBars / widget.barCount); - // Max height remains the full size potential for the container - final double maxHeight = widget.size; - - // Determine the alignment for scaling based on the symmetric flag - final Alignment scaleAlignment = - widget.centerSymmetric ? Alignment.center : Alignment.bottomCenter; - - // Determine the cross axis alignment for the Row - final CrossAxisAlignment rowAlignment = widget.centerSymmetric - ? CrossAxisAlignment.center - : CrossAxisAlignment.end; - - return SizedBox( - width: widget.size, - height: widget.size, - // Clip ensures bars don't draw outside the SizedBox bounds - // especially important for center alignment if maxFactor > 0.5 - child: ClipRect( - child: Row( - // Use calculated alignment - crossAxisAlignment: rowAlignment, - // Use spaceEvenly for better distribution, especially with center alignment - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: List.generate( - widget.barCount, - (index) { - final params = _animationParams[index]; - // The actual bar widget that will be animated - return Container( - width: barWidth, - // Set initial height to the max potential height - // The scaleY animation will control the visible height - height: maxHeight, - decoration: BoxDecoration( - color: color, - borderRadius: BorderRadius.circular(barWidth / 2), - ), - ) - .animate( - delay: params.initialDelay, - onPlay: (controller) => controller.repeat( - reverse: true, - ), - ) - // 1. Scale to targetHeightFactor1 - .scaleY( - begin: - widget.minHeightFactor, // Scale factor starts near min - end: params.targetHeightFactor1, - duration: params.duration1, - curve: Curves.easeInOutCirc, - alignment: scaleAlignment, // Apply chosen alignment - ) - // 2. Then scale to targetHeightFactor2 - .then() - .scaleY( - end: params.targetHeightFactor2, - duration: params.duration2, - curve: Curves.easeInOutCirc, - alignment: scaleAlignment, // Apply chosen alignment - ); - }, - growable: false, - ), - ), - ), - ); - } -} - -// Helper class: Renamed height fields for clarity -class _BarAnimationParams { - final Duration duration1; - final Duration duration2; - final double targetHeightFactor1; // Factor relative to total size - final double targetHeightFactor2; // Factor relative to total size - final Duration initialDelay; - - _BarAnimationParams({ - required this.duration1, - required this.duration2, - required this.targetHeightFactor1, - required this.targetHeightFactor2, - required this.initialDelay, - }); -} diff --git a/lib/features/you/view/server_manager.dart b/lib/features/you/view/server_manager.dart index 75e3f43..f44eb77 100644 --- a/lib/features/you/view/server_manager.dart +++ b/lib/features/you/view/server_manager.dart @@ -2,22 +2,17 @@ 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:vaani/api/api_provider.dart' show makeBaseUrl; -import 'package:vaani/api/authenticated_users_provider.dart' - show authenticatedUsersProvider; -import 'package:vaani/api/server_provider.dart' - show ServerAlreadyExistsException, audiobookShelfServerProvider; -import 'package:vaani/features/onboarding/view/user_login.dart' - show UserLoginWidget; -import 'package:vaani/features/player/view/mini_player_bottom_padding.dart' - show MiniPlayerBottomPadding; -import 'package:vaani/main.dart' show appLogger; -import 'package:vaani/router/router.dart' show Routes; -import 'package:vaani/settings/api_settings_provider.dart' - show apiSettingsProvider; +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/player/view/mini_player_bottom_padding.dart'; +import 'package:vaani/main.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; -import 'package:vaani/shared/extensions/obfuscation.dart' show ObfuscateSet; -import 'package:vaani/shared/widgets/add_new_server.dart' show AddNewServer; +import 'package:vaani/shared/extensions/obfuscation.dart'; +import 'package:vaani/shared/widgets/add_new_server.dart'; class ServerManagerPage extends HookConsumerWidget { const ServerManagerPage({ @@ -26,6 +21,15 @@ class ServerManagerPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final apiSettings = ref.watch(apiSettingsProvider); + final registeredServers = ref.watch(audiobookShelfServerProvider); + final registeredServersAsList = registeredServers.toList(); + final availableUsers = ref.watch(authenticatedUserProvider); + final serverURIController = useTextEditingController(); + final formKey = GlobalKey(); + + appLogger.fine('registered servers: ${registeredServers.obfuscate()}'); + appLogger.fine('available users: ${availableUsers.obfuscate()}'); return Scaffold( appBar: AppBar( title: const Text('Manage Accounts'), @@ -33,340 +37,420 @@ class ServerManagerPage extends HookConsumerWidget { body: Center( child: Padding( padding: const EdgeInsets.all(8.0), - child: ServerManagerBody(), - ), - ), - ); - } -} - -class ServerManagerBody extends HookConsumerWidget { - const ServerManagerBody({ - super.key, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final registeredServers = ref.watch(audiobookShelfServerProvider); - final registeredServersAsList = registeredServers.toList(); - final availableUsers = ref.watch(authenticatedUsersProvider); - final apiSettings = ref.watch(apiSettingsProvider); - final serverURIController = useTextEditingController(); - final formKey = GlobalKey(); - - appLogger.fine('registered servers: ${registeredServers.obfuscate()}'); - appLogger.fine('available users: ${availableUsers.obfuscate()}'); - - return Column( - // crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.end, - children: [ - const Text( - 'Registered Servers', - ), - Expanded( - child: ListView.builder( - itemCount: registeredServers.length, - reverse: true, - itemBuilder: (context, index) { - var registeredServer = registeredServersAsList[index]; - return ExpansionTile( - title: Text(registeredServer.serverUrl.toString()), - subtitle: Text( - 'Users: ${availableUsers.where((element) => element.server == registeredServer).length}', - ), - // children are list of users of this server - children: availableUsers - .where( - (element) => element.server == registeredServer, - ) - .map( - (e) => AvailableUserTile(user: e), - ) - .nonNulls - .toList() - - // add buttons of delete server and add user to server at the end - ..addAll([ - AddUserTile(server: registeredServer), - DeleteServerTile(server: registeredServer), - ]), - ); - }, - ), - ), - const SizedBox(height: 20), - const Padding( - padding: EdgeInsets.all(8.0), - child: Text('Add New Server'), - ), - Form( - key: formKey, - autovalidateMode: AutovalidateMode.onUserInteraction, - child: AddNewServer( - controller: serverURIController, - onPressed: () { - if (formKey.currentState!.validate()) { - try { - final newServer = model.AudiobookShelfServer( - serverUrl: makeBaseUrl(serverURIController.text), - ); - ref.read(audiobookShelfServerProvider.notifier).addServer( - newServer, - ); - ref.read(apiSettingsProvider.notifier).updateState( - apiSettings.copyWith( - activeServer: newServer, - ), - ); - serverURIController.clear(); - } on ServerAlreadyExistsException catch (e) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(e.toString()), - ), - ); - } - } else { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Invalid URL'), - ), - ); - } - }, - ), - ), - MiniPlayerBottomPadding(), - ], - ); - } -} - -class DeleteServerTile extends HookConsumerWidget { - const DeleteServerTile({ - super.key, - required this.server, - }); - - final model.AudiobookShelfServer server; - - @override - Widget build(BuildContext context, WidgetRef ref) { - return ListTile( - leading: const Icon(Icons.delete), - title: const Text('Delete Server'), - onTap: () { - showDialog( - context: context, - builder: (context) { - return AlertDialog( - title: const Text('Remove Server and Users'), - // Make content scrollable in case of smaller screens/keyboard - content: SingleChildScrollView( - child: Text.rich( - TextSpan( - children: [ - const TextSpan( - text: 'This will remove the server ', - ), - TextSpan( - text: server.serverUrl.host, - style: TextStyle( - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.primary, - ), - ), - const TextSpan( - text: ' and all its users\' login info from this app.', - ), - ], - ), - ), + child: Column( + // crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + const Text( + 'Registered Servers', ), - actions: [ - TextButton( - onPressed: () { - Navigator.of(context).pop(); - }, - child: const Text('Cancel'), - ), - TextButton( - onPressed: () { - ref - .read( - audiobookShelfServerProvider.notifier, - ) - .removeServer( - server, - removeUsers: true, - ); - Navigator.of(context).pop(); - }, - child: const Text('Delete'), - ), - ], - ); - }, - ); - }, - ); - } -} - -class AddUserTile extends HookConsumerWidget { - const AddUserTile({ - super.key, - required this.server, - }); - - final model.AudiobookShelfServer server; - - @override - Widget build(BuildContext context, WidgetRef ref) { - return ListTile( - leading: const Icon(Icons.person_add), - title: const Text('Add User'), - onTap: () async { - await showDialog( - context: context, - // barrierDismissible: false, // Optional: prevent closing by tapping outside - builder: (dialogContext) { - // Use a different context name to avoid conflicts - return AlertDialog( - title: Text('Add User to ${server.serverUrl.host}'), - // Make content scrollable in case of smaller screens/keyboard - content: SingleChildScrollView( - child: UserLoginWidget( - server: server.serverUrl, - // Pass the callback to pop the dialog on success - onSuccess: (user) { - // Add the user to the server - ref.read(authenticatedUsersProvider.notifier).addUser(user); - Navigator.of(dialogContext).pop(); // Close the dialog - // Optional: Show a confirmation SnackBar - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('User added successfully! Switch?'), - action: SnackBarAction( - label: 'Switch', - onPressed: () { - // Switch to the new user - ref.read(apiSettingsProvider.notifier).updateState( - ref.read(apiSettingsProvider).copyWith( - activeUser: user, - ), - ); - context.goNamed(Routes.home.name); - }, - ), + Expanded( + child: ListView.builder( + itemCount: registeredServers.length, + reverse: true, + itemBuilder: (context, index) { + var registeredServer = registeredServersAsList[index]; + return ExpansionTile( + title: Text(registeredServer.serverUrl.toString()), + subtitle: Text( + 'Users: ${availableUsers.where((element) => element.server == registeredServer).length}', ), + // trailing: _DeleteServerButton( + // registeredServer: registeredServer, + // ), + // children are list of users of this server + children: availableUsers + .where( + (element) => element.server == registeredServer, + ) + .map( + (e) => ListTile( + selected: apiSettings.activeUser == e, + leading: apiSettings.activeUser == e + ? const Icon(Icons.person) + : const Icon(Icons.person_off_outlined), + title: Text(e.username ?? 'Anonymous'), + onTap: apiSettings.activeUser == e + ? null + : () { + ref + .read(apiSettingsProvider.notifier) + .updateState( + apiSettings.copyWith( + activeUser: e, + ), + ); + // pop all routes and go to the home page + // while (context.canPop()) { + // context.pop(); + // } + context.goNamed( + Routes.home.name, + ); + }, + trailing: IconButton( + icon: const Icon(Icons.delete), + onPressed: () { + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text('Delete User'), + content: const Text( + 'Are you sure you want to delete this user?', + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + ref + .read( + authenticatedUserProvider + .notifier, + ) + .removeUser(e); + Navigator.of(context).pop(); + }, + child: const Text('Delete'), + ), + ], + ); + }, + ); + }, + ), + ), + ) + .nonNulls + .toList() + + // add buttons of delete server and add user to server at the end + ..addAll([ + ListTile( + leading: const Icon(Icons.person_add), + title: const Text('Add User'), + onTap: () async { + // open a dialog to add a new user with username and password or another method using only auth token + final addedUser = await showDialog( + context: context, + builder: (context) { + return _AddUserDialog( + server: registeredServer, + ); + }, + ); + + // if (addedUser != null) { + // // show a snackbar that the user has been added and ask if change to this user + // ScaffoldMessenger.of(context).showSnackBar( + // SnackBar( + // content: const Text( + // 'User added successfully, do you want to switch to this user?', + // ), + // action: SnackBarAction( + // label: 'Switch', + // onPressed: () { + // // set the active user + // ref + // .read(apiSettingsProvider.notifier) + // .updateState( + // apiSettings.copyWith( + // activeUser: addedUser, + // ), + // ); + + // context.goNamed(Routes.home.name); + // }, + // ), + // ), + // ); + // } + }, + ), + ListTile( + leading: const Icon(Icons.delete), + title: const Text('Delete Server'), + onTap: () { + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text('Delete Server'), + content: const Text( + 'Are you sure you want to delete this server and all its users?', + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + ref + .read( + audiobookShelfServerProvider + .notifier, + ) + .removeServer( + registeredServer, + removeUsers: true, + ); + Navigator.of(context).pop(); + }, + child: const Text('Delete'), + ), + ], + ); + }, + ); + }, + ), + ]), ); }, ), ), - actions: [ - TextButton( + const SizedBox(height: 20), + const Padding( + padding: EdgeInsets.all(8.0), + child: Text('Add New Server'), + ), + Form( + key: formKey, + autovalidateMode: AutovalidateMode.onUserInteraction, + child: AddNewServer( + controller: serverURIController, onPressed: () { - Navigator.of(dialogContext).pop(); // Close the dialog - }, - child: const Text('Cancel'), - ), - ], - ); - }, - ); - // No need for the SnackBar asking to switch user here anymore. - }, - ); - } -} - -class AvailableUserTile extends HookConsumerWidget { - const AvailableUserTile({ - super.key, - required this.user, - }); - - final model.AuthenticatedUser user; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final apiSettings = ref.watch(apiSettingsProvider); - - return ListTile( - selected: apiSettings.activeUser == user, - leading: apiSettings.activeUser == user - ? const Icon(Icons.person) - : const Icon(Icons.person_off_outlined), - title: Text(user.username ?? 'Anonymous'), - onTap: apiSettings.activeUser == user - ? null - : () { - ref.read(apiSettingsProvider.notifier).updateState( - apiSettings.copyWith( - activeUser: user, - ), - ); - // pop all routes and go to the home page - // while (context.canPop()) { - // context.pop(); - // } - context.goNamed( - Routes.home.name, - ); - }, - trailing: IconButton( - icon: const Icon(Icons.delete), - onPressed: () { - showDialog( - context: context, - builder: (context) { - return AlertDialog( - title: const Text('Remove User Login'), - content: Text.rich( - TextSpan( - children: [ - const TextSpan( - text: 'This will remove login details of the user ', - ), - TextSpan( - text: user.username ?? 'Anonymous', - style: TextStyle( - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.primary, + if (formKey.currentState!.validate()) { + try { + final newServer = model.AudiobookShelfServer( + serverUrl: makeBaseUrl(serverURIController.text), + ); + ref + .read(audiobookShelfServerProvider.notifier) + .addServer( + newServer, + ); + ref.read(apiSettingsProvider.notifier).updateState( + apiSettings.copyWith( + activeServer: newServer, + ), + ); + serverURIController.clear(); + } on ServerAlreadyExistsException catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(e.toString()), + ), + ); + } + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Invalid URL'), ), - ), - const TextSpan( - text: ' from this app.', - ), - ], - ), + ); + } + }, ), - actions: [ - TextButton( - onPressed: () { - Navigator.of(context).pop(); - }, - child: const Text('Cancel'), - ), - TextButton( - onPressed: () { - ref - .read( - authenticatedUsersProvider.notifier, - ) - .removeUser(user); - Navigator.of(context).pop(); - }, - child: const Text('Delete'), - ), - ], - ); - }, - ); - }, + ), + MiniPlayerBottomPadding(), + ], + ), + ), ), ); } } + +class _AddUserDialog extends HookConsumerWidget { + const _AddUserDialog({ + required this.server, + }); + + final model.AudiobookShelfServer server; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final usernameController = useTextEditingController(); + final passwordController = useTextEditingController(); + final authTokensController = useTextEditingController(); + final isPasswordVisible = useState(false); + final apiSettings = ref.watch(apiSettingsProvider); + final isMethodAuth = useState(false); + final api = ref.watch(audiobookshelfApiProvider(server.serverUrl)); + + final formKey = GlobalKey(); + + final serverErrorResponse = ErrorResponseHandler(); + + /// 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( + responseErrorHandler: serverErrorResponse.storeError, + ); + if (success != null) { + authenticatedUser = model.AuthenticatedUser( + server: server, + id: success.user.id, + username: success.user.username, + authToken: api.token!, + ); + } + } else { + final username = usernameController.text; + final password = passwordController.text; + final success = await api.login( + username: username, + password: password, + responseErrorHandler: serverErrorResponse.storeError, + ); + if (success != null) { + authenticatedUser = model.AuthenticatedUser( + server: server, + id: success.user.id, + username: username, + authToken: api.token!, + ); + } + } + // add the user to the list of users + if (authenticatedUser != null) { + ref.read(authenticatedUserProvider.notifier).addUser(authenticatedUser); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Login failed. Got response: ${serverErrorResponse.response.body} (${serverErrorResponse.response.statusCode})', + ), + ), + ); + } + return authenticatedUser; + } + + return AlertDialog( + // 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( + mainAxisSize: MainAxisSize.min, + children: [ + Wrap( + alignment: WrapAlignment.center, + spacing: 8.0, + children: [ + ChoiceChip( + label: const Text('Username/Password'), + selected: !isMethodAuth.value, + onSelected: (selected) { + isMethodAuth.value = !selected; + }, + ), + ChoiceChip( + label: const Text('Auth Token'), + selected: isMethodAuth.value, + onSelected: (selected) { + isMethodAuth.value = selected; + }, + ), + ], + ), + const SizedBox(height: 16), + if (isMethodAuth.value) + TextFormField( + controller: authTokensController, + decoration: const InputDecoration(labelText: 'Auth Token'), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter an auth token'; + } + return null; + }, + ) + else ...[ + TextFormField( + controller: usernameController, + decoration: const InputDecoration(labelText: 'Username'), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter a username'; + } + return null; + }, + ), + TextFormField( + controller: passwordController, + decoration: InputDecoration( + labelText: 'Password', + suffixIcon: IconButton( + icon: Icon( + isPasswordVisible.value + ? Icons.visibility + : Icons.visibility_off, + ), + onPressed: () { + isPasswordVisible.value = !isPasswordVisible.value; + }, + ), + ), + obscureText: !isPasswordVisible.value, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter a password'; + } + return null; + }, + ), + ], + ], + ), + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () async { + if (formKey.currentState!.validate()) { + final addedUser = await loginAndSave(); + if (addedUser != null) { + Navigator.of(context).pop(addedUser); + } + } + }, + child: const Text('Add User'), + ), + ], + ); + } +} diff --git a/lib/features/you/view/you_page.dart b/lib/features/you/view/you_page.dart index ca789db..53b33b8 100644 --- a/lib/features/you/view/you_page.dart +++ b/lib/features/you/view/you_page.dart @@ -183,10 +183,6 @@ class UserBar extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final me = ref.watch(meProvider); - final api = ref.watch(authenticatedApiProvider); - - final themeData = Theme.of(context); - final textTheme = themeData.textTheme; return me.when( data: (userData) { @@ -198,30 +194,19 @@ class UserBar extends HookConsumerWidget { // first letter of the username child: Text( userData.username[0].toUpperCase(), - style: textTheme.headlineLarge?.copyWith( + style: const TextStyle( + fontSize: 32, fontWeight: FontWeight.bold, ), ), ), const SizedBox(width: 16), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - userData.username, - style: textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 4), - Text( - api.baseUrl.toString(), - style: textTheme.bodyMedium?.copyWith( - color: - themeData.colorScheme.onSurface.withValues(alpha: 0.6), - ), - ), - ], + Text( + userData.username, + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + ), ), ], ); diff --git a/pubspec.yaml b/pubspec.yaml index 07c4948..2aafa66 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 0.0.18+9 +version: 0.0.17+8 environment: sdk: ">=3.3.4 <4.0.0"