diff --git a/packages/firebase_ui_auth/lib/src/screens/internal/login_screen.dart b/packages/firebase_ui_auth/lib/src/screens/internal/login_screen.dart index 587cdbc6..199e893b 100644 --- a/packages/firebase_ui_auth/lib/src/screens/internal/login_screen.dart +++ b/packages/firebase_ui_auth/lib/src/screens/internal/login_screen.dart @@ -51,6 +51,9 @@ class LoginScreen extends StatelessWidget { final double breakpoint; final Set? styles; + /// {@macro ui.auth.widgets.email_form.showPasswordVisibilityToggle} + final bool showPasswordVisibilityToggle; + const LoginScreen({ super.key, required this.action, @@ -69,6 +72,7 @@ class LoginScreen extends StatelessWidget { this.loginViewKey, this.breakpoint = 800, this.styles, + this.showPasswordVisibilityToggle = false, }); @override @@ -87,6 +91,7 @@ class LoginScreen extends StatelessWidget { showAuthActionSwitch: showAuthActionSwitch, subtitleBuilder: subtitleBuilder, footerBuilder: footerBuilder, + showPasswordVisibilityToggle: showPasswordVisibilityToggle, ), ), ); diff --git a/packages/firebase_ui_auth/lib/src/views/different_method_sign_in_view.dart b/packages/firebase_ui_auth/lib/src/views/different_method_sign_in_view.dart index 2324fcb6..3876d230 100644 --- a/packages/firebase_ui_auth/lib/src/views/different_method_sign_in_view.dart +++ b/packages/firebase_ui_auth/lib/src/views/different_method_sign_in_view.dart @@ -24,6 +24,9 @@ class DifferentMethodSignInView extends StatelessWidget { /// the [availableProviders]. final VoidCallback? onSignedIn; + /// {@macro ui.auth.widgets.email_from.showPasswordVisibilityToggle} + final bool showPasswordVisibilityToggle; + /// {@macro ui.auth.views.different_method_sign_in_view} const DifferentMethodSignInView({ super.key, @@ -31,6 +34,7 @@ class DifferentMethodSignInView extends StatelessWidget { required this.providers, this.auth, this.onSignedIn, + this.showPasswordVisibilityToggle = false, }); @override @@ -59,6 +63,7 @@ class DifferentMethodSignInView extends StatelessWidget { action: AuthAction.signIn, providers: providers, showTitle: false, + showPasswordVisibilityToggle: showPasswordVisibilityToggle, ), listener: (oldState, newState, ctrl) { if (newState is SignedIn) { diff --git a/packages/firebase_ui_auth/lib/src/views/login_view.dart b/packages/firebase_ui_auth/lib/src/views/login_view.dart index 350199c6..109c16ac 100644 --- a/packages/firebase_ui_auth/lib/src/views/login_view.dart +++ b/packages/firebase_ui_auth/lib/src/views/login_view.dart @@ -55,6 +55,9 @@ class LoginView extends StatefulWidget { /// A label that would be used for the "Sign in" button. final String? actionButtonLabelOverride; + /// {@macro ui.auth.widgets.email_from.showPasswordVisibilityToggle} + final bool showPasswordVisibilityToggle; + /// {@macro ui.auth.views.login_view} const LoginView({ super.key, @@ -68,6 +71,7 @@ class LoginView extends StatefulWidget { this.footerBuilder, this.subtitleBuilder, this.actionButtonLabelOverride, + this.showPasswordVisibilityToggle = false, }); @override @@ -222,6 +226,8 @@ class _LoginViewState extends State { provider: provider, email: widget.email, actionButtonLabelOverride: widget.actionButtonLabelOverride, + showPasswordVisibilityToggle: + widget.showPasswordVisibilityToggle, ) ] else if (provider is PhoneAuthProvider) ...[ const SizedBox(height: 8), diff --git a/packages/firebase_ui_auth/lib/src/views/reauthenticate_view.dart b/packages/firebase_ui_auth/lib/src/views/reauthenticate_view.dart index 5efd60a8..92a5eb67 100644 --- a/packages/firebase_ui_auth/lib/src/views/reauthenticate_view.dart +++ b/packages/firebase_ui_auth/lib/src/views/reauthenticate_view.dart @@ -22,6 +22,9 @@ class ReauthenticateView extends StatelessWidget { /// A label that would be used for the "Sign in" button. final String? actionButtonLabelOverride; + /// {@macro ui.auth.widgets.email_from.showPasswordVisibilityToggle} + final bool showPasswordVisibilityToggle; + /// {@macro ui.auth.views.reauthenticate_view} const ReauthenticateView({ super.key, @@ -29,6 +32,7 @@ class ReauthenticateView extends StatelessWidget { this.auth, this.onSignedIn, this.actionButtonLabelOverride, + this.showPasswordVisibilityToggle = false, }); @override @@ -63,6 +67,7 @@ class ReauthenticateView extends StatelessWidget { showTitle: false, showAuthActionSwitch: false, actionButtonLabelOverride: actionButtonLabelOverride, + showPasswordVisibilityToggle: showPasswordVisibilityToggle, ), listener: (oldState, newState, ctrl) { if (newState is SignedIn) { diff --git a/packages/firebase_ui_auth/lib/src/widgets/email_form.dart b/packages/firebase_ui_auth/lib/src/widgets/email_form.dart index 434517d0..d8cb3ad3 100644 --- a/packages/firebase_ui_auth/lib/src/widgets/email_form.dart +++ b/packages/firebase_ui_auth/lib/src/widgets/email_form.dart @@ -131,6 +131,11 @@ class EmailForm extends StatelessWidget { /// ``` final EmailFormStyle? style; + /// {@template ui.auth.widgets.email_from.showPasswordVisibilityToggle} + /// Whether to show the password visibility toggle button. + /// {@endtemplate} + final bool showPasswordVisibilityToggle; + /// {@macro ui.auth.widgets.email_form} const EmailForm({ super.key, @@ -141,6 +146,7 @@ class EmailForm extends StatelessWidget { this.email, this.actionButtonLabelOverride, this.style, + this.showPasswordVisibilityToggle = false, }); @override @@ -153,6 +159,7 @@ class EmailForm extends StatelessWidget { onSubmit: onSubmit, actionButtonLabelOverride: actionButtonLabelOverride, style: style, + showPasswordVisibilityToggle: showPasswordVisibilityToggle, ); return AuthFlowBuilder( @@ -175,6 +182,7 @@ class _SignInFormContent extends StatefulWidget { final EmailAuthProvider? provider; final String? actionButtonLabelOverride; + final bool showPasswordVisibilityToggle; final EmailFormStyle? style; @@ -186,6 +194,7 @@ class _SignInFormContent extends StatefulWidget { this.provider, this.actionButtonLabelOverride, this.style, + this.showPasswordVisibilityToggle = false, }); @override @@ -258,6 +267,7 @@ class _SignInFormContentState extends State<_SignInFormContent> { controller: passwordCtrl, onSubmit: _submit, placeholder: l.passwordInputLabel, + showVisibilityToggle: widget.showPasswordVisibilityToggle, ), if (widget.action == AuthAction.signIn) ...[ const SizedBox(height: 8), @@ -297,6 +307,7 @@ class _SignInFormContentState extends State<_SignInFormContent> { ) ]), placeholder: l.confirmPasswordInputLabel, + showVisibilityToggle: widget.showPasswordVisibilityToggle, ), const SizedBox(height: 8), ], diff --git a/packages/firebase_ui_auth/lib/src/widgets/email_sign_up_dialog.dart b/packages/firebase_ui_auth/lib/src/widgets/email_sign_up_dialog.dart index 6f65cd76..92d01c0b 100644 --- a/packages/firebase_ui_auth/lib/src/widgets/email_sign_up_dialog.dart +++ b/packages/firebase_ui_auth/lib/src/widgets/email_sign_up_dialog.dart @@ -23,50 +23,74 @@ class EmailSignUpDialog extends StatelessWidget { /// An instance of [EmailAuthProvider] that should be used to authenticate. final EmailAuthProvider provider; + /// {@macro ui.auth.widgets.email_from.showPasswordVisibilityToggle} + final bool showPasswordVisibilityToggle; + /// {@macro ui.auth.widget.email_sign_up_dialog} const EmailSignUpDialog({ super.key, this.auth, required this.provider, required this.action, + this.showPasswordVisibilityToggle = false, }); + AuthStateListenerCallback onAuthStateChanged( + BuildContext context, + ) { + return (AuthState oldState, AuthState newState, _) { + if (newState is CredentialLinked) { + Navigator.of(context).pop(); + } + + return null; + }; + } + @override Widget build(BuildContext context) { final l = FirebaseUILocalizations.labelsOf(context); + return _DialogWrapper( + child: Dialog( + child: AuthStateListener( + listener: onAuthStateChanged(context), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 16), + Title(text: l.provideEmail), + const SizedBox(height: 32), + EmailForm( + auth: auth, + action: action, + provider: provider, + showPasswordVisibilityToggle: showPasswordVisibilityToggle, + ), + ], + ), + ), + ), + ), + ); + } +} + +class _DialogWrapper extends StatelessWidget { + final Widget child; + + const _DialogWrapper({required this.child}); + + @override + Widget build(BuildContext context) { return Center( child: SingleChildScrollView( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 500), - child: Dialog( - child: AuthStateListener( - listener: (oldState, newState, ctrl) { - if (newState is CredentialLinked) { - Navigator.of(context).pop(); - } - - return null; - }, - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(height: 16), - Title(text: l.provideEmail), - const SizedBox(height: 32), - EmailForm( - auth: auth, - action: action, - provider: provider, - ), - ], - ), - ), - ), - ), + child: child, ), ), ); diff --git a/packages/firebase_ui_auth/lib/src/widgets/internal/universal_icon_button.dart b/packages/firebase_ui_auth/lib/src/widgets/internal/universal_icon_button.dart index 1606c7c2..2869a18a 100644 --- a/packages/firebase_ui_auth/lib/src/widgets/internal/universal_icon_button.dart +++ b/packages/firebase_ui_auth/lib/src/widgets/internal/universal_icon_button.dart @@ -26,6 +26,7 @@ class UniversalIconButton extends PlatformWidget { Widget buildCupertino(BuildContext context) { return CupertinoButton( onPressed: onPressed, + padding: EdgeInsets.zero, child: Icon(cupertinoIcon, size: size), ); } diff --git a/packages/firebase_ui_auth/lib/src/widgets/internal/universal_text_form_field.dart b/packages/firebase_ui_auth/lib/src/widgets/internal/universal_text_form_field.dart index dbb0b9a4..f793c065 100644 --- a/packages/firebase_ui_auth/lib/src/widgets/internal/universal_text_form_field.dart +++ b/packages/firebase_ui_auth/lib/src/widgets/internal/universal_text_form_field.dart @@ -24,6 +24,7 @@ class UniversalTextFormField extends PlatformWidget { final bool autocorrect; final Widget? prefix; final Iterable? autofillHints; + final Widget? suffixIcon; const UniversalTextFormField({ super.key, @@ -40,34 +41,49 @@ class UniversalTextFormField extends PlatformWidget { this.enableSuggestions, this.autocorrect = false, this.autofillHints, + this.suffixIcon, }); @override Widget buildCupertino(BuildContext context) { - return Container( - padding: const EdgeInsets.only(bottom: 8), - decoration: const BoxDecoration( - border: Border( - bottom: BorderSide( - color: CupertinoColors.inactiveGray, + return Stack( + children: [ + Container( + padding: const EdgeInsets.only(bottom: 8), + decoration: const BoxDecoration( + border: Border( + bottom: BorderSide( + color: CupertinoColors.inactiveGray, + ), + ), + ), + child: CupertinoTextFormFieldRow( + autocorrect: autocorrect, + autofillHints: autofillHints, + focusNode: focusNode, + padding: EdgeInsets.zero, + controller: controller, + placeholder: placeholder, + validator: validator, + onFieldSubmitted: onSubmitted, + autofocus: autofocus, + inputFormatters: inputFormatters, + keyboardType: keyboardType, + obscureText: obscureText, + prefix: prefix, ), ), - ), - child: CupertinoTextFormFieldRow( - autocorrect: autocorrect, - autofillHints: autofillHints, - focusNode: focusNode, - padding: EdgeInsets.zero, - controller: controller, - placeholder: placeholder, - validator: validator, - onFieldSubmitted: onSubmitted, - autofocus: autofocus, - inputFormatters: inputFormatters, - keyboardType: keyboardType, - obscureText: obscureText, - prefix: prefix, - ), + if (suffixIcon != null) + Positioned.fill( + child: Align( + alignment: Alignment.topRight, + child: Padding( + padding: const EdgeInsets.only(bottom: 8), + child: suffixIcon, + ), + ), + ), + ], ); } @@ -82,6 +98,7 @@ class UniversalTextFormField extends PlatformWidget { decoration: InputDecoration( labelText: placeholder, prefix: prefix, + suffixIcon: suffixIcon, ), validator: validator, onFieldSubmitted: onSubmitted, diff --git a/packages/firebase_ui_auth/lib/src/widgets/password_input.dart b/packages/firebase_ui_auth/lib/src/widgets/password_input.dart index 1f1b9332..03012acc 100644 --- a/packages/firebase_ui_auth/lib/src/widgets/password_input.dart +++ b/packages/firebase_ui_auth/lib/src/widgets/password_input.dart @@ -2,10 +2,13 @@ // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. -import 'package:flutter/widgets.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; import 'package:firebase_ui_localizations/firebase_ui_localizations.dart'; import '../validators.dart'; + +import 'internal/universal_icon_button.dart'; import 'internal/universal_text_form_field.dart'; /// {@template ui.auth.widgets.password_input} @@ -13,7 +16,7 @@ import 'internal/universal_text_form_field.dart'; /// /// {@macro ui.auth.widgets.internal.universal_text_form_field} /// {@endtemplate} -class PasswordInput extends StatelessWidget { +class PasswordInput extends StatefulWidget { /// Allows to control the focus state of the input. final FocusNode focusNode; @@ -35,6 +38,10 @@ class PasswordInput extends StatelessWidget { /// {@macro flutter.services.AutofillConfiguration.autofillHints} final Iterable autofillHints; + /// Whether to show the visibility toggle button. + /// Defaults to `false`. + final bool showVisibilityToggle; + /// {@macro ui.auth.widgets.password_input} const PasswordInput({ super.key, @@ -44,21 +51,48 @@ class PasswordInput extends StatelessWidget { required this.placeholder, this.autofillHints = const [AutofillHints.password], this.validator, + this.showVisibilityToggle = false, }); + @override + State createState() => _PasswordInputState(); +} + +class _PasswordInputState extends State { + var obscureText = true; + + Widget buildSuffixIcon() { + final mIcon = obscureText ? Icons.visibility : Icons.visibility_off; + final cIcon = obscureText ? CupertinoIcons.eye : CupertinoIcons.eye_slash; + + return UniversalIconButton( + materialIcon: mIcon, + cupertinoIcon: cIcon, + onPressed: () => setState(() => obscureText = !obscureText), + ); + } + @override Widget build(BuildContext context) { final l = FirebaseUILocalizations.labelsOf(context); + Widget? suffixIcon; + + if (widget.showVisibilityToggle) { + suffixIcon = buildSuffixIcon(); + } + return UniversalTextFormField( - autofillHints: autofillHints, - focusNode: focusNode, - controller: controller, - obscureText: true, + autofillHints: widget.autofillHints, + focusNode: widget.focusNode, + controller: widget.controller, + obscureText: obscureText, enableSuggestions: false, - validator: validator ?? NotEmpty(l.passwordIsRequiredErrorText).validate, - onSubmitted: (v) => onSubmit(v!), - placeholder: placeholder, + validator: + widget.validator ?? NotEmpty(l.passwordIsRequiredErrorText).validate, + onSubmitted: (v) => widget.onSubmit(v!), + placeholder: widget.placeholder, + suffixIcon: suffixIcon, ); } } diff --git a/packages/firebase_ui_auth/test/widgets/email_form_test.dart b/packages/firebase_ui_auth/test/widgets/email_form_test.dart index 5d3f0589..94ced93a 100644 --- a/packages/firebase_ui_auth/test/widgets/email_form_test.dart +++ b/packages/firebase_ui_auth/test/widgets/email_form_test.dart @@ -8,6 +8,8 @@ import 'package:firebase_ui_auth/firebase_ui_auth.dart'; import 'package:firebase_auth/firebase_auth.dart'; import 'package:mockito/mockito.dart'; +import 'package:firebase_ui_auth/src/widgets/internal/universal_text_form_field.dart'; + import '../test_utils.dart'; class MockFirebaseAuth extends Mock implements FirebaseAuth {} @@ -103,5 +105,53 @@ void main() { expect(button, findsOneWidget); }, ); + + testWidgets('has password visibility toggle', (tester) async { + await tester.pumpWidget( + TestMaterialApp( + child: EmailForm( + auth: MockAuth(), + action: AuthAction.signIn, + showPasswordVisibilityToggle: true, + ), + ), + ); + + final toggleFinder = find.byIcon(Icons.visibility); + expect(toggleFinder, findsOneWidget); + }); + + testWidgets('allows to toggle password visibility', (tester) async { + await tester.pumpWidget( + TestMaterialApp( + child: EmailForm( + auth: MockAuth(), + action: AuthAction.signIn, + showPasswordVisibilityToggle: true, + ), + ), + ); + + final passwordHost = find.byType(PasswordInput); + final toggleFinder = find.byIcon(Icons.visibility); + + final textField = find.descendant( + of: passwordHost, + matching: find.byType(UniversalTextFormField), + ); + + expect( + tester.widget(textField).obscureText, + isTrue, + ); + + await tester.tap(toggleFinder); + await tester.pump(); + + expect( + tester.widget(textField).obscureText, + isFalse, + ); + }); }); }