2024-05-08 05:03:49 -04:00
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
2024-09-06 15:10:00 -04:00
|
|
|
import 'package:go_router/go_router.dart';
|
2024-05-08 05:03:49 -04:00
|
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
|
|
|
import 'package:lottie/lottie.dart';
|
2024-09-06 15:10:00 -04:00
|
|
|
import 'package:shelfsdk/audiobookshelf_api.dart';
|
|
|
|
|
import 'package:vaani/api/api_provider.dart';
|
|
|
|
|
import 'package:vaani/api/authenticated_user_provider.dart';
|
|
|
|
|
import 'package:vaani/api/server_provider.dart';
|
2024-08-23 04:21:46 -04:00
|
|
|
import 'package:vaani/hacks/fix_autofill_losing_focus.dart';
|
2024-09-06 15:10:00 -04:00
|
|
|
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;
|
2024-05-08 05:03:49 -04:00
|
|
|
|
2024-09-06 15:10:00 -04:00
|
|
|
class UserLoginWidget extends HookConsumerWidget {
|
|
|
|
|
UserLoginWidget({
|
2024-05-08 05:03:49 -04:00
|
|
|
super.key,
|
2024-09-06 15:10:00 -04:00
|
|
|
required this.server,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
final Uri server;
|
|
|
|
|
final serverStatusError = ErrorResponse();
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
|
|
|
final serverStatus =
|
|
|
|
|
ref.watch(serverStatusProvider(server, serverStatusError.storeError));
|
|
|
|
|
|
|
|
|
|
final api = ref.watch(audiobookshelfApiProvider(server));
|
|
|
|
|
|
|
|
|
|
return serverStatus.when(
|
|
|
|
|
data: (value) {
|
|
|
|
|
if (value == null) {
|
|
|
|
|
// check the error message
|
|
|
|
|
return Text(serverStatusError.response.body);
|
|
|
|
|
}
|
|
|
|
|
// check available authentication methods and return the correct widget
|
|
|
|
|
return UserLoginMultipleAuth(
|
|
|
|
|
server: server,
|
|
|
|
|
localAvailable:
|
|
|
|
|
value.authMethods?.contains(AuthMethod.local) ?? false,
|
|
|
|
|
openidAvailable:
|
|
|
|
|
value.authMethods?.contains(AuthMethod.openid) ?? false,
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
loading: () {
|
|
|
|
|
return const Center(
|
|
|
|
|
child: CircularProgressIndicator(),
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
error: (error, _) {
|
|
|
|
|
return Center(
|
|
|
|
|
child: Column(
|
|
|
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
|
|
|
children: [
|
|
|
|
|
Text('Server is not reachable: $error'),
|
|
|
|
|
ElevatedButton(
|
|
|
|
|
onPressed: () {
|
|
|
|
|
ref.invalidate(
|
|
|
|
|
serverStatusProvider(
|
|
|
|
|
server,
|
|
|
|
|
serverStatusError.storeError,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
child: const Text('Try again'),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
enum AuthMethodChoice {
|
|
|
|
|
local,
|
|
|
|
|
openid,
|
|
|
|
|
authToken,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class UserLoginMultipleAuth extends HookConsumerWidget {
|
|
|
|
|
const UserLoginMultipleAuth({
|
|
|
|
|
super.key,
|
|
|
|
|
required this.server,
|
|
|
|
|
this.localAvailable = false,
|
|
|
|
|
this.openidAvailable = false,
|
2024-05-08 05:03:49 -04:00
|
|
|
this.onPressed,
|
|
|
|
|
});
|
|
|
|
|
|
2024-09-06 15:10:00 -04:00
|
|
|
final Uri server;
|
|
|
|
|
final bool localAvailable;
|
|
|
|
|
final bool openidAvailable;
|
2024-05-08 05:03:49 -04:00
|
|
|
final void Function()? onPressed;
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
2024-09-06 15:10:00 -04:00
|
|
|
// will show choice chips for the available authentication methods
|
|
|
|
|
// authToken method is always available
|
|
|
|
|
final methodChoice = useState<AuthMethodChoice>(
|
|
|
|
|
localAvailable ? AuthMethodChoice.local : AuthMethodChoice.authToken,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
final apiSettings = ref.watch(apiSettingsProvider);
|
|
|
|
|
|
|
|
|
|
model.AudiobookShelfServer addServer() {
|
|
|
|
|
var newServer = model.AudiobookShelfServer(
|
|
|
|
|
serverUrl: server,
|
|
|
|
|
);
|
|
|
|
|
try {
|
|
|
|
|
// add the server to the list of servers
|
|
|
|
|
ref.read(audiobookShelfServerProvider.notifier).addServer(
|
|
|
|
|
newServer,
|
|
|
|
|
);
|
|
|
|
|
} on ServerAlreadyExistsException catch (e) {
|
|
|
|
|
newServer = e.server;
|
|
|
|
|
} finally {
|
|
|
|
|
ref.read(apiSettingsProvider.notifier).updateState(
|
|
|
|
|
apiSettings.copyWith(
|
|
|
|
|
activeServer: newServer,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
return newServer;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Center(
|
|
|
|
|
child: InactiveFocusScopeObserver(
|
|
|
|
|
child: AutofillGroup(
|
|
|
|
|
child: Padding(
|
|
|
|
|
padding: const EdgeInsets.all(8.0),
|
|
|
|
|
child: Column(
|
|
|
|
|
children: [
|
|
|
|
|
Wrap(
|
|
|
|
|
// mainAxisAlignment: MainAxisAlignment.center,
|
|
|
|
|
spacing: 10,
|
|
|
|
|
runAlignment: WrapAlignment.center,
|
|
|
|
|
runSpacing: 10,
|
|
|
|
|
alignment: WrapAlignment.center,
|
|
|
|
|
children: [
|
|
|
|
|
// a small label to show the user what to do
|
|
|
|
|
if (localAvailable)
|
|
|
|
|
ChoiceChip(
|
|
|
|
|
label: const Text('Local'),
|
|
|
|
|
selected: methodChoice.value == AuthMethodChoice.local,
|
|
|
|
|
onSelected: (selected) {
|
|
|
|
|
if (selected) {
|
|
|
|
|
methodChoice.value = AuthMethodChoice.local;
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
if (openidAvailable)
|
|
|
|
|
ChoiceChip(
|
|
|
|
|
label: const Text('OpenID'),
|
|
|
|
|
selected: methodChoice.value == AuthMethodChoice.openid,
|
|
|
|
|
onSelected: (selected) {
|
|
|
|
|
if (selected) {
|
|
|
|
|
methodChoice.value = AuthMethodChoice.openid;
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
ChoiceChip(
|
|
|
|
|
label: const Text('Token'),
|
|
|
|
|
selected:
|
|
|
|
|
methodChoice.value == AuthMethodChoice.authToken,
|
|
|
|
|
onSelected: (selected) {
|
|
|
|
|
if (selected) {
|
|
|
|
|
methodChoice.value = AuthMethodChoice.authToken;
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
const SizedBox.square(
|
|
|
|
|
dimension: 8,
|
|
|
|
|
),
|
|
|
|
|
switch (methodChoice.value) {
|
|
|
|
|
AuthMethodChoice.local => UserLoginWithPassword(
|
|
|
|
|
server: server,
|
|
|
|
|
addServer: addServer,
|
|
|
|
|
),
|
|
|
|
|
AuthMethodChoice.openid => _UserLoginWithOpenID(
|
|
|
|
|
server: server,
|
|
|
|
|
addServer: addServer,
|
|
|
|
|
),
|
|
|
|
|
AuthMethodChoice.authToken => UserLoginWithToken(
|
|
|
|
|
server: server,
|
|
|
|
|
addServer: addServer,
|
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class _UserLoginWithOpenID extends HookConsumerWidget {
|
|
|
|
|
const _UserLoginWithOpenID({
|
|
|
|
|
super.key,
|
|
|
|
|
required this.server,
|
|
|
|
|
required this.addServer,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
final Uri server;
|
|
|
|
|
final model.AudiobookShelfServer Function() addServer;
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
|
|
|
// TODO: implement build
|
|
|
|
|
return const Text('OpenID');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class UserLoginWithToken extends HookConsumerWidget {
|
|
|
|
|
UserLoginWithToken({
|
|
|
|
|
super.key,
|
|
|
|
|
required this.server,
|
|
|
|
|
required this.addServer,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
final Uri server;
|
|
|
|
|
final model.AudiobookShelfServer Function() addServer;
|
|
|
|
|
final serverErrorResponse = ErrorResponse();
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
|
|
|
final authTokensController = useTextEditingController();
|
|
|
|
|
|
|
|
|
|
final api = ref.watch(audiobookshelfApiProvider(server));
|
|
|
|
|
Future<void> loginAndSave() async {
|
|
|
|
|
api.token = authTokensController.text;
|
|
|
|
|
model.AuthenticatedUser? authenticatedUser;
|
|
|
|
|
LoginResponse? success;
|
|
|
|
|
try {
|
|
|
|
|
success = await api.misc.authorize(
|
|
|
|
|
responseErrorHandler: serverErrorResponse.storeError,
|
|
|
|
|
);
|
|
|
|
|
if (success == null) {
|
|
|
|
|
throw StateError('No response from server');
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
|
|
|
SnackBar(
|
|
|
|
|
content: Text(
|
|
|
|
|
'Login failed. Got response: ${serverErrorResponse.response.body} (${serverErrorResponse.response.statusCode})',
|
|
|
|
|
),
|
|
|
|
|
action: SnackBarAction(
|
|
|
|
|
label: 'See Error',
|
|
|
|
|
onPressed: () {
|
|
|
|
|
showDialog(
|
|
|
|
|
context: context,
|
|
|
|
|
builder: (context) => AlertDialog(
|
|
|
|
|
title: const Text('Error'),
|
|
|
|
|
content: Text(e.toString()),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
authenticatedUser = model.AuthenticatedUser(
|
|
|
|
|
server: addServer(),
|
|
|
|
|
id: success.user.id,
|
|
|
|
|
username: success.user.username,
|
|
|
|
|
authToken: api.token!,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
ref
|
|
|
|
|
.read(authenticatedUserProvider.notifier)
|
|
|
|
|
.addUser(authenticatedUser, setActive: true);
|
|
|
|
|
|
|
|
|
|
context.goNamed(Routes.home.name);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Form(
|
|
|
|
|
child: Column(
|
|
|
|
|
children: [
|
|
|
|
|
TextFormField(
|
|
|
|
|
controller: authTokensController,
|
|
|
|
|
autofocus: true,
|
|
|
|
|
textInputAction: TextInputAction.done,
|
|
|
|
|
maxLines: 10,
|
|
|
|
|
minLines: 1,
|
|
|
|
|
decoration: InputDecoration(
|
|
|
|
|
labelText: 'API Token',
|
|
|
|
|
labelStyle: TextStyle(
|
|
|
|
|
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.8),
|
|
|
|
|
),
|
|
|
|
|
border: const OutlineInputBorder(),
|
|
|
|
|
),
|
|
|
|
|
validator: (value) {
|
|
|
|
|
if (value == null || value.isEmpty) {
|
|
|
|
|
return 'Please enter an API token';
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
},
|
|
|
|
|
onFieldSubmitted: (_) async {
|
|
|
|
|
await loginAndSave();
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 10),
|
|
|
|
|
ElevatedButton(
|
|
|
|
|
onPressed: loginAndSave,
|
|
|
|
|
child: const Text('Login'),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// class _UserLoginWithToken extends HookConsumerWidget {
|
|
|
|
|
|
|
|
|
|
class UserLoginWithPassword extends HookConsumerWidget {
|
|
|
|
|
UserLoginWithPassword({
|
|
|
|
|
super.key,
|
|
|
|
|
required this.server,
|
|
|
|
|
required this.addServer,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
final Uri server;
|
|
|
|
|
final model.AudiobookShelfServer Function() addServer;
|
|
|
|
|
final serverErrorResponse = ErrorResponse();
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
|
|
|
final usernameController = useTextEditingController();
|
|
|
|
|
final passwordController = useTextEditingController();
|
2024-05-08 05:03:49 -04:00
|
|
|
final isPasswordVisibleAnimationController = useAnimationController(
|
|
|
|
|
duration: const Duration(milliseconds: 500),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
var isPasswordVisible = useState(false);
|
2024-09-06 15:10:00 -04:00
|
|
|
final api = ref.watch(audiobookshelfApiProvider(server));
|
2024-05-08 05:03:49 -04:00
|
|
|
|
|
|
|
|
// forward animation when the password visibility changes
|
|
|
|
|
useEffect(
|
|
|
|
|
() {
|
|
|
|
|
if (isPasswordVisible.value) {
|
|
|
|
|
isPasswordVisibleAnimationController.forward();
|
|
|
|
|
} else {
|
|
|
|
|
isPasswordVisibleAnimationController.reverse();
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
},
|
|
|
|
|
[isPasswordVisible.value],
|
|
|
|
|
);
|
|
|
|
|
|
2024-09-06 15:10:00 -04:00
|
|
|
/// Login to the server and save the user
|
|
|
|
|
Future<void> loginAndSave() async {
|
|
|
|
|
final username = usernameController.text;
|
|
|
|
|
final password = passwordController.text;
|
|
|
|
|
|
|
|
|
|
LoginResponse? success;
|
|
|
|
|
try {
|
|
|
|
|
success = await api.login(
|
|
|
|
|
username: username,
|
|
|
|
|
password: password,
|
|
|
|
|
responseErrorHandler: serverErrorResponse.storeError,
|
|
|
|
|
);
|
|
|
|
|
if (success == null) {
|
|
|
|
|
throw StateError('No response from server');
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
|
|
|
SnackBar(
|
|
|
|
|
content: Text(
|
|
|
|
|
'Login failed. Got response: ${serverErrorResponse.response.body} (${serverErrorResponse.response.statusCode})',
|
|
|
|
|
),
|
|
|
|
|
action: SnackBarAction(
|
|
|
|
|
label: 'See Error',
|
|
|
|
|
onPressed: () {
|
|
|
|
|
showDialog(
|
|
|
|
|
context: context,
|
|
|
|
|
builder: (context) => AlertDialog(
|
|
|
|
|
title: const Text('Error'),
|
|
|
|
|
content: Text(e.toString()),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
// save the server
|
|
|
|
|
final authenticatedUser = model.AuthenticatedUser(
|
|
|
|
|
server: addServer(),
|
|
|
|
|
id: success.user.id,
|
|
|
|
|
password: password,
|
|
|
|
|
username: username,
|
|
|
|
|
authToken: api.token!,
|
|
|
|
|
);
|
|
|
|
|
// add the user to the list of users
|
|
|
|
|
ref
|
|
|
|
|
.read(authenticatedUserProvider.notifier)
|
|
|
|
|
.addUser(authenticatedUser, setActive: true);
|
|
|
|
|
|
|
|
|
|
// redirect to the library page
|
|
|
|
|
GoRouter.of(context).goNamed(Routes.home.name);
|
|
|
|
|
}
|
|
|
|
|
|
2024-05-08 05:03:49 -04:00
|
|
|
return Center(
|
|
|
|
|
child: InactiveFocusScopeObserver(
|
|
|
|
|
child: AutofillGroup(
|
2024-05-10 04:11:39 -04:00
|
|
|
child: Padding(
|
|
|
|
|
padding: const EdgeInsets.all(8.0),
|
|
|
|
|
child: Column(
|
|
|
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
|
|
|
children: [
|
|
|
|
|
TextFormField(
|
|
|
|
|
controller: usernameController,
|
|
|
|
|
autofocus: true,
|
|
|
|
|
autofillHints: const [AutofillHints.username],
|
|
|
|
|
textInputAction: TextInputAction.next,
|
|
|
|
|
decoration: InputDecoration(
|
|
|
|
|
labelText: 'Username',
|
|
|
|
|
labelStyle: TextStyle(
|
|
|
|
|
color: Theme.of(context)
|
|
|
|
|
.colorScheme
|
2024-08-23 04:21:46 -04:00
|
|
|
.onSurface
|
2024-05-10 04:11:39 -04:00
|
|
|
.withOpacity(0.8),
|
|
|
|
|
),
|
|
|
|
|
border: const OutlineInputBorder(),
|
2024-05-08 05:03:49 -04:00
|
|
|
),
|
|
|
|
|
),
|
2024-05-10 04:11:39 -04:00
|
|
|
const SizedBox(height: 10),
|
|
|
|
|
TextFormField(
|
|
|
|
|
controller: passwordController,
|
|
|
|
|
autofillHints: const [AutofillHints.password],
|
|
|
|
|
textInputAction: TextInputAction.done,
|
|
|
|
|
obscureText: !isPasswordVisible.value,
|
2024-09-06 15:10:00 -04:00
|
|
|
onFieldSubmitted: (_) {
|
|
|
|
|
loginAndSave();
|
|
|
|
|
},
|
2024-05-10 04:11:39 -04:00
|
|
|
decoration: InputDecoration(
|
|
|
|
|
labelText: 'Password',
|
|
|
|
|
labelStyle: TextStyle(
|
|
|
|
|
color: Theme.of(context)
|
|
|
|
|
.colorScheme
|
2024-08-23 04:21:46 -04:00
|
|
|
.onSurface
|
2024-05-10 04:11:39 -04:00
|
|
|
.withOpacity(0.8),
|
2024-05-08 05:03:49 -04:00
|
|
|
),
|
2024-05-10 04:11:39 -04:00
|
|
|
border: const OutlineInputBorder(),
|
|
|
|
|
suffixIcon: ColorFiltered(
|
|
|
|
|
colorFilter: ColorFilter.mode(
|
|
|
|
|
Theme.of(context).colorScheme.primary.withOpacity(0.8),
|
|
|
|
|
BlendMode.srcIn,
|
|
|
|
|
),
|
|
|
|
|
child: InkWell(
|
|
|
|
|
borderRadius: BorderRadius.circular(50),
|
|
|
|
|
onTap: () {
|
|
|
|
|
isPasswordVisible.value = !isPasswordVisible.value;
|
|
|
|
|
},
|
|
|
|
|
child: Container(
|
|
|
|
|
margin: const EdgeInsets.only(left: 8, right: 8),
|
|
|
|
|
child: Lottie.asset(
|
|
|
|
|
'assets/animations/Animation - 1714930099660.json',
|
|
|
|
|
controller: isPasswordVisibleAnimationController,
|
|
|
|
|
),
|
2024-05-08 05:03:49 -04:00
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
2024-05-10 04:11:39 -04:00
|
|
|
suffixIconConstraints: const BoxConstraints(
|
|
|
|
|
maxHeight: 45,
|
|
|
|
|
),
|
2024-05-08 05:03:49 -04:00
|
|
|
),
|
|
|
|
|
),
|
2024-05-10 04:11:39 -04:00
|
|
|
const SizedBox(height: 30),
|
|
|
|
|
ElevatedButton(
|
2024-09-06 15:10:00 -04:00
|
|
|
onPressed: loginAndSave,
|
2024-05-10 04:11:39 -04:00
|
|
|
child: const Text('Login'),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
2024-05-08 05:03:49 -04:00
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|