diff --git a/lib/src/components/_supa_password_field.dart b/lib/src/components/_supa_password_field.dart new file mode 100644 index 0000000..ce5c044 --- /dev/null +++ b/lib/src/components/_supa_password_field.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; + +/// Internal password field component with visibility toggle +class SupaPasswordField extends StatefulWidget { + + final FocusNode? focusNode; + + /// Controller for the password field + final TextEditingController controller; + + /// Validator function for the password field + final String? Function(String?)? validator; + + /// Label text for the password field + final String labelText; + + /// Prefix icon for the password field + final Widget? prefixIcon; + + /// Autofill hints for the password field + final Iterable? autofillHints; + + /// Text input action for the password field + final TextInputAction? textInputAction; + + /// Callback when field is submitted + final void Function(String)? onFieldSubmitted; + + /// Whether the field should auto-validate + final AutovalidateMode? autovalidateMode; + + const SupaPasswordField({ + super.key, + required this.controller, + this.validator, + required this.labelText, + this.prefixIcon, + this.autofillHints, + this.textInputAction, + this.onFieldSubmitted, + this.autovalidateMode, + this.focusNode, + }); + + @override + State createState() => _SupaPasswordFieldState(); +} + +class _SupaPasswordFieldState extends State { + bool _obscureText = true; + + @override + Widget build(BuildContext context) { + return TextFormField( + focusNode: widget.focusNode, + controller: widget.controller, + validator: widget.validator, + obscureText: _obscureText, + autofillHints: widget.autofillHints, + textInputAction: widget.textInputAction, + onFieldSubmitted: widget.onFieldSubmitted, + autovalidateMode: widget.autovalidateMode, + decoration: InputDecoration( + prefixIcon: widget.prefixIcon, + label: Text(widget.labelText), + suffixIcon: Tooltip( + message: _obscureText ? 'Show password' : 'Hide password', + child: IconButton( + icon: Icon( + _obscureText ? Icons.visibility_off : Icons.visibility, + ), + onPressed: () { + setState(() { + _obscureText = !_obscureText; + }); + }, + ), + ), + ), + ); + } +} diff --git a/lib/src/components/supa_email_auth.dart b/lib/src/components/supa_email_auth.dart index 0fd2396..9422750 100644 --- a/lib/src/components/supa_email_auth.dart +++ b/lib/src/components/supa_email_auth.dart @@ -1,5 +1,6 @@ import 'package:email_validator/email_validator.dart'; import 'package:flutter/material.dart'; +import 'package:supabase_auth_ui/src/components/_supa_password_field.dart'; import 'package:supabase_auth_ui/src/localizations/supa_email_auth_localization.dart'; import 'package:supabase_auth_ui/src/utils/constants.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; @@ -179,7 +180,7 @@ class SupaEmailAuth extends StatefulWidget { final String? Function(String?)? passwordValidator; /// Callback for the user to complete a sign in. - final void Function(AuthResponse response) onSignInComplete; + final void Function(AuthResponse response, String email) onSignInComplete; /// Callback for the user to complete a signUp. /// @@ -187,7 +188,7 @@ class SupaEmailAuth extends StatefulWidget { final void Function(AuthResponse response) onSignUpComplete; /// Callback for sending the password reset email - final void Function()? onPasswordResetEmailSent; + final void Function(String email)? onPasswordResetEmailSent; /// Callback for when the auth action threw an exception /// @@ -217,12 +218,15 @@ class SupaEmailAuth extends StatefulWidget { final Widget? prefixIconEmail; final Widget? prefixIconPassword; + final String? initialEmail; + /// Whether the confirm password field should be displayed final bool showConfirmPasswordField; /// {@macro supa_email_auth} const SupaEmailAuth({ super.key, + this.initialEmail = "", this.autofocus = true, this.redirectTo, this.resetPasswordRedirectTo, @@ -248,7 +252,7 @@ class SupaEmailAuth extends StatefulWidget { class _SupaEmailAuthState extends State { final _formKey = GlobalKey(); - final _emailController = TextEditingController(); + late final TextEditingController _emailController; final _passwordController = TextEditingController(); final _confirmPasswordController = TextEditingController(); late bool _isSigningIn; @@ -261,10 +265,12 @@ class _SupaEmailAuthState extends State { /// Focus node for email field final FocusNode _emailFocusNode = FocusNode(); + final FocusNode _passwordFocusNode = FocusNode(); @override void initState() { super.initState(); + _emailController = TextEditingController(text: widget.initialEmail); _isSigningIn = widget.isInitiallySigningIn; _metadataControllers = Map.fromEntries((widget.metadataFields ?? []).map( (metadataField) => MapEntry( @@ -274,6 +280,24 @@ class _SupaEmailAuthState extends State { : TextEditingController(), ), )); + + // Request focus on password field if email is pre-filled and not recovering password + if (widget.initialEmail != null && widget.initialEmail!.isNotEmpty && !_isRecoveringPassword) { + // It's important to request focus after the first frame has been built. + // WidgetsBinding.instance.addPostFrameCallback ensures that. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + FocusScope.of(context).requestFocus(_passwordFocusNode); + } + }); + } else if (widget.autofocus && !_isRecoveringPassword) { + // Default autofocus behavior if no initial email or recovering password + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + FocusScope.of(context).requestFocus(_emailFocusNode); + } + }); + } } @override @@ -281,6 +305,8 @@ class _SupaEmailAuthState extends State { _emailController.dispose(); _passwordController.dispose(); _confirmPasswordController.dispose(); + _emailFocusNode.dispose(); + _passwordFocusNode.dispose(); for (final controller in _metadataControllers.values) { if (controller is TextEditingController) { controller.dispose(); @@ -328,11 +354,15 @@ class _SupaEmailAuthState extends State { ), if (!_isRecoveringPassword) ...[ spacer(16), - TextFormField( + SupaPasswordField( + controller: _passwordController, + labelText: localization.enterPassword, + prefixIcon: widget.prefixIconPassword, autofillHints: _isSigningIn ? [AutofillHints.password] : [AutofillHints.newPassword], autovalidateMode: AutovalidateMode.onUserInteraction, + focusNode: _passwordFocusNode, textInputAction: widget.metadataFields != null && !_isSigningIn ? TextInputAction.next : TextInputAction.done, @@ -343,12 +373,6 @@ class _SupaEmailAuthState extends State { } return null; }, - decoration: InputDecoration( - prefixIcon: widget.prefixIconPassword, - label: Text(localization.enterPassword), - ), - obscureText: true, - controller: _passwordController, onFieldSubmitted: (_) { if (widget.metadataFields == null || _isSigningIn) { _signInSignUp(); @@ -357,13 +381,10 @@ class _SupaEmailAuthState extends State { ), if (widget.showConfirmPasswordField && !_isSigningIn) ...[ spacer(16), - TextFormField( + SupaPasswordField( controller: _confirmPasswordController, - decoration: InputDecoration( - prefixIcon: widget.prefixIconPassword, - label: Text(localization.confirmPassword), - ), - obscureText: true, + labelText: localization.confirmPassword, + prefixIcon: widget.prefixIconPassword, validator: (value) { if (value != _passwordController.text) { return localization.confirmPasswordError; @@ -468,7 +489,7 @@ class _SupaEmailAuthState extends State { height: 16, width: 16, child: CircularProgressIndicator( - color: Theme.of(context).colorScheme.onPrimary, + color: Theme.of(context).colorScheme.primary, strokeWidth: 1.5, ), ) @@ -533,13 +554,16 @@ class _SupaEmailAuthState extends State { setState(() { _isLoading = true; }); + + await Future.delayed(Duration(milliseconds: 50)); + try { if (_isSigningIn) { final response = await supabase.auth.signInWithPassword( email: _emailController.text.trim(), password: _passwordController.text.trim(), ); - widget.onSignInComplete.call(response); + widget.onSignInComplete.call(response, _emailController.text.trim()); } else { final user = supabase.auth.currentUser; late final AuthResponse response; @@ -579,11 +603,12 @@ class _SupaEmailAuthState extends State { widget.onError?.call(error); } _emailFocusNode.requestFocus(); - } - if (mounted) { - setState(() { - _isLoading = false; - }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } } } @@ -603,13 +628,11 @@ class _SupaEmailAuthState extends State { email, redirectTo: widget.resetPasswordRedirectTo ?? widget.redirectTo, ); - widget.onPasswordResetEmailSent?.call(); + widget.onPasswordResetEmailSent?.call(email); // FIX use_build_context_synchronously if (!mounted) return; context.showSnackBar(widget.localization.passwordResetSent); - setState(() { - _isRecoveringPassword = false; - }); + } on AuthException catch (error) { widget.onError?.call(error); } catch (error) { diff --git a/pubspec.yaml b/pubspec.yaml index 880ca10..c762d3b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -11,8 +11,8 @@ environment: dependencies: flutter: sdk: flutter - supabase_flutter: ^2.5.6 - email_validator: ^2.0.1 + supabase_flutter: ^2.10.3 + email_validator: ^3.0.0 font_awesome_flutter: ^10.6.0 google_sign_in: ^6.2.1 sign_in_with_apple: ^6.1.0