Create a dynamic Input widget, a form that can transform into both sign-in and registration based on the authentication mode, animate the transition between registration and sign-in form, and validate user input.
Hello and welcome to Khadka's Coding Lounge. This is Nibesh from Khadka's Coding Lounge. So, far in this series, we made Splash Screen, User Onboarding System, Global Theme, and Custom widgets like the app bar, bottom navigation bar, and a drawer for our application.
This 5th installment is one of the most important parts of this blog series as you can tell from the title because now, according to our user-flow screen given below, we have to authenticate the user.
Authentication is a basic yet very important aspect of any application regardless of platform. Serverless/Headless apps are on trend these days. Among them, Google's Firebase is one of the popular ones, especially for mobile applications.
In this chapter of the series, we'll create an authentication UI. We'll create a dynamic input form widget to be more efficient. We'll also write some validations for the input, and animate sign-in <-> registration form transitions.
You can find code up until from here.
We're going to make a simple form. For registration, we'll have four inputs: email, username, password, and confirm password inputs, while the sign-in form only uses two inputs: email and password.
Let's create relevant files and folders in our project.
# cursor on root folder
# create folders
mkdir lib/screens/auth lib/screens/auth/widgets lib/screens/auth/providers lib/screens/auth/utils
# Create files
touch lib/screens/auth/auth_screen.dart lib/screens/auth/providers/auth_provider.dart lib/screens/auth/widgets/text_from_widget.dart lib/screens/auth/widgets/auth_form_widget.dart lib/screens/auth/utils/auth_validators.dart lib/screens/auth/utils/auth_utils.dart
Before we create our dynamic text form field widget, let's go over the details of how dynamic it's going to be.
We'll make our dynamic text form widget in the text_from_widget file.
import 'package:flutter/material.dart';
class DynamicInputWidget extends StatelessWidget {
const DynamicInputWidget(
{required this.controller,
required this.obscureText,
required this.focusNode,
required this.toggleObscureText,
required this.validator,
required this.prefIcon,
required this.labelText,
required this.textInputAction,
required this.isNonPasswordField,
Key? key})
: super(key: key);
// bool to check if the text field is for password or not
final bool isNonPasswordField;
// Controller for the text field
final TextEditingController controller;
// Functio to toggle Text obscuractio on password text field
final VoidCallback? toggleObscureText;
// to obscure text or not bool
final bool obscureText;
// FocusNode for input
final FocusNode focusNode;
// Validator function
final String? Function(String?)? validator;
// Prefix icon for input form
final Icon prefIcon;
// label for input form
final String labelText;
// The keyword action to display
final TextInputAction textInputAction;
@override
Widget build(BuildContext context) {
return TextFormField(
controller: controller,
decoration: InputDecoration(
// Input with border outlined
border: const OutlineInputBorder(
// Make border edge circular
borderRadius: BorderRadius.all(Radius.circular(10.0)),
),
label: Text(labelText),
prefixIcon: prefIcon,
suffixIcon: IconButton(
onPressed: toggleObscureText,
// If is non-password filed like emal the suffix icon will be null
icon: isNonPasswordField
? const Icon(null)
: obscureText
? const Icon(Icons.visibility)
: const Icon(Icons.visibility_off),
),
),
focusNode: focusNode,
textInputAction: textInputAction,
obscureText: obscureText,
validator: validator,
// onSaved: passwordVlidator,
);
}
}
Let's go over a few important fields of our dynamic input widget class.
// bool to check if the text field is for password or not
final bool isNonPasswordField; //# 1
// Function to toggle Text obscuraction on password text field
final VoidCallback? toggleObscureText; //# 2
// to obscure text or not bool
final bool obscureText;
// Validator function
final String? Function(String?)? validator; //# 3
Now, that our dynamic input is ready. Let's write some validators before we move on to create our form. We've created a separate file auth_validators for this sole purpose. We'll write three functions, which will separately validate Email, Password, and Confirm Passwords for user input.
class AuthValidators {
// Create error messages to send.
// #1
static const String emailErrMsg = "Invalid Email Address, Please provide a valid email.";
static const String passwordErrMsg = "Password must have at least 6 characters.";
static const String confirmPasswordErrMsg = "Two passwords don't match.";
// A simple email validator that checks presence and position of @
// #2
String? emailValidator(String? val) {
final String email = val as String;
// If length of email is <=3 then its invlaid
// #3
if (email.length <= 3) return emailErrMsg;
// Check if it has @
// # 4
final hasAtSymbol = email.contains('@');
// find position of @
// # 5
final indexOfAt = email.indexOf('@');
// Check numbers of @
// # 6
final numbersOfAt = "@".allMatches(email).length;
// Valid if has @
// # 7
if (!hasAtSymbol) return emailErrMsg;
// and if number of @ is only 1
// # 8
if (numbersOfAt != 1) return emailErrMsg;
//and if '@' is not the first or last character
// # 9
if (indexOfAt == 0 || indexOfAt == email.length - 1) return emailErrMsg;
// Else its valid
return null;
}
}
Inside the auth_validators, we create an AuthValidators class.
That was a very simple validator, where we checked four things: if the input for email is empty, the length of the email input is less than 4, and the position and number @ in the input.
Reminder: Do not change the order of return statements. It will cause an error.
We'll continue with the second validator for the password. We'll only validate the password if it's not empty and its length is greater than 5. So, once again inside AuthValidator class, we'll create a new function passwordValidator.
// Password validator
String? passwordVlidator(String? val) {
final String password = val as String;
if (password.isEmpty || password.length <= 5) return passwordErrMsg;
return null;
}
During registration, there will be two password input fields, the second one is to confirm the given password. Our confirmPassword validator will take two inputs: the original password and the second password.
// Confirm password
String? confirmPasswordValidator(String? val, firstPasswordInpTxt) {
final String firstPassword = firstPasswordInpTxt;
final String secondPassword = val as String;
// If either of the password field is empty
// Or if thier length do not match then we don't need to compare their content
// #1
if (firstPassword.isEmpty ||
secondPassword.isEmpty ||
firstPassword.length != secondPassword.length) {
return confirmPasswordErrMsg;
}
// If two passwords do not match then send error message
// #2
if (firstPassword != secondPassword) return confirmPasswordErrMsg;
return null;
}
For password confirmation, we checked:
Like this our three validators are ready for action.
Altogether AuthValidators Class looks like this.
class AuthValidators {
// Create error messages to send.
static const String emailErrMsg =
"Invalid Email Address, Please provide a valid email.";
static const String passwordErrMsg =
"Password must have at least 6 characters.";
static const String confirmPasswordErrMsg = "Two passwords don't match.";
// A simple email validator that checks presence and position of @
String? emailValidator(String? val) {
final String email = val as String;
// If length of email is <=3 then its invlaid
if (email.length <= 3) return emailErrMsg;
// Check if it has @
final hasAtSymbol = email.contains('@');
// find position of @
final indexOfAt = email.indexOf('@');
// Check numbers of @
final numbersOfAt = "@".allMatches(email).length;
// Valid if has @
if (!hasAtSymbol) return emailErrMsg;
// and if number of @ is only 1
if (numbersOfAt != 1) return emailErrMsg;
//and if '@' is not first or last character
if (indexOfAt == 0 || indexOfAt == email.length - 1) return emailErrMsg;
// Else its valid
return null;
}
// Password validator
String? passwordVlidator(String? val) {
final String password = val as String;
if (password.isEmpty || password.length <= 5) return passwordErrMsg;
return null;
}
// Confirm password
String? confirmPasswordValidator(String? val, firstPasswordInpTxt) {
final String firstPassword = firstPasswordInpTxt;
final String secondPassword = val as String;
// If either of the password field is empty
// Or if thier length do not match then we don't need to compare their content
if (firstPassword.isEmpty ||
secondPassword.isEmpty ||
firstPassword.length != secondPassword.length) {
return confirmPasswordErrMsg;
}
// If two passwords do not match then send error message
if (firstPassword != secondPassword) return confirmPasswordErrMsg;
return null;
}
}
Here's something for you to practice.
Now, that we've made our dynamic input as well as validator it's time to put them together to create an authentication form visuals. We'll create our form widget in the auth_form_widget file which will be displayed in the auth_screen file.
auth_form_widget
import 'package:flutter/material.dart';
class AuthFormWidget extends StatefulWidget {
const AuthFormWidget({Key? key}) : super(key: key);
@override
State<AuthFormWidget> createState() => _AuthFormWidgetState();
}
class _AuthFormWidgetState extends State<AuthFormWidget> {
// Define Form key
final _formKey = GlobalKey<FormState>();
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(8),
child: Form(
key: _formKey,
child: TextFormField(),
),
);
}
}
In flutter, the Form class can act as a container to display TextFormFields(if they are more than one). It also requires a FormState which can be obtained via the global key of type FormState as we did just now. Before this form widget bulks up let's connect it to the auth_screen and display it on the app. We'll change it later on after connecting auth screen to the router.
auth_screen.dart
import 'package:flutter/material.dart';
import 'package:temple/globals/settings/router/utils/router_utils.dart';
import 'package:temple/screens/auth/widgets/auth_form_widget.dart';
class AuthScreen extends StatelessWidget {
const AuthScreen({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(APP_PAGE.auth.routePageTitle)),
body:
// Safe area prevents safe gards widgets to go beyond device edges
SafeArea(
//===========//
// to dismiss keyword on tap outside use listener
child: Listener(
onPointerDown: (PointerDownEvent event) =>
FocusManager.instance.primaryFocus?.unfocus(),
//===========//
child: SingleChildScrollView(
child: SizedBox(
width: double.infinity,
child: Column(children: [
// Display a welcome user image
Padding(
padding: const EdgeInsets.all(8.0),
child: Image.asset(
'assets/AuthScreen/WelcomeScreenImage_landscape_2.png',
fit: BoxFit.fill,
),
),
const AuthFormWidget()
]),
),
),
),
),
);
}
}
Here is auth_screen, this is it, we'll not make any changes in the future.
Since the app is only accessible to registered users, the authentication screen will be the first screen our users will be prompted to after onboarding. So, we don't need to display user_drawer or the bottom navbar.
Dismissal of the active keyboard, when tapped outside, is an important characteristic to have. Listener class responds to gesture events like a mouse click, tap, etc. Together with FocusManager we can track the focus node tree and unfocus the active keyboard. You might be wondering why am I using it on auth_screen as a whole instead of auth_form_widget. That's because gesture detector events should cover the whole visible area which in this case is SingleChildScrollView.
Next, is the image section, if you're using the source code from the GitHub repo image should already be in the AuthScreen folder inside assets. Let's add the path in the pubspec file.
assets:
# Splash Screens
- assets/splash/om_splash.png
- assets/splash/om_lotus_splash.png
- assets/splash/om_lotus_splash_1152x1152.png
# Onboard Screens
- assets/onboard/FindTemples.png
- assets/onboard/FindVenues.png
// New line
# Auth Screens
- assets/AuthScreen/WelcomeScreenImage_landscape_2.png
It's time to add auth_screen to the app_router file.
app_router
routes: [
GoRoute(
path: APP_PAGE.home.routePath,
name: APP_PAGE.home.routeName,
builder: (context, state) => const Home(),
),
// Add the onboard Screen
GoRoute(
path: APP_PAGE.onboard.routePath,
name: APP_PAGE.onboard.routeName,
builder: (context, state) => const OnBoardScreen()),
// New line from here
// Add Auth Screen on Go Router
GoRoute(
path: APP_PAGE.auth.routePath,
name: APP_PAGE.auth.routeName,
builder: (context, state) => const AuthScreen()),
],
We're yet to write a backend/business logic in this blog. But we do have to test UI. So, let's create a temporary link in our user_drawer file that'll take us to auth_screen.
user_drawer
...............
actions: [
ListTile(
leading: Icon(
Icons.person_outline_rounded,
color: Theme.of(context).colorScheme.secondary,
),
title: const Text('User Profile'),
onTap: () {
print("User Profile Button Pressed");
}),
// ============================//
// A temporarry link to auth screen
ListTile(
leading: Icon(
Icons.login,
color: Theme.of(context).colorScheme.secondary,
),
title: const Text('Register/Login'),
onTap: () => GoRouter.of(context).goNamed(APP_PAGE.auth.routeName)),
// ============================//
ListTile(
leading: Icon(
Icons.logout,
color: Theme.of(context).colorScheme.secondary,
),
title: const Text('Logout'),
onTap: () {
print("Log Out Button Pressed");
}),
],
.....
Save it all and run the app, navigate to auth_screen from the user drawer(press the person icon for the drawer), and you'll see auth screen. Now that we can see the authentication screen, let's create a full-fledged auth form.
The codes for the auth_form_widget file are long. So, let’s go over it a few pieces at a time first.
// Instantiate validator
final AuthValidators authValidator = AuthValidators();
// controllers
late TextEditingController emailController;
late TextEditingController usernameController;
late TextEditingController passwordController;
late TextEditingController confirmPasswordController;
// create focus nodes
late FocusNode emailFocusNode;
late FocusNode usernameFocusNode;
late FocusNode passwordFocusNode;
late FocusNode confirmPasswordFocusNode;
// to obscure text default value is false
bool obscureText = true;
Instead of creating two separate screens for registering and signing in, we'll just toggle between the few inputs displayed, on and off. For that, let's create a boolean to control authMode.
// This will require toggling between register and signin mode
bool registerAuthMode = false;
Instantiate all the text editing controllers and focus nodes on initState function. Similarly, these all also need to be disposed of once done so let's do that as well with the dispose method.
@override
void initState() {
super.initState();
emailController = TextEditingController();
usernameController = TextEditingController();
passwordController = TextEditingController();
confirmPasswordController = TextEditingController();
emailFocusNode = FocusNode();
usernameFocusNode = FocusNode();
passwordFocusNode = FocusNode();
confirmPasswordFocusNode = FocusNode();
}
@override
void dispose() {
super.dispose();
emailController.dispose();
usernameController.dispose();
passwordController.dispose();
confirmPasswordController.dispose();
emailFocusNode.dispose();
usernameFocusNode.dispose();
passwordFocusNode.dispose();
confirmPasswordFocusNode.dispose();
}
Create a function that'll toggle the password's visibility on the relevant icon tap.
void toggleObscureText() {
setState(() {
obscureText = !obscureText;
});
}
Let's create a snack bar to pop info on various circumstances.
// Create a scaffold messanger
SnackBar msgPopUp(msg) {
return SnackBar(
content: Text(
msg,
textAlign: TextAlign.center,
));
}
Let's change the child of Form we're returning to a column.
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(8),
child: Form(
key: _formKey,
child: Column(children: [],)
),
);
}
It's time to create our email, username, and password, and confirm password input forms. Inside the column's children for from widget, we'll add inputs one by one.
// Email
DynamicInputWidget(
controller: emailController,
obscureText: false,
focusNode: emailFocusNode,
toggleObscureText: null,
validator: authValidator.emailValidator,
prefIcon: const Icon(Icons.mail),
labelText: "Enter Email Address",
textInputAction: TextInputAction.next,
isNonPasswordField: true,
),
const SizedBox(
height: 20,
),
Make sure to import DynamicInput Widget
// Username
DynamicInputWidget(
controller: usernameController,
obscureText: false,
focusNode: usernameFocusNode,
toggleObscureText: null,
validator: null,
prefIcon: const Icon(Icons.person),
labelText: "Enter Username(Optional)",
textInputAction: TextInputAction.next,
isNonPasswordField: true,
),
const SizedBox(
height: 20,
),
// password
DynamicInputWidget(
controller: passwordController,
labelText: "Enter Password",
obscureText: obscureText,
focusNode: passwordFocusNode,
toggleObscureText: toggleObscureText,
validator: authValidator.passwordVlidator,
prefIcon: const Icon(Icons.password),
textInputAction: registerAuthMode
? TextInputAction.next
: TextInputAction.done,
isNonPasswordField: false,
),
const SizedBox(
height: 20,
),
// confirm password
DynamicInputWidget(
controller: confirmPasswordController,
focusNode: confirmPasswordFocusNode,
isNonPasswordField: false,
labelText: "Confirm Password",
obscureText: obscureText,
prefIcon: const Icon(Icons.password),
textInputAction: TextInputAction.done,
toggleObscureText: toggleObscureText,
validator: (val) => authValidator.confirmPasswordValidator(
val, passwordController.text),
),
const SizedBox(
height: 20,
),
Later on, we'll also need to create and toggle functions to register and sign in once we set up the firebase back-end. For now, we'll just toggle the register/sign-in texts of the elevated button.
// Toggle register/singin button text. Later on we'll also need to toggle register or signin function
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () {},
child: const Text('Cancel'),
),
const SizedBox(
width: 20,
),
ElevatedButton(
onPressed: () {},
child: Text(registerAuthMode ? 'Register' : 'Sign In'),
style: ButtonStyle(
elevation: MaterialStateProperty.all(8.0),
),
),
],
),
Manage authentication mode with setState().
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(registerAuthMode
? "Already Have an account?"
: "Don't have an account yet?"),
TextButton(
onPressed: () =>
setState(() => registerAuthMode = !registerAuthMode),
child: Text(registerAuthMode ? "Sign In" : "Regsiter"),
)
],
)
If you save the file and run it. You probably notice the toggle doesn't work. That's because we haven't implemented an animated transition yet. Two input widgets: username and confirm password widgets need to be hidden during the sign-in process but visible on registration.
So, we'll use the toggle visibility base on the value of the variable registerAuthMode we created earlier. We'll use animation for a smooth transition during the toggle.
AnimatedContainer class can be used for animation, while AnimatedOpacity class can be used for fade-in/out widgets. With the combination of these two, we'll toggle input with animated opacity while the animated container will squeeze/fill the space occupied by input smoothly.
So, let's animate the username, sized-box widget following it, and confirm-password input widget.
// Username
AnimatedContainer(
duration: const Duration(milliseconds: 500),
height: registerAuthMode ? 65 : 0,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 500),
opacity: registerAuthMode ? 1 : 0,
child: DynamicInputWidget(
controller: usernameController,
obscureText: false,
focusNode: usernameFocusNode,
toggleObscureText: null,
validator: null,
prefIcon: const Icon(Icons.person),
labelText: "Enter Username(Optional)",
textInputAction: TextInputAction.next,
isNonPasswordField: true,
),
),
),
// We'll also need to fade in/out sizedbox
AnimatedOpacity(
duration: const Duration(milliseconds: 500),
opacity: registerAuthMode ? 1 : 0,
child: const SizedBox(
height: 20,
),
),
// Confirm password
AnimatedContainer(
duration: const Duration(milliseconds: 500),
height: registerAuthMode ? 65 : 0,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 500),
opacity: registerAuthMode ? 1 : 0,
child: DynamicInputWidget(
controller: confirmPasswordController,
focusNode: confirmPasswordFocusNode,
isNonPasswordField: false,
labelText: "Confirm Password",
obscureText: obscureText,
prefIcon: const Icon(Icons.password),
textInputAction: TextInputAction.done,
toggleObscureText: toggleObscureText,
validator: (val) => authValidator.confirmPasswordValidator(
val, passwordController.text),
),
),
),
After this, you'll be able to see two inputs on the authentication screen, cause sign-in mode is our default mode. Now, our Form Widget UI is ready. Don't be confused, you can find all auth_form_widget as a whole down below.
import 'package:flutter/material.dart';
import 'package:temple/screens/auth/utils/auth_validators.dart';
import 'package:temple/screens/auth/widgets/text_from_widget.dart';
class AuthFormWidget extends StatefulWidget {
const AuthFormWidget({Key? key}) : super(key: key);
@override
State<AuthFormWidget> createState() => _AuthFormWidgetState();
}
class _AuthFormWidgetState extends State<AuthFormWidget> {
// Define Form key
final _formKey = GlobalKey<FormState>();
// Instantiate validator
final AuthValidators authValidator = AuthValidators();
// controllers
late TextEditingController emailController;
late TextEditingController usernameController;
late TextEditingController passwordController;
late TextEditingController confirmPasswordController;
// create focus nodes
late FocusNode emailFocusNode;
late FocusNode usernameFocusNode;
late FocusNode passwordFocusNode;
late FocusNode confirmPasswordFocusNode;
// to obscure text default value is false
bool obscureText = true;
// This will require to toggle between register and sigin in mode
bool registerAuthMode = false;
// Instantiate all the *text editing controllers* and focus nodes on *initState* function
@override
void initState() {
super.initState();
emailController = TextEditingController();
usernameController = TextEditingController();
passwordController = TextEditingController();
confirmPasswordController = TextEditingController();
emailFocusNode = FocusNode();
usernameFocusNode = FocusNode();
passwordFocusNode = FocusNode();
confirmPasswordFocusNode = FocusNode();
}
// These all need to be disposed of once done so let's do that as well.
@override
void dispose() {
super.dispose();
emailController.dispose();
usernameController.dispose();
passwordController.dispose();
confirmPasswordController.dispose();
emailFocusNode.dispose();
usernameFocusNode.dispose();
passwordFocusNode.dispose();
confirmPasswordFocusNode.dispose();
}
// Create a function that'll toggle the password's visibility on the relevant icon tap.
void toggleObscureText() {
setState(() {
obscureText = !obscureText;
});
}
// Let's create a snack bar to pop info on various circumstances.
// Create a scaffold messanger
SnackBar msgPopUp(msg) {
return SnackBar(
content: Text(
msg,
textAlign: TextAlign.center,
));
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(8),
child: Form(
key: _formKey,
child: Column(
children: [
// Email
DynamicInputWidget(
controller: emailController,
obscureText: false,
focusNode: emailFocusNode,
toggleObscureText: null,
validator: authValidator.emailValidator,
prefIcon: const Icon(Icons.mail),
labelText: "Enter Email Address",
textInputAction: TextInputAction.next,
isNonPasswordField: true,
),
const SizedBox(
height: 20,
),
// Username
AnimatedContainer(
duration: const Duration(milliseconds: 500),
height: registerAuthMode ? 65 : 0,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 500),
opacity: registerAuthMode ? 1 : 0,
child: DynamicInputWidget(
controller: usernameController,
obscureText: false,
focusNode: usernameFocusNode,
toggleObscureText: null,
validator: null,
prefIcon: const Icon(Icons.person),
labelText: "Enter Username(Optional)",
textInputAction: TextInputAction.next,
isNonPasswordField: true,
),
),
),
AnimatedOpacity(
duration: const Duration(milliseconds: 500),
opacity: registerAuthMode ? 1 : 0,
child: const SizedBox(
height: 20,
),
),
DynamicInputWidget(
controller: passwordController,
labelText: "Enter Password",
obscureText: obscureText,
focusNode: passwordFocusNode,
toggleObscureText: toggleObscureText,
validator: authValidator.passwordVlidator,
prefIcon: const Icon(Icons.password),
textInputAction: registerAuthMode
? TextInputAction.next
: TextInputAction.done,
isNonPasswordField: false,
),
const SizedBox(
height: 20,
),
AnimatedContainer(
duration: const Duration(milliseconds: 500),
height: registerAuthMode ? 65 : 0,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 500),
opacity: registerAuthMode ? 1 : 0,
child: DynamicInputWidget(
controller: confirmPasswordController,
focusNode: confirmPasswordFocusNode,
isNonPasswordField: false,
labelText: "Confirm Password",
obscureText: obscureText,
prefIcon: const Icon(Icons.password),
textInputAction: TextInputAction.done,
toggleObscureText: toggleObscureText,
validator: (val) => authValidator.confirmPasswordValidator(
val, passwordController.text),
),
),
),
const SizedBox(
height: 20,
),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () {},
child: const Text('Cancel'),
),
const SizedBox(
width: 20,
),
ElevatedButton(
onPressed: () {},
child: Text(registerAuthMode ? 'Register' : 'Sign In'),
style: ButtonStyle(
elevation: MaterialStateProperty.all(8.0),
),
),
],
),
const SizedBox(
height: 20,
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(registerAuthMode
? "Already Have an account?"
: "Don't have an account yet?"),
TextButton(
onPressed: () =>
setState(() => registerAuthMode = !registerAuthMode),
child: Text(registerAuthMode ? "Sign In" : "Regsiter"),
)
],
)
],
),
),
);
}
}
Our validator's still not going to work, because we still haven't handled what to do on form submission. But let's check out the result of our work.
Let's summarize what we did so far.
That's it for today. We'll work on Firebase Flutter Set-Up in the next section. If you have any questions then leave them in the comment section.
Thank you for your time. Don't forget to give a like and share the article. Hopefully, you'll also subscribe to the publication to get notified of the next upload. This is Nibesh Khadka from Khadka's Coding Lounge. We are a freelancing agency that makes high-value websites and mobile applications, google workspace add-ons, and much more.
Also published here.