From c8767b4e1ea39bd17611d4f1e5cb7f62c86de1a3 Mon Sep 17 00:00:00 2001 From: "Dr.Blank" <64108942+Dr-Blank@users.noreply.github.com> Date: Wed, 23 Apr 2025 14:51:39 +0530 Subject: [PATCH 1/4] feat: enhance UserBar with user API details and improved text styling --- lib/features/you/view/you_page.dart | 31 +++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/lib/features/you/view/you_page.dart b/lib/features/you/view/you_page.dart index 53b33b8..ca789db 100644 --- a/lib/features/you/view/you_page.dart +++ b/lib/features/you/view/you_page.dart @@ -183,6 +183,10 @@ 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) { @@ -194,19 +198,30 @@ class UserBar extends HookConsumerWidget { // first letter of the username child: Text( userData.username[0].toUpperCase(), - style: const TextStyle( - fontSize: 32, + style: textTheme.headlineLarge?.copyWith( fontWeight: FontWeight.bold, ), ), ), const SizedBox(width: 16), - Text( - userData.username, - style: const TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - ), + 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), + ), + ), + ], ), ], ); From 25be7fda03d6ebfe198cb46a8a949ddfcb26fa98 Mon Sep 17 00:00:00 2001 From: "Dr.Blank" Date: Wed, 23 Apr 2025 15:00:01 +0530 Subject: [PATCH 2/4] fix: keyboard not showing when adding new user (#79) * feat: add fadeSlideTransitionBuilder for smoother transitions in user login * fix: reuse onboarding components on server manager page * fix: gaining focus rebuilt the widget using memoized fixes this issue --- ...dart => authenticated_users_provider.dart} | 12 +- ...rt => authenticated_users_provider.g.dart} | 22 +- lib/api/server_provider.dart | 4 +- lib/api/server_provider.g.dart | 2 +- .../view/onboarding_single_page.dart | 32 +- lib/features/onboarding/view/user_login.dart | 86 +- .../view/user_login_with_open_id.dart | 2 + .../view/user_login_with_password.dart | 19 +- .../view/user_login_with_token.dart | 17 +- lib/features/you/view/server_manager.dart | 744 ++++++++---------- 10 files changed, 447 insertions(+), 493 deletions(-) rename lib/api/{authenticated_user_provider.dart => authenticated_users_provider.dart} (86%) rename lib/api/{authenticated_user_provider.g.dart => authenticated_users_provider.g.dart} (54%) diff --git a/lib/api/authenticated_user_provider.dart b/lib/api/authenticated_users_provider.dart similarity index 86% rename from lib/api/authenticated_user_provider.dart rename to lib/api/authenticated_users_provider.dart index c4f065c..5e78fab 100644 --- a/lib/api/authenticated_user_provider.dart +++ b/lib/api/authenticated_users_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_user_provider.g.dart'; +part 'authenticated_users_provider.g.dart'; final _box = AvailableHiveBoxes.authenticatedUserBox; -final _logger = Logger('authenticated_user_provider'); +final _logger = Logger('authenticated_users_provider'); /// provides with a set of authenticated users @riverpod -class AuthenticatedUser extends _$AuthenticatedUser { +class AuthenticatedUsers extends _$AuthenticatedUsers { @override Set build() { ref.listenSelf((_, __) { @@ -56,6 +56,7 @@ class AuthenticatedUser extends _$AuthenticatedUser { 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( @@ -82,9 +83,12 @@ class AuthenticatedUser extends _$AuthenticatedUser { // 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: null, + activeUser: newActiveUser, ), ); } diff --git a/lib/api/authenticated_user_provider.g.dart b/lib/api/authenticated_users_provider.g.dart similarity index 54% rename from lib/api/authenticated_user_provider.g.dart rename to lib/api/authenticated_users_provider.g.dart index b12f8c6..44a2610 100644 --- a/lib/api/authenticated_user_provider.g.dart +++ b/lib/api/authenticated_users_provider.g.dart @@ -1,28 +1,30 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'authenticated_user_provider.dart'; +part of 'authenticated_users_provider.dart'; // ************************************************************************** // RiverpodGenerator // ************************************************************************** -String _$authenticatedUserHash() => r'1983527595207c63a12bc84cf0bf1a3c1d729506'; +String _$authenticatedUsersHash() => + r'5fdd472f62fc3b73ff8417cdce9f02e86c33d00f'; /// provides with a set of authenticated users /// -/// Copied from [AuthenticatedUser]. -@ProviderFor(AuthenticatedUser) -final authenticatedUserProvider = AutoDisposeNotifierProvider>.internal( - AuthenticatedUser.new, - name: r'authenticatedUserProvider', +/// Copied from [AuthenticatedUsers]. +@ProviderFor(AuthenticatedUsers) +final authenticatedUsersProvider = AutoDisposeNotifierProvider< + AuthenticatedUsers, Set>.internal( + AuthenticatedUsers.new, + name: r'authenticatedUsersProvider', debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') ? null - : _$authenticatedUserHash, + : _$authenticatedUsersHash, dependencies: null, allTransitiveDependencies: null, ); -typedef _$AuthenticatedUser = AutoDisposeNotifier>; +typedef _$AuthenticatedUsers + = 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 6bfefb1..ef1c864 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_user_provider.dart'; +import 'package:vaani/api/authenticated_users_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(authenticatedUserProvider.notifier).removeUsersOfServer(server); + ref.read(authenticatedUsersProvider.notifier).removeUsersOfServer(server); } } diff --git a/lib/api/server_provider.g.dart b/lib/api/server_provider.g.dart index 5fc811c..ff2406a 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'09e7e37ddc794c45eafbaab7eba82c9dd17faa93'; + r'31a96b431221965cd586aad670a32ca901539e41'; /// 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 7f071a0..b9a7eb5 100644 --- a/lib/features/onboarding/view/onboarding_single_page.dart +++ b/lib/features/onboarding/view/onboarding_single_page.dart @@ -39,6 +39,22 @@ 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, @@ -54,22 +70,6 @@ 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 bf65b12..5d12aa8 100644 --- a/lib/features/onboarding/view/user_login.dart +++ b/lib/features/onboarding/view/user_login.dart @@ -1,33 +1,42 @@ 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'; -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: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:vaani/settings/models/models.dart' as model; class UserLoginWidget extends HookConsumerWidget { UserLoginWidget({ super.key, required this.server, + this.onSuccess, }); final Uri server; - final serverStatusError = ErrorResponseHandler(); + final Function(model.AuthenticatedUser)? onSuccess; @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) { @@ -42,6 +51,7 @@ class UserLoginWidget extends HookConsumerWidget { openIDAvailable: value.authMethods?.contains(AuthMethod.openid) ?? false, openIDButtonText: value.authFormData?.authOpenIDButtonText, + onSuccess: onSuccess, ); }, loading: () { @@ -88,6 +98,7 @@ class UserLoginMultipleAuth extends HookConsumerWidget { this.openIDAvailable = false, this.onPressed, this.openIDButtonText, + this.onSuccess, }); final Uri server; @@ -95,6 +106,7 @@ 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) { @@ -104,8 +116,6 @@ class UserLoginMultipleAuth extends HookConsumerWidget { localAvailable ? AuthMethodChoice.local : AuthMethodChoice.authToken, ); - final apiSettings = ref.watch(apiSettingsProvider); - model.AudiobookShelfServer addServer() { var newServer = model.AudiobookShelfServer( serverUrl: server, @@ -119,9 +129,9 @@ class UserLoginMultipleAuth extends HookConsumerWidget { newServer = e.server; } finally { ref.read(apiSettingsProvider.notifier).updateState( - apiSettings.copyWith( - activeServer: newServer, - ), + ref.read(apiSettingsProvider).copyWith( + activeServer: newServer, + ), ); } return newServer; @@ -172,26 +182,36 @@ class UserLoginMultipleAuth extends HookConsumerWidget { } }, ), - ], + ].animate(interval: 100.ms).fadeIn( + duration: 150.ms, + curve: Curves.easeIn, + ), ), ), Padding( padding: const EdgeInsets.all(8.0), - 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, - ), - }, + 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, + ), + }, + ), ), ], ), 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 71baa42..b3a1d9e 100644 --- a/lib/features/onboarding/view/user_login_with_open_id.dart +++ b/lib/features/onboarding/view/user_login_with_open_id.dart @@ -20,12 +20,14 @@ 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 b730cc5..210da77 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_user_provider.dart'; +import 'package:vaani/api/authenticated_users_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,11 +18,13 @@ 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) { @@ -81,13 +83,16 @@ 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); - // redirect to the library page - GoRouter.of(context).goNamed(Routes.home.name); + 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); + } } 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 a67b96b..7d2fcfb 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_user_provider.dart'; +import 'package:vaani/api/authenticated_users_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,11 +14,13 @@ 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) { @@ -65,11 +67,14 @@ class UserLoginWithToken extends HookConsumerWidget { authToken: api.token!, ); - ref - .read(authenticatedUserProvider.notifier) - .addUser(authenticatedUser, setActive: true); - - context.goNamed(Routes.home.name); + if (onSuccess != null) { + onSuccess!(authenticatedUser); + } else { + ref + .read(authenticatedUsersProvider.notifier) + .addUser(authenticatedUser, setActive: true); + context.goNamed(Routes.home.name); + } } return Form( diff --git a/lib/features/you/view/server_manager.dart b/lib/features/you/view/server_manager.dart index f44eb77..75e3f43 100644 --- a/lib/features/you/view/server_manager.dart +++ b/lib/features/you/view/server_manager.dart @@ -2,17 +2,22 @@ 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'; -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/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/settings/models/models.dart' as model; -import 'package:vaani/shared/extensions/obfuscation.dart'; -import 'package:vaani/shared/widgets/add_new_server.dart'; +import 'package:vaani/shared/extensions/obfuscation.dart' show ObfuscateSet; +import 'package:vaani/shared/widgets/add_new_server.dart' show AddNewServer; class ServerManagerPage extends HookConsumerWidget { const ServerManagerPage({ @@ -21,15 +26,6 @@ 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'), @@ -37,241 +33,119 @@ class ServerManagerPage extends HookConsumerWidget { body: Center( child: Padding( padding: const EdgeInsets.all(8.0), - child: 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}', - ), - // 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'), - ), - ], - ); - }, - ); - }, - ), - ]), - ); - }, - ), - ), - 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(), - ], - ), + child: ServerManagerBody(), ), ), ); } } -class _AddUserDialog extends HookConsumerWidget { - const _AddUserDialog({ +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, }); @@ -279,178 +153,220 @@ class _AddUserDialog extends HookConsumerWidget { @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, + 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.', + ), + ], ), - ), - ], - ), - ), - 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; + ), + 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'), ), ], - ), - 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'), - ), - ], + ); + }, + ); + } +} + +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); + }, + ), + ), + ); + }, + ), + ), + actions: [ + TextButton( + 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, + ), + ), + 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'), + ), + ], + ); + }, + ); + }, + ), ); } } From bae99292a2c5c59b01e02a00e4a825b60b01205e Mon Sep 17 00:00:00 2001 From: "Dr.Blank" Date: Wed, 23 Apr 2025 16:23:57 +0530 Subject: [PATCH 3/4] feat: add PlayingIndicatorIcon widget for animated playback indication (#80) --- lib/features/onboarding/view/user_login.dart | 2 +- .../widgets/chapter_selection_button.dart | 61 ++++-- .../view/widgets/playing_indicator_icon.dart | 194 ++++++++++++++++++ 3 files changed, 237 insertions(+), 20 deletions(-) create mode 100644 lib/features/player/view/widgets/playing_indicator_icon.dart diff --git a/lib/features/onboarding/view/user_login.dart b/lib/features/onboarding/view/user_login.dart index 5d12aa8..8aeff14 100644 --- a/lib/features/onboarding/view/user_login.dart +++ b/lib/features/onboarding/view/user_login.dart @@ -22,7 +22,7 @@ import 'package:vaani/settings/api_settings_provider.dart' import 'package:vaani/settings/models/models.dart' as model; class UserLoginWidget extends HookConsumerWidget { - UserLoginWidget({ + const UserLoginWidget({ super.key, required this.server, this.onSuccess, diff --git a/lib/features/player/view/widgets/chapter_selection_button.dart b/lib/features/player/view/widgets/chapter_selection_button.dart index 889392a..04cbd0e 100644 --- a/lib/features/player/view/widgets/chapter_selection_button.dart +++ b/lib/features/player/view/widgets/chapter_selection_button.dart @@ -1,13 +1,18 @@ 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'; -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'; +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; class ChapterSelectionButton extends HookConsumerWidget { const ChapterSelectionButton({ @@ -67,6 +72,7 @@ class ChapterSelectionModal extends HookConsumerWidget { useTimer(scrollToCurrentChapter, 500.ms); // useInterval(scrollToCurrentChapter, 500.ms); + final theme = Theme.of(context); return Column( children: [ ListTile( @@ -81,24 +87,41 @@ class ChapterSelectionModal extends HookConsumerWidget { child: currentBook?.chapters == null ? const Text('No chapters found') : Column( - children: [ - for (final chapter in currentBook!.chapters) - ListTile( - title: Text(chapter.title), - trailing: Text( - '(${chapter.duration.smartBinaryFormat})', - ), - selected: currentChapterIndex == chapter.id, - key: currentChapterIndex == chapter.id - ? chapterKey + 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( + '(${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, 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 new file mode 100644 index 0000000..d179797 --- /dev/null +++ b/lib/features/player/view/widgets/playing_indicator_icon.dart @@ -0,0 +1,194 @@ +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, + }); +} From 23e5d73bead34b0ef34e7271ab5f9cf2eef86cb4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 23 Apr 2025 11:01:25 +0000 Subject: [PATCH 4/4] chore(release): bump version to v0.0.18 --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index 2aafa66..07c4948 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.17+8 +version: 0.0.18+9 environment: sdk: ">=3.3.4 <4.0.0"