Vaani/lib/features/onboarding/view/user_login.dart

489 lines
15 KiB
Dart
Raw Normal View History

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
),
),
),
);
}
}