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
This commit is contained in:
Dr.Blank 2025-04-23 15:00:01 +05:30 committed by GitHub
parent c8767b4e1e
commit 25be7fda03
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 447 additions and 493 deletions

View file

@ -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/settings/models/authenticated_user.dart' as model;
import 'package:vaani/shared/extensions/obfuscation.dart'; import 'package:vaani/shared/extensions/obfuscation.dart';
part 'authenticated_user_provider.g.dart'; part 'authenticated_users_provider.g.dart';
final _box = AvailableHiveBoxes.authenticatedUserBox; final _box = AvailableHiveBoxes.authenticatedUserBox;
final _logger = Logger('authenticated_user_provider'); final _logger = Logger('authenticated_users_provider');
/// provides with a set of authenticated users /// provides with a set of authenticated users
@riverpod @riverpod
class AuthenticatedUser extends _$AuthenticatedUser { class AuthenticatedUsers extends _$AuthenticatedUsers {
@override @override
Set<model.AuthenticatedUser> build() { Set<model.AuthenticatedUser> build() {
ref.listenSelf((_, __) { ref.listenSelf((_, __) {
@ -56,6 +56,7 @@ class AuthenticatedUser extends _$AuthenticatedUser {
void addUser(model.AuthenticatedUser user, {bool setActive = false}) { void addUser(model.AuthenticatedUser user, {bool setActive = false}) {
state = state..add(user); state = state..add(user);
ref.invalidateSelf();
if (setActive) { if (setActive) {
final apiSettings = ref.read(apiSettingsProvider); final apiSettings = ref.read(apiSettingsProvider);
ref.read(apiSettingsProvider.notifier).updateState( ref.read(apiSettingsProvider.notifier).updateState(
@ -82,9 +83,12 @@ class AuthenticatedUser extends _$AuthenticatedUser {
// also remove the user from the active user // also remove the user from the active user
final apiSettings = ref.read(apiSettingsProvider); final apiSettings = ref.read(apiSettingsProvider);
if (apiSettings.activeUser == user) { 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( ref.read(apiSettingsProvider.notifier).updateState(
apiSettings.copyWith( apiSettings.copyWith(
activeUser: null, activeUser: newActiveUser,
), ),
); );
} }

View file

@ -1,28 +1,30 @@
// GENERATED CODE - DO NOT MODIFY BY HAND // GENERATED CODE - DO NOT MODIFY BY HAND
part of 'authenticated_user_provider.dart'; part of 'authenticated_users_provider.dart';
// ************************************************************************** // **************************************************************************
// RiverpodGenerator // RiverpodGenerator
// ************************************************************************** // **************************************************************************
String _$authenticatedUserHash() => r'1983527595207c63a12bc84cf0bf1a3c1d729506'; String _$authenticatedUsersHash() =>
r'5fdd472f62fc3b73ff8417cdce9f02e86c33d00f';
/// provides with a set of authenticated users /// provides with a set of authenticated users
/// ///
/// Copied from [AuthenticatedUser]. /// Copied from [AuthenticatedUsers].
@ProviderFor(AuthenticatedUser) @ProviderFor(AuthenticatedUsers)
final authenticatedUserProvider = AutoDisposeNotifierProvider<AuthenticatedUser, final authenticatedUsersProvider = AutoDisposeNotifierProvider<
Set<model.AuthenticatedUser>>.internal( AuthenticatedUsers, Set<model.AuthenticatedUser>>.internal(
AuthenticatedUser.new, AuthenticatedUsers.new,
name: r'authenticatedUserProvider', name: r'authenticatedUsersProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null ? null
: _$authenticatedUserHash, : _$authenticatedUsersHash,
dependencies: null, dependencies: null,
allTransitiveDependencies: null, allTransitiveDependencies: null,
); );
typedef _$AuthenticatedUser = AutoDisposeNotifier<Set<model.AuthenticatedUser>>; typedef _$AuthenticatedUsers
= AutoDisposeNotifier<Set<model.AuthenticatedUser>>;
// ignore_for_file: type=lint // 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 // 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

View file

@ -1,6 +1,6 @@
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:riverpod_annotation/riverpod_annotation.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/db/storage.dart';
import 'package:vaani/settings/api_settings_provider.dart'; import 'package:vaani/settings/api_settings_provider.dart';
import 'package:vaani/settings/models/audiobookshelf_server.dart' as model; import 'package:vaani/settings/models/audiobookshelf_server.dart' as model;
@ -88,7 +88,7 @@ class AudiobookShelfServer extends _$AudiobookShelfServer {
} }
// remove the users of this server // remove the users of this server
if (removeUsers) { if (removeUsers) {
ref.read(authenticatedUserProvider.notifier).removeUsersOfServer(server); ref.read(authenticatedUsersProvider.notifier).removeUsersOfServer(server);
} }
} }

View file

@ -7,7 +7,7 @@ part of 'server_provider.dart';
// ************************************************************************** // **************************************************************************
String _$audiobookShelfServerHash() => String _$audiobookShelfServerHash() =>
r'09e7e37ddc794c45eafbaab7eba82c9dd17faa93'; r'31a96b431221965cd586aad670a32ca901539e41';
/// provides with a set of servers added by the user /// provides with a set of servers added by the user
/// ///

View file

@ -39,6 +39,22 @@ class OnboardingSinglePage extends HookConsumerWidget {
} }
} }
Widget fadeSlideTransitionBuilder(
Widget child,
Animation<double> animation,
) {
return FadeTransition(
opacity: animation,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 0.3),
end: const Offset(0, 0),
).animate(animation),
child: child,
),
);
}
class OnboardingBody extends HookConsumerWidget { class OnboardingBody extends HookConsumerWidget {
const OnboardingBody({ const OnboardingBody({
super.key, super.key,
@ -54,22 +70,6 @@ class OnboardingBody extends HookConsumerWidget {
final canUserLogin = useState(apiSettings.activeServer != null); final canUserLogin = useState(apiSettings.activeServer != null);
fadeSlideTransitionBuilder(
Widget child,
Animation<double> animation,
) {
return FadeTransition(
opacity: animation,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 0.3),
end: const Offset(0, 0),
).animate(animation),
child: child,
),
);
}
return Column( return Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,

View file

@ -1,33 +1,42 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shelfsdk/audiobookshelf_api.dart'; import 'package:shelfsdk/audiobookshelf_api.dart' show AuthMethod;
import 'package:vaani/api/api_provider.dart'; import 'package:vaani/api/api_provider.dart' show serverStatusProvider;
import 'package:vaani/api/server_provider.dart'; import 'package:vaani/api/server_provider.dart'
import 'package:vaani/features/onboarding/view/user_login_with_open_id.dart'; show ServerAlreadyExistsException, audiobookShelfServerProvider;
import 'package:vaani/features/onboarding/view/user_login_with_password.dart'; import 'package:vaani/features/onboarding/view/onboarding_single_page.dart'
import 'package:vaani/features/onboarding/view/user_login_with_token.dart'; show fadeSlideTransitionBuilder;
import 'package:vaani/hacks/fix_autofill_losing_focus.dart'; import 'package:vaani/features/onboarding/view/user_login_with_open_id.dart'
import 'package:vaani/models/error_response.dart'; show UserLoginWithOpenID;
import 'package:vaani/settings/api_settings_provider.dart'; 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; import 'package:vaani/settings/models/models.dart' as model;
class UserLoginWidget extends HookConsumerWidget { class UserLoginWidget extends HookConsumerWidget {
UserLoginWidget({ UserLoginWidget({
super.key, super.key,
required this.server, required this.server,
this.onSuccess,
}); });
final Uri server; final Uri server;
final serverStatusError = ErrorResponseHandler(); final Function(model.AuthenticatedUser)? onSuccess;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final serverStatusError = useMemoized(() => ErrorResponseHandler(), []);
final serverStatus = final serverStatus =
ref.watch(serverStatusProvider(server, serverStatusError.storeError)); ref.watch(serverStatusProvider(server, serverStatusError.storeError));
final api = ref.watch(audiobookshelfApiProvider(server));
return serverStatus.when( return serverStatus.when(
data: (value) { data: (value) {
if (value == null) { if (value == null) {
@ -42,6 +51,7 @@ class UserLoginWidget extends HookConsumerWidget {
openIDAvailable: openIDAvailable:
value.authMethods?.contains(AuthMethod.openid) ?? false, value.authMethods?.contains(AuthMethod.openid) ?? false,
openIDButtonText: value.authFormData?.authOpenIDButtonText, openIDButtonText: value.authFormData?.authOpenIDButtonText,
onSuccess: onSuccess,
); );
}, },
loading: () { loading: () {
@ -88,6 +98,7 @@ class UserLoginMultipleAuth extends HookConsumerWidget {
this.openIDAvailable = false, this.openIDAvailable = false,
this.onPressed, this.onPressed,
this.openIDButtonText, this.openIDButtonText,
this.onSuccess,
}); });
final Uri server; final Uri server;
@ -95,6 +106,7 @@ class UserLoginMultipleAuth extends HookConsumerWidget {
final bool openIDAvailable; final bool openIDAvailable;
final void Function()? onPressed; final void Function()? onPressed;
final String? openIDButtonText; final String? openIDButtonText;
final Function(model.AuthenticatedUser)? onSuccess;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@ -104,8 +116,6 @@ class UserLoginMultipleAuth extends HookConsumerWidget {
localAvailable ? AuthMethodChoice.local : AuthMethodChoice.authToken, localAvailable ? AuthMethodChoice.local : AuthMethodChoice.authToken,
); );
final apiSettings = ref.watch(apiSettingsProvider);
model.AudiobookShelfServer addServer() { model.AudiobookShelfServer addServer() {
var newServer = model.AudiobookShelfServer( var newServer = model.AudiobookShelfServer(
serverUrl: server, serverUrl: server,
@ -119,9 +129,9 @@ class UserLoginMultipleAuth extends HookConsumerWidget {
newServer = e.server; newServer = e.server;
} finally { } finally {
ref.read(apiSettingsProvider.notifier).updateState( ref.read(apiSettingsProvider.notifier).updateState(
apiSettings.copyWith( ref.read(apiSettingsProvider).copyWith(
activeServer: newServer, activeServer: newServer,
), ),
); );
} }
return newServer; return newServer;
@ -172,26 +182,36 @@ class UserLoginMultipleAuth extends HookConsumerWidget {
} }
}, },
), ),
], ].animate(interval: 100.ms).fadeIn(
duration: 150.ms,
curve: Curves.easeIn,
),
), ),
), ),
Padding( Padding(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
child: switch (methodChoice.value) { child: AnimatedSwitcher(
AuthMethodChoice.authToken => UserLoginWithToken( duration: 200.ms,
server: server, transitionBuilder: fadeSlideTransitionBuilder,
addServer: addServer, child: switch (methodChoice.value) {
), AuthMethodChoice.authToken => UserLoginWithToken(
AuthMethodChoice.local => UserLoginWithPassword( server: server,
server: server, addServer: addServer,
addServer: addServer, onSuccess: onSuccess,
), ),
AuthMethodChoice.openid => UserLoginWithOpenID( AuthMethodChoice.local => UserLoginWithPassword(
server: server, server: server,
addServer: addServer, addServer: addServer,
openIDButtonText: openIDButtonText, onSuccess: onSuccess,
), ),
}, AuthMethodChoice.openid => UserLoginWithOpenID(
server: server,
addServer: addServer,
openIDButtonText: openIDButtonText,
onSuccess: onSuccess,
),
},
),
), ),
], ],
), ),

View file

@ -20,12 +20,14 @@ class UserLoginWithOpenID extends HookConsumerWidget {
required this.server, required this.server,
required this.addServer, required this.addServer,
this.openIDButtonText, this.openIDButtonText,
this.onSuccess,
}); });
final Uri server; final Uri server;
final model.AudiobookShelfServer Function() addServer; final model.AudiobookShelfServer Function() addServer;
final String? openIDButtonText; final String? openIDButtonText;
final responseErrorHandler = ErrorResponseHandler(name: 'OpenID'); final responseErrorHandler = ErrorResponseHandler(name: 'OpenID');
final Function(model.AuthenticatedUser)? onSuccess;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {

View file

@ -5,7 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lottie/lottie.dart'; import 'package:lottie/lottie.dart';
import 'package:shelfsdk/audiobookshelf_api.dart'; import 'package:shelfsdk/audiobookshelf_api.dart';
import 'package:vaani/api/api_provider.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/hacks/fix_autofill_losing_focus.dart';
import 'package:vaani/models/error_response.dart'; import 'package:vaani/models/error_response.dart';
import 'package:vaani/router/router.dart'; import 'package:vaani/router/router.dart';
@ -18,11 +18,13 @@ class UserLoginWithPassword extends HookConsumerWidget {
super.key, super.key,
required this.server, required this.server,
required this.addServer, required this.addServer,
this.onSuccess,
}); });
final Uri server; final Uri server;
final model.AudiobookShelfServer Function() addServer; final model.AudiobookShelfServer Function() addServer;
final serverErrorResponse = ErrorResponseHandler(); final serverErrorResponse = ErrorResponseHandler();
final Function(model.AuthenticatedUser)? onSuccess;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@ -81,13 +83,16 @@ class UserLoginWithPassword extends HookConsumerWidget {
username: username, username: username,
authToken: api.token!, authToken: api.token!,
); );
// add the user to the list of users
ref
.read(authenticatedUserProvider.notifier)
.addUser(authenticatedUser, setActive: true);
// redirect to the library page if (onSuccess != null) {
GoRouter.of(context).goNamed(Routes.home.name); 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( return Center(

View file

@ -4,7 +4,7 @@ import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shelfsdk/audiobookshelf_api.dart'; import 'package:shelfsdk/audiobookshelf_api.dart';
import 'package:vaani/api/api_provider.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/models/error_response.dart';
import 'package:vaani/router/router.dart'; import 'package:vaani/router/router.dart';
import 'package:vaani/settings/models/models.dart' as model; import 'package:vaani/settings/models/models.dart' as model;
@ -14,11 +14,13 @@ class UserLoginWithToken extends HookConsumerWidget {
super.key, super.key,
required this.server, required this.server,
required this.addServer, required this.addServer,
this.onSuccess,
}); });
final Uri server; final Uri server;
final model.AudiobookShelfServer Function() addServer; final model.AudiobookShelfServer Function() addServer;
final serverErrorResponse = ErrorResponseHandler(); final serverErrorResponse = ErrorResponseHandler();
final Function(model.AuthenticatedUser)? onSuccess;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@ -65,11 +67,14 @@ class UserLoginWithToken extends HookConsumerWidget {
authToken: api.token!, authToken: api.token!,
); );
ref if (onSuccess != null) {
.read(authenticatedUserProvider.notifier) onSuccess!(authenticatedUser);
.addUser(authenticatedUser, setActive: true); } else {
ref
context.goNamed(Routes.home.name); .read(authenticatedUsersProvider.notifier)
.addUser(authenticatedUser, setActive: true);
context.goNamed(Routes.home.name);
}
} }
return Form( return Form(

View file

@ -2,17 +2,22 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:vaani/api/api_provider.dart'; import 'package:vaani/api/api_provider.dart' show makeBaseUrl;
import 'package:vaani/api/authenticated_user_provider.dart'; import 'package:vaani/api/authenticated_users_provider.dart'
import 'package:vaani/api/server_provider.dart'; show authenticatedUsersProvider;
import 'package:vaani/features/player/view/mini_player_bottom_padding.dart'; import 'package:vaani/api/server_provider.dart'
import 'package:vaani/main.dart'; show ServerAlreadyExistsException, audiobookShelfServerProvider;
import 'package:vaani/models/error_response.dart'; import 'package:vaani/features/onboarding/view/user_login.dart'
import 'package:vaani/router/router.dart'; show UserLoginWidget;
import 'package:vaani/settings/api_settings_provider.dart'; 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/settings/models/models.dart' as model;
import 'package:vaani/shared/extensions/obfuscation.dart'; import 'package:vaani/shared/extensions/obfuscation.dart' show ObfuscateSet;
import 'package:vaani/shared/widgets/add_new_server.dart'; import 'package:vaani/shared/widgets/add_new_server.dart' show AddNewServer;
class ServerManagerPage extends HookConsumerWidget { class ServerManagerPage extends HookConsumerWidget {
const ServerManagerPage({ const ServerManagerPage({
@ -21,15 +26,6 @@ class ServerManagerPage extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { 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<FormState>();
appLogger.fine('registered servers: ${registeredServers.obfuscate()}');
appLogger.fine('available users: ${availableUsers.obfuscate()}');
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('Manage Accounts'), title: const Text('Manage Accounts'),
@ -37,241 +33,119 @@ class ServerManagerPage extends HookConsumerWidget {
body: Center( body: Center(
child: Padding( child: Padding(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
child: Column( child: ServerManagerBody(),
// 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(),
],
),
), ),
), ),
); );
} }
} }
class _AddUserDialog extends HookConsumerWidget { class ServerManagerBody extends HookConsumerWidget {
const _AddUserDialog({ 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<FormState>();
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<Widget>(
(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, required this.server,
}); });
@ -279,178 +153,220 @@ class _AddUserDialog extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final usernameController = useTextEditingController(); return ListTile(
final passwordController = useTextEditingController(); leading: const Icon(Icons.delete),
final authTokensController = useTextEditingController(); title: const Text('Delete Server'),
final isPasswordVisible = useState(false); onTap: () {
final apiSettings = ref.watch(apiSettingsProvider); showDialog(
final isMethodAuth = useState(false); context: context,
final api = ref.watch(audiobookshelfApiProvider(server.serverUrl)); builder: (context) {
return AlertDialog(
final formKey = GlobalKey<FormState>(); title: const Text('Remove Server and Users'),
// Make content scrollable in case of smaller screens/keyboard
final serverErrorResponse = ErrorResponseHandler(); content: SingleChildScrollView(
child: Text.rich(
/// Login to the server and save the user TextSpan(
Future<model.AuthenticatedUser?> loginAndSave() async { children: [
model.AuthenticatedUser? authenticatedUser; const TextSpan(
if (isMethodAuth.value) { text: 'This will remove the server ',
api.token = authTokensController.text; ),
final success = await api.misc.authorize( TextSpan(
responseErrorHandler: serverErrorResponse.storeError, text: server.serverUrl.host,
); style: TextStyle(
if (success != null) { fontWeight: FontWeight.bold,
authenticatedUser = model.AuthenticatedUser( color: Theme.of(context).colorScheme.primary,
server: server, ),
id: success.user.id, ),
username: success.user.username, const TextSpan(
authToken: api.token!, text: ' and all its users\' login info from this app.',
); ),
} ],
} 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'), actions: [
selected: isMethodAuth.value, TextButton(
onSelected: (selected) { onPressed: () {
isMethodAuth.value = selected; 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) { class AddUserTile extends HookConsumerWidget {
Navigator.of(context).pop(addedUser); const AddUserTile({
} super.key,
} required this.server,
}, });
child: const Text('Add User'),
), 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'),
),
],
);
},
);
},
),
); );
} }
} }