diff --git a/lib/pages/login_page.dart b/lib/pages/login_page.dart index 4f57549..38941e2 100644 --- a/lib/pages/login_page.dart +++ b/lib/pages/login_page.dart @@ -2,43 +2,58 @@ import "package:flutter/material.dart"; import "package:flutter_hooks/flutter_hooks.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:nexus/controllers/client_controller.dart"; +import "package:nexus/models/requests/login_request.dart"; import "package:nexus/widgets/appbar.dart"; class LoginPage extends HookConsumerWidget { final Uri homeserver; + const LoginPage({super.key, required this.homeserver}); @override Widget build(BuildContext context, WidgetRef ref) { final client = ref.watch(ClientController.provider.notifier); + final isLoggingIn = useState(false); + final hasError = useState(false); + final passwordFocusNode = useFocusNode(); + + final theme = Theme.of(context); - final isLoading = useState(false); final username = useTextEditingController(); final password = useTextEditingController(); - final inputError = useState(null); - Future tryLogin() async { - isLoading.value = true; + if (isLoggingIn.value) return; + isLoggingIn.value = true; + hasError.value = false; - try { - final error = await client.login( - .new( - username: username.text, - password: password.text, - homeserverUrl: homeserver.origin, + final error = await client.login( + LoginRequest( + username: username.text, + password: password.text, + homeserverUrl: homeserver.origin, + ), + ); + + if (!context.mounted) return; + + if (error != null) { + hasError.value = true; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + "Login failed. Is your password right?\nError: $error", + style: TextStyle(color: theme.colorScheme.onErrorContainer), + ), + backgroundColor: theme.colorScheme.errorContainer, ), ); - - if (error != null) { - inputError.value = error; - isLoading.value = false; - } else { - if (context.mounted) Navigator.of(context).pop(); - } - } finally { - isLoading.value = false; + isLoggingIn.value = false; + } else { + Navigator.of(context).pop(); } + passwordFocusNode.requestFocus(); + isLoggingIn.value = false; } return Scaffold( @@ -51,24 +66,57 @@ class LoginPage extends HookConsumerWidget { body: AlertDialog( title: Text("Login to ${homeserver.host}"), content: Column( - mainAxisSize: .min, - crossAxisAlignment: .start, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text("Enter your login credentials:"), + SizedBox(height: 12), TextField( autofocus: true, - textInputAction: .next, - decoration: .new(label: Text("Username")), + textInputAction: TextInputAction.next, + onChanged: (newVal) { + if (hasError.value) { + hasError.value = false; + } + }, + decoration: InputDecoration( + label: Text("Username"), + focusedBorder: hasError.value + ? OutlineInputBorder( + borderSide: BorderSide(color: theme.colorScheme.error), + ) + : null, + enabledBorder: hasError.value + ? OutlineInputBorder( + borderSide: BorderSide(color: theme.colorScheme.error), + ) + : null, + ), controller: username, ), SizedBox(height: 12), TextField( - textInputAction: .done, + focusNode: passwordFocusNode, + textInputAction: TextInputAction.done, onSubmitted: (_) => tryLogin(), + onChanged: (newVal) { + if (hasError.value) { + hasError.value = false; + } + }, selectAllOnFocus: true, - decoration: .new( + decoration: InputDecoration( label: Text("Password"), - errorText: inputError.value, - errorMaxLines: 5, + focusedBorder: hasError.value + ? OutlineInputBorder( + borderSide: BorderSide(color: theme.colorScheme.error), + ) + : null, + enabledBorder: hasError.value + ? OutlineInputBorder( + borderSide: BorderSide(color: theme.colorScheme.error), + ) + : null, ), controller: password, obscureText: true, @@ -77,7 +125,7 @@ class LoginPage extends HookConsumerWidget { ), actions: [ TextButton( - onPressed: isLoading.value ? null : tryLogin, + onPressed: hasError.value ? null : tryLogin, child: Text("Sign In"), ), ], diff --git a/lib/pages/verify_page.dart b/lib/pages/verify_page.dart index 5f8c156..0469fa4 100644 --- a/lib/pages/verify_page.dart +++ b/lib/pages/verify_page.dart @@ -3,6 +3,7 @@ import "package:flutter_hooks/flutter_hooks.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:nexus/controllers/client_controller.dart"; import "package:nexus/widgets/appbar.dart"; +import "package:nexus/widgets/form_text_input.dart"; class VerifyPage extends HookConsumerWidget { const VerifyPage({super.key}); @@ -10,8 +11,7 @@ class VerifyPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final passphraseController = useTextEditingController(); - final isLoading = useState(false); - + final isVerifying = useState(false); return Scaffold( appBar: Appbar(), body: AlertDialog( @@ -24,17 +24,19 @@ class VerifyPage extends HookConsumerWidget { "Enter your recovery key or passphrase below to unlock encrypted events.\nYour passphrase is usually not the same as your password.", ), SizedBox(height: 12), - TextField( + FormTextInput( + required: false, autofocus: true, + capitalize: true, controller: passphraseController, - obscureText: true, - decoration: .new(label: Text("Recovery Key or Passphrase")), + obscure: true, + title: "Recovery Key or Passphrase", ), ], ), actions: [ TextButton( - onPressed: isLoading.value + onPressed: isVerifying.value ? null : () async { final scaffoldMessenger = ScaffoldMessenger.of(context); @@ -47,7 +49,7 @@ class VerifyPage extends HookConsumerWidget { ), ); - isLoading.value = true; + isVerifying.value = true; final error = await ref .watch(ClientController.provider.notifier) @@ -55,7 +57,7 @@ class VerifyPage extends HookConsumerWidget { snackbar.close(); if (error != null) { - isLoading.value = false; + isVerifying.value = false; if (context.mounted) { scaffoldMessenger.showSnackBar( .new( diff --git a/lib/widgets/form_text_input.dart b/lib/widgets/form_text_input.dart new file mode 100644 index 0000000..8b48883 --- /dev/null +++ b/lib/widgets/form_text_input.dart @@ -0,0 +1,81 @@ +import "package:flutter/material.dart"; +import "package:flutter/services.dart"; + +class FormTextInput extends StatelessWidget { + final List extraValidators; + final TextEditingController? controller; + final TextInputType keyboardType; + final String? initialValue; + final bool readOnly; + final bool obscure; + final String? title; + final int? minLines; + final int? maxLength; + final bool outlined; + final int? maxLines; + final bool capitalize; + final bool required; + final bool autocorrect; + final void Function()? onTap; + final Widget? trailing; + final InputBorder? border; + final List? formatters; + final bool autofocus; + + const FormTextInput({ + super.key, + this.border, + this.controller, + this.autofocus = false, + this.title, + this.obscure = false, + this.readOnly = false, + this.extraValidators = const [], + this.keyboardType = TextInputType.text, + this.initialValue, + this.minLines, + this.capitalize = false, + this.maxLength, + this.formatters, + this.maxLines = 1, + this.outlined = true, + this.trailing, + this.onTap, + this.autocorrect = true, + this.required = true, + }); + + @override + Widget build(BuildContext context) => TextFormField( + autofocus: autofocus, + controller: controller, + keyboardType: keyboardType, + readOnly: readOnly, + minLines: minLines, + maxLines: maxLines, + maxLength: maxLength, + inputFormatters: formatters, + textCapitalization: capitalize ? .sentences : .none, + initialValue: initialValue, + autocorrect: autocorrect, + obscureText: obscure, + onTap: onTap, + decoration: .new( + labelText: title, + border: border ?? (outlined ? null : const UnderlineInputBorder()), + suffixIcon: trailing, + ), + validator: (value) { + if ((value?.isEmpty ?? true) && required) { + return "This field is required"; + } + + for (final validator in extraValidators) { + final reason = validator(value!); + if (reason != null) return reason; + } + + return null; + }, + ); +}