Flutter Responsive Design Tutorial — Login and Registration in a dialog with Form validation

Sanjib Maharjan
19 min readSep 14, 2022

--

Designing in Flutter is very easy with its unique way of coding style as compared to other languages like CSS. Flutter is the cross-platform framework and the app developed using it can run on various devices ranging from small mobile devices to larger devices like desktop computers. When designing the app, it's a good practice to consider responsiveness so that we can make sure that our design does not fall apart when loaded on devices of different sizes.
In this tutorial, we’re gonna build the login and register form using the steps mentioned in my responsive design article which you can get access to by clicking here.
The final result of this tutorial is shown in the image below. You can also get the completed repo link in the end sections of this article but I highly recommend going through this detailed explanation. To begin you can follow along with this article or you can also watch the video that is provided in this article.

Login and Register Form Design in Flutter

We’re gonna build the above auth forms and while doing so we’re also gonna cover the following topics.

  1. Custom painter and Clipper
  2. Form Validation
  3. Responsive Design

We’ll be using flutter version 3.0.0 and fvm throughout this tutorial. And if you are unaware of fvm then you can check out my tutorial on that or you can also choose to not use it. If you are not using fvm then you just have to exclude the fvm prefix from all the fvm commands that we’ll use further in this tutorial. For example, the fvm equivalent command to create the project is fvm flutter create <project_name> . Hence if you are not using fvm then the command to create the project will be flutter create <project_name>.

Create the project using the command fvm flutter create <project_name>. Omit fvm from the command if you are not using fvm (this is the last time I am pointing this out 😊). Now open the project in your favorite IDE. I will not be going through the steps to open it in IDE and also setting the flutter version properly. For that, you can check my other tutorial. I am proceeding further assuming that you have properly set version 3.0.0 to your project. You can also choose a different version but make sure that the version supports null safety.

Open the main.dart file and replace its boilerplate content with the following contents.

void main() {
WidgetsFlutterBinding.ensureInitialized();
runApp(const MyApp());
}

class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);

@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Login',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: const HomePage());
}
}
class HomePage extends StatelessWidget {
const HomePage({Key? key}) : super(key: key);

@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
InkWell(
onTap: () => {},
child: const Text("Login")),

InkWell(
onTap: () => {},
child: const Text("Register")),
],
),
),
appBar: AppBar(title: const Text("Flutter Login Design")),
);
}
}

Now if you run the code then you’ll see 2 buttons spaced evenly in the center position. The idea here is to open the login or register form when clicking the respective buttons. We’ll be showing the forms in a dialog and in flutter we can use the showDialog function and Dialog widget from the material package. Create the file /utils/dialog_util.dart and paste the following contents.

import 'package:flutter/material.dart';

abstract class DialogUtil {
static openLoginPopup(BuildContext context, {bool dismissible = true, bool isRegister = false}) {
showDialog(
context: context,
barrierDismissible: dismissible,
builder: (context) => WillPopScope(
onWillPop: () async => dismissible,
child: Dialog(
insetPadding: const EdgeInsets.all(10),
backgroundColor: Colors.transparent,
child: AuthWrapper(isRegister: isRegister,)
),
));
}
}

This function consists of a dismissible property through which we can control the closing of the dialog by clicking outside part of the dialog. WillPopScope widget further enhances this by controlling the dismissal of the dialog when clicking the back button. ie. when dismissable is passed as false then the dialog cannot be dismissed in any way(clicking the back button or clicking outside) except directly from the code.
The auth wrapper widget is not yet there and inside that, we’ll write the logic to render the register or login page as per the given condition. For now, create the widget /widgets/auth/auth_wrapper.dart and paste the following.

import 'package:flutter/material.dart';

class AuthWrapper extends StatefulWidget {
final bool isRegister;

const AuthWrapper({Key? key, this.isRegister = false}) : super(key: key);

@override
State<AuthWrapper> createState() => _AuthWrapperState();
}

class _AuthWrapperState extends State<AuthWrapper> {

late bool _isRegisterPage;

@override
void initState() {
super.initState();
_isRegisterPage = widget.isRegister;
}

void _switchPage({bool isRegisterPage = false}) {
setState(() => _isRegisterPage = isRegisterPage);
}

@override
Widget build(BuildContext context) {
return Container(
height: 100,
width: double.infinity,
color: Colors.amber,
child: Text(_isRegisterPage ? "Register Page": "Login Page"),
);
}
}

AuthWrapper is a stateful class with a state to store a boolean to represent the login or register page. Import this widget in the dialog_util class and after that our code should render to display the dialog with a “Register” or “Login” text when clicking the respective button after we add the openLoginPop handler to the buttons in the HomePage widget as follows.

InkWell(
onTap: () => DialogUtil.openLoginPopup(context, dismissible: false),
child: const Text("Login")),

InkWell(
onTap: () => DialogUtil.openLoginPopup(context, dismissible: false, isRegister: true),
child: const Text("Register")),

Next, we’ll proceed to make the designs for the login form.

The design mainly consists of two main sections, for the top part we can use either a CustomPainter or CustomClipper widget as per our preference. The bottom part contains the form elements for which we’ll use the widgets like TextButton, TextField, etc.
But before that let's define some theme colors that we’re gonna use throughout this tutorial, in a separate file constants/colors.dart and paste the following contents.

import 'package:flutter/material.dart';

const themeColorDarkest = Color(0xFF75d5e3);
const themeColorDark = Color(0xFF8ae5ff);
const themeColor = Color(0xFFacf5fb);
const themeColorLighter = Color(0xFFc3fbf9);
const themeColorLightest = Color(0xFFcdf9ff);

const errorThemeColor = Color(0xFFB22222);

// const themeColorDarkest = Color(0xFFbb3200);
// const themeColorDark = Color(0xFFd53900);
// const themeColor = Color(0xFFee4000);
// const themeColorLighter = Color(0xFFff4b08);
// const themeColorLightest = Color(0xFFff5d22);

Its consists of two sets of colors that we can use interchangeably. You can add as many color sets here and use the one that fits you the best.

Now create another file widgets/auth/auth_curve.dart and paste the following contents.

import 'package:flutter/material.dart';
import 'package:flutter_login/constants/colors.dart';

class AuthCurve extends StatelessWidget {
const AuthCurve({Key? key}) : super(key: key);

@override
Widget build(BuildContext context) {
return ClipPath(
clipper: AuthClipper(),
child: Container(
height: 200, width: double.infinity, color: themeColorLightest),
);
}
}
class AuthClipper extends CustomClipper<Path> {
@override
Path getClip(Size size) {
// TODO: implement getClip
throw UnimplementedError();
}

@override
bool shouldReclip(covariant CustomClipper<Path> oldClipper) {
// TODO: implement shouldReclip
throw UnimplementedError();
}
}

The AuthCurve widget consists of a container that is clipped by the ClipPath widget and the path that is clipped is defined by the ClipPath widget within its getClip function which we’ll explore next.

We must define the path inside the getClip function of the AuthClipper widget. It also has another function shouldRelip function which must return true if reclip is needed again to give a different path given that some parameters changes, and false if no reclip is needed. In our case, the path remains the same throughout its lifecycle and hence we’ll return false from that function.

Drawing the path is similar to drawing the lines in a coordinate system by joining different points except the origin lies at the top-left side of the widget that is being clipped.

The path always starts from the origin and from there we can draw lines or curves to form the different paths.

  1. lineTo function: We can draw a straight line from the current point to the given point (x, y) using this function. Eg. path.lineTo(x, y)
  2. quadraticBezierTo function: We can draw a curve from the current point to the ending point (x, y) using this function. It also has an extra control point (cx, cy) which decides the curviness of the line.
    Eg. path.quadtaticBezierTo(cx, cy, x, y)
  3. cubicTo function: This function also is used to draw the curve line but it has 2 control points. Eg. path.cubicTo(cx1, cy1, cx2, cy2, x, y)
  4. close function: This function draws the line from the current point to the starting point.

We can use the above methods to create the required path that we have in our design. We start from the origin from where we’ll draw the straight vertical line to (0, height/1.3) using the lineTo function. From there we’ll draw a curve using the quadratic bezier function up to (w, h/1.05) using the control point as (w/2.5, h/2). This forms the inward curve which does not meet the curve in our design. Hence we’ll use the cubicTo function instead, using a second control point as (w/1.3, h*1.05). From there we’ll draw the vertical line to (w, 0) using the pointTo function and finally close the loop by using the close function. I got these points through many attempts, you can play around with these points to get a path that is different from the one we have here.

Using these points update the code in the getClip function of the AuthClipper widget.

class AuthClipper extends CustomClipper<Path> {
@override
Path getClip(Size size) {
Path path = Path();
path.lineTo(0, size.height / 1.3);
var controlPoint = Offset(size.width / 2.5, size.height / 2);
var controlPoint2 = Offset(size.width / 1.3, size.height * 1.05);
var endPoint = Offset(size.width, size.height / 1.05);
// path.quadraticBezierTo(
// controlPoint.dx, controlPoint.dy, endPoint.dx, endPoint.dy);
path.cubicTo(controlPoint.dx, controlPoint.dy, controlPoint2.dx,
controlPoint2.dy, endPoint.dx, endPoint.dy);
path.lineTo(size.width, 0);
path.close();
return path;
}

@override
bool shouldReclip(covariant CustomClipper<Path> oldClipper) {
return false;
}
}

We have one curve ready. We still need another curve with a different color. If we were to use the ClipPath widget to achieve that then we will have to follow the above steps to generate the new path and clip another container with that path. And then finally put together those two using a stack widget. If you like this approach then you can do that but there is another approach using a CustomPainter widget where we can draw as many paths with different colors. Create a class AuthPainter class and paste the following contents.

class AuthPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
var controlPoint = Offset(size.width / 2, size.height / 2.5);
var controlPoint2 = Offset(size.width * 1.05, size.height / 1.3);
var endPoint = Offset(size.width / 1.05, size.height);
Path path = Path()
..lineTo(size.width, 0)
..cubicTo(controlPoint.dx, controlPoint.dy, controlPoint2.dx,
controlPoint2.dy, endPoint.dx, endPoint.dy)
..lineTo(0, size.height)
..close();
var paint1 = Paint()..color = themeColorLightest;
canvas.drawPath(path, paint1);

var controlPoint3 = Offset(size.width / 3, size.height / 2);
var controlPoint4 = Offset(size.width * 1.01, size.height / 1.3);
var endPoint2 = Offset(size.width / 1.3, size.height);
Path path2 = Path()
..lineTo(size.width / 1.03, 0)
..cubicTo(controlPoint3.dx, controlPoint3.dy, controlPoint4.dx,
controlPoint4.dy, endPoint2.dx, endPoint2.dy)
..lineTo(0, size.height)
..close();
var paint2 = Paint()..color = themeColorDarkest;
canvas.drawPath(path2, paint2);
}

@override
bool shouldRepaint(covariant CustomPainter oldDelegate) {
return false;
}
}

Here we copied the first path from the AuthClipper class and draw that path on the canvas with color. We also have another path there which I created, following the steps that we did for the first path. Finally, also update the code in the AuthCurve class to use the painter class.

class AuthCurve extends StatelessWidget {
const AuthCurve({Key? key}) : super(key: key);

@override
Widget build(BuildContext context) {
return Stack(
children: [
CustomPaint(
painter: AuthPainter(), size: const Size(double.infinity, 200)),
Positioned(
top: 10,
right: 10,
child: RawMaterialButton(
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
onPressed: () => Navigator.of(context).pop(),
constraints: BoxConstraints.tight(const Size(22, 22)),
fillColor: Colors.white,
shape: const CircleBorder(),
child: const Icon(
Icons.close,
size: 20,
color: themeColorDarkest
),
),
)
],
);
}
}

Here I also placed the close button on the top right side to dismiss the dialog using the Stack widget.
Update the build function in theAuthWrapper widget with the following contents.

@override
Widget build(BuildContext context) {
return ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(20)),
child: Container(
color: Colors.white,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const AuthCurve(),
Text(_isRegisterPage ? "Register Page": "Login Page")
],
)),
),
);
}

Now if we run the project, then we should get the following result. I used aClipRRect widget to make the corners rounded.

Create a file widgets/commons/custom_text_field.dart and paste the following contents.

import 'package:flutter/material.dart';
import 'package:flutter_login/constants/colors.dart';

class CustomTextField extends StatelessWidget {
final String? placeholder;
final Widget? prefixIcon;
final Widget? suffixIcon;
final bool hideText;
final TextEditingController? controller;
final bool valid;
final String? errorText;

const CustomTextField(
{Key? key,
this.valid = true,
this.errorText,
this.suffixIcon,
this.controller,
this.hideText = false,
this.placeholder,
this.prefixIcon})
: super(key: key);

@override
Widget build(BuildContext context) {
return TextField(
controller: controller,
obscureText: hideText,
decoration: InputDecoration(
errorText: valid ? null : errorText,
suffixIcon: suffixIcon,
errorBorder: const OutlineInputBorder(
borderSide: BorderSide(color: errorThemeColor),
borderRadius: BorderRadius.all(Radius.circular(30))),
focusedErrorBorder: const OutlineInputBorder(
borderSide: BorderSide(color: errorThemeColor),
borderRadius: BorderRadius.all(Radius.circular(30))),
enabledBorder: const OutlineInputBorder(
borderSide: BorderSide(color: themeColorDarkest),
borderRadius: BorderRadius.all(Radius.circular(30))),
focusedBorder: const OutlineInputBorder(
borderSide: BorderSide(color: themeColorDarkest, width: 2),
borderRadius: BorderRadius.all(Radius.circular(30))),
disabledBorder: const OutlineInputBorder(
borderSide: BorderSide(color: Colors.transparent),
borderRadius: BorderRadius.all(Radius.circular(30))),
hintText: placeholder,
prefixIcon: prefixIcon),
);
}
}

The above widget is specialized to match our design and will be reused for all the text fields that we’re gonna use in the forms. It consists of a valid and the errorText parameters which will be used for the form validation later.
Similarly, create another custom class widgets/commons/custom_button.dart and paste the following contents in there.

import 'package:flutter/material.dart';
import 'package:flutter_login/constants/colors.dart';

class CustomButton extends StatelessWidget {
final String label;
final bool disabled;
final EdgeInsets? padding;
final VoidCallback onPressed;

const CustomButton(
{Key? key,
required this.label,
this.disabled = false,
this.padding,
required this.onPressed})
: super(key: key);

@override
Widget build(BuildContext context) {
return TextButton(
onPressed: disabled ? null : onPressed,
style: TextButton.styleFrom(
padding: padding ?? const EdgeInsets.symmetric(vertical: 20),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(30))),
backgroundColor: disabled ? Colors.black12 : themeColorDarkest),
child: Text(
label,
style: const TextStyle(color: Colors.white),
));
}
}

With these, we can add the fields for a username as:

CustomTextField(
placeholder: "Username",
controller: _usernameController,
prefixIcon: const Icon(
Icons.person,
color: themeColorDarkest,
))

The controller is added to read the text from the text field. Initialize them as follows:

String _username = "";

late final TextEditingController _usernameController;

@override
void initState() {
super.initState();
_usernameController = TextEditingController()
..addListener(() {
setState(() {
_username = _usernameController.text.trim();
});
});
}

@override
void dispose() {
super.dispose();
_usernameController.dispose();
}

Add another text field for the password and repeat all the above steps to read the value. In addition, we’ll also hide the password field and add a button to hide the visibility of the password field. Next, Add a button to submit the form and also disable it as long as there is some text in either of the text field. Finally, we’ll add the link for the forgot password and also a link to take to the registration form.

import 'package:flutter/material.dart';
import 'package:flutter_login/constants/colors.dart';
import 'package:flutter_login/widgets/commons/custom_button.dart';
import 'package:flutter_login/widgets/commons/custom_text_field.dart';

class LoginForm extends StatefulWidget {
final VoidCallback switchToRegisterPage;

const LoginForm({Key? key, required this.switchToRegisterPage})
: super(key: key);

@override
State<LoginForm> createState() => _LoginFormState();
}

class _LoginFormState extends State<LoginForm> {
bool _isPasswordVisible = false;
String _username = "";
String _password = "";

late final TextEditingController _usernameController;
late final TextEditingController _passwordController;

@override
void initState() {
super.initState();
_usernameController = TextEditingController()
..addListener(() {
setState(() {
_username = _usernameController.text.trim();
});
});
_passwordController = TextEditingController()
..addListener(() {
setState(() {
_password = _passwordController.text.trim();
});
});
}

bool get _isFormValid => _username.isNotEmpty && _password.isNotEmpty;

@override
void dispose() {
super.dispose();
_usernameController.dispose();
_passwordController.dispose();
}

@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 40, vertical: 30),
child: Column(
children: [
CustomTextField(
placeholder: "Username",
controller: _usernameController,
prefixIcon: const Icon(
Icons.person,
color: themeColorDarkest,
)),
const SizedBox(
height: 30,
),
CustomTextField(
controller: _passwordController,
hideText: !_isPasswordVisible,
suffixIcon: IconButton(
onPressed: _toggleVisibility,
icon: Icon(_isPasswordVisible
? Icons.visibility_outlined
: Icons.visibility_off_outlined)),
placeholder: "Password",
prefixIcon: const Icon(
Icons.key,
color: themeColorDarkest,
),
),
const SizedBox(
height: 30,
),
SizedBox(
width: double.infinity,
child: CustomButton(
onPressed: _tryLogin,
padding: const EdgeInsets.symmetric(vertical: 20),
label: "Login",
disabled: !_isFormValid,
)),
const SizedBox(
height: 20,
),
InkWell(
onTap: () {},
child: const Text("Forgot password?"),
),
const SizedBox(
height: 20,
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text("Join us Now? "),
InkWell(
onTap: widget.switchToRegisterPage,
child: const Text(
"Register",
style: TextStyle(
color: themeColorDarkest,
decoration: TextDecoration.underline),
),
)
],
)
],
),
);
}

void _tryLogin() {
//todo make an api call to login
}

void _toggleVisibility() {
setState(() => _isPasswordVisible = !_isPasswordVisible);
}
}

Include the LoginLorm in the AuthWrapper widget as:

_isRegisterPage ? const Text(“Register Page”) : LoginForm(switchToRegisterPage: () => _switchPage(isRegisterPage: true))

Create another class widgets/auth/register_form.dart and copy all the contents from the LoginForm widget to this and then we’ll make certain changes to accommodate the design for the registration.
We’re gonna add two extra fields for the email and the confirm password. For the validation of this form, we’re gonna do some extra work than we did for the login form. First of all, we’ll disable the button if any of the fields fail to meet the validation criteria. Let's say the username field should contain at least 3 characters, the password should contain at least 8 characters, the email field should have a valid email, and finally, confirm password should be equal to the password.

Create utils/validation_utils.dart file and add the following contents there to validate the email.

abstract class ValidationUtil {
static final RegExp _emailRegExp = RegExp(
r'^[a-zA-Z0-9.!#$%&’*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$');

static bool isValidEmail(String email) {
return _emailRegExp.hasMatch(email);
}
}

Now define some functions to validate the fields that we have in our form.

bool _isUsernameValid({bool skipEmpty = true}) =>
_username.length >= 3 || (skipEmpty && _username.isEmpty);

bool _isPasswordValid({bool skipEmpty = true}) =>
_password.length >= 8 || (skipEmpty && _password.isEmpty);

bool _isConfirmPasswordValid({bool skipEmpty = true}) =>
_confirmPassword == _password || (skipEmpty && _confirmPassword.isEmpty);

bool _isEmailValid({bool skipEmpty = true}) =>
ValidationUtil.isValidEmail(_email) || (skipEmpty && _email.isEmpty);

bool get _isFormValid =>
_isUsernameValid(skipEmpty: false) &&
_isPasswordValid(skipEmpty: false) &&
_isConfirmPasswordValid(skipEmpty: false) &&
_isEmailValid(skipEmpty: false);

The skipEmpty parameter is used to skip the validation in the text fields in case there is no text.
If you have missed some steps, here is the entire content of the registration form.

import 'package:flutter/material.dart';
import 'package:flutter_login/constants/colors.dart';
import 'package:flutter_login/utils/validation_util.dart';
import 'package:flutter_login/widgets/commons/custom_button.dart';
import 'package:flutter_login/widgets/commons/custom_text_field.dart';

class RegisterForm extends StatefulWidget {
final VoidCallback switchToLoginPage;

const RegisterForm({Key? key, required this.switchToLoginPage})
: super(key: key);

@override
State<RegisterForm> createState() => _RegisterFormState();
}

class _RegisterFormState extends State<RegisterForm> {
bool _isPasswordVisible = false;
bool _isConfirmPasswordVisible = false;
String _username = "";
String _password = "";
String _email = "";
String _confirmPassword = "";

late final TextEditingController _usernameController;
late final TextEditingController _passwordController;
late final TextEditingController _emailController;
late final TextEditingController _confirmPasswordController;

@override
void initState() {
super.initState();
_usernameController = TextEditingController()
..addListener(() {
setState(() {
_username = _usernameController.text.trim();
});
});
_passwordController = TextEditingController()
..addListener(() {
setState(() {
_password = _passwordController.text.trim();
});
});
_emailController = TextEditingController()
..addListener(() {
setState(() {
_email = _emailController.text.trim();
});
});
_confirmPasswordController = TextEditingController()
..addListener(() {
setState(() {
_confirmPassword = _confirmPasswordController.text.trim();
});
});
}

bool _isUsernameValid({bool skipEmpty = true}) =>
_username.length >= 3 || (skipEmpty && _username.isEmpty);

bool _isPasswordValid({bool skipEmpty = true}) =>
_password.length >= 8 || (skipEmpty && _password.isEmpty);

bool _isConfirmPasswordValid({bool skipEmpty = true}) =>
_confirmPassword == _password || (skipEmpty && _confirmPassword.isEmpty);

bool _isEmailValid({bool skipEmpty = true}) =>
ValidationUtil.isValidEmail(_email) || (skipEmpty && _email.isEmpty);

bool get _isFormValid =>
_isUsernameValid(skipEmpty: false) &&
_isPasswordValid(skipEmpty: false) &&
_isConfirmPasswordValid(skipEmpty: false) &&
_isEmailValid(skipEmpty: false);

@override
void dispose() {
super.dispose();
_usernameController.dispose();
_passwordController.dispose();
_confirmPasswordController.dispose();
_emailController.dispose();
}

@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 40, vertical: 30),
child: Column(
children: [
CustomTextField(
placeholder: "Username",
controller: _usernameController,
valid: _isUsernameValid(),
errorText: "Invalid username",
prefixIcon: const Icon(
Icons.person,
color: themeColorDarkest,
)),
const SizedBox(
height: 20,
),
CustomTextField(
placeholder: "Email",
valid: _isEmailValid(),
errorText: "Invalid email",
controller: _emailController,
prefixIcon: const Icon(
Icons.alternate_email_outlined,
color: themeColorDarkest,
)),
const SizedBox(
height: 20,
),
CustomTextField(
controller: _passwordController,
valid: _isPasswordValid(),
errorText: "Password must of of at-least of 8 characters",
hideText: !_isPasswordVisible,
suffixIcon: IconButton(
onPressed: _toggleVisibility,
icon: Icon(_isPasswordVisible
? Icons.visibility_outlined
: Icons.visibility_off_outlined)),
placeholder: "Password",
prefixIcon: const Icon(
Icons.key,
color: themeColorDarkest,
),
),
const SizedBox(
height: 20,
),
CustomTextField(
controller: _confirmPasswordController,
hideText: !_isConfirmPasswordVisible,
valid: _isConfirmPasswordValid(),
errorText: "Must match the password",
suffixIcon: IconButton(
onPressed: _toggleConfirmPasswordVisibility,
icon: Icon(_isConfirmPasswordVisible
? Icons.visibility_outlined
: Icons.visibility_off_outlined)),
placeholder: "Confirm Password",
prefixIcon: const Icon(
Icons.key,
color: themeColorDarkest,
),
),
const SizedBox(
height: 20,
),
SizedBox(
width: double.infinity,
child: CustomButton(
onPressed: _tryRegister,
label: "Register",
disabled: !_isFormValid,
padding: const EdgeInsets.symmetric(vertical: 12),
)),
const SizedBox(
height: 20,
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text("Have Account? "),
InkWell(
onTap: widget.switchToLoginPage,
child: const Text(
"Login",
style: TextStyle(
color: themeColorDarkest,
decoration: TextDecoration.underline),
),
)
],
)
],
),
);
}

void _tryRegister() {
//todo make an api call to register
}

void _toggleVisibility() {
setState(() => _isPasswordVisible = !_isPasswordVisible);
}

void _toggleConfirmPasswordVisibility() {
setState(() => _isConfirmPasswordVisible = !_isConfirmPasswordVisible);
}
}

Add the RegisterForm widget to the AuthWrapper widget and then we’ll get the following result with the form validation.

We have the design for the auth forms ready with validation, but when we load the app on devices with larger screen sizes, the designs look stretched and torn out.

Next, we’ll work to make this design responsive so that when it is loaded on the larger devices, it does not look as stretched out as shown in the above picture. We’ll load the components differently when the width of the device reaches a certain limit.
I’ll be using the classes that I’ve used in my tutorials for the Responsive Design in Flutter which you can get access to by clicking here.

We have the ResponsiveUtil class which compares the current width of the devices using the MediaQuery class and with that class, we can render components differently for various ranges of device sizes. Here in this tutorial, I have considered only 3 device types: Small(Mobile): less than or equal to 600, Medium(Tab): greater than 600 and lower than 900, and Large(Desktop): greater than or equal to 900. These sizes are just the assumptions that you can modify as per your need.
Now we can render different components using this class as follows.

final ResponsiveUtil _util = ResponsiveUtil(context: context);

return Text(_util.isMobile
? "Mobile"
: _util.isTablet
? "Tablet"
: "Desktop");
//we can also use the value function as follows
return Text(_util.value<String>(
mobile: "Mobile",
tablet: "Tablet",
desktop: "Desktop"
)
);
//Or set different padding values
return Padding(
padding: EdgeInsets.all(
_util.value<double>(
mobile: 12,
tablet: 14,
desktop: 16
)
),
child: const Text("Test"),
);
//Or render completly different designs
return _util.value<Widget>(
mobile: MobileDesign(),
tablet: TabDesign(),
desktop: DesktopDesign()
);

We can also delegate the instantiation of the responsive util class to the responsive builder widget. Here's how we can make use of it.

return ResponsiveBuilder(
builder: (responsiveUtil) {
return _util.value<Widget>(
mobile: MobileDesign(),
tablet: TabDesign(),
desktop: DesktopDesign()
);
}
);

You can get access to these classes from this gist. We also have some utility widgets like RowOrColumn which renders the widgets in a row or a column as per the given condition. You can find these classes in this gist.

Now using these classes, we’ll make our designs responsive.

First of all, we’ll fix the max width to a certain width so that the design remains the same for all the devices that exceed that limit. We have two designs one for the mobile screens and one for the desktop and tabs(ie. having a width greater than 600) which is shown in the figure above. We have already designed the first one, we’ll proceed to design the second one next.

Open the AuthCurve widget and make the following changes.

class AuthCurve extends StatelessWidget {
final ResponsiveUtil responsiveUtil;

const AuthCurve({Key? key, required this.responsiveUtil}) : super(key: key);

@override
Widget build(BuildContext context) {
return Stack(
children: [
CustomPaint(
painter:
responsiveUtil.isMobile ? AuthPainterMobile() : AuthPainter(),
size: Size(
double.infinity,
responsiveUtil.value<double>(
mobile: 200, tablet: 500, desktop: 600)),
),
],
);
}
}

Notice we’ve removed the close button from this widget which we’ll keep elsewhere later since keeping it here would not work for the second design. We have received the responsiveUtil from the parameter which we could also get by using a ResponsiveBuilder widget. Finally, we have created two painter classes each for mobile and non-mobile devices. We already have the code for the AuthPainterMobile and for the AuthPainter, create a new class and paste the following contents.

class AuthPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
var controlPoint = Offset(size.width / 2, size.height / 2.5);
var controlPoint2 = Offset(size.width * 1.05, size.height / 1.3);
var endPoint = Offset(size.width / 1.05, size.height);
Path path = Path()
..lineTo(size.width, 0)
..cubicTo(controlPoint.dx, controlPoint.dy, controlPoint2.dx,
controlPoint2.dy, endPoint.dx, endPoint.dy)
..lineTo(0, size.height)
..close();
var paint1 = Paint()..color = themeColorLightest;
canvas.drawPath(path, paint1);

var controlPoint3 = Offset(size.width / 3, size.height / 2);
var controlPoint4 = Offset(size.width * 1.01, size.height / 1.3);
var endPoint2 = Offset(size.width / 1.3, size.height);
Path path2 = Path()
..lineTo(size.width / 1.03, 0)
..cubicTo(controlPoint3.dx, controlPoint3.dy, controlPoint4.dx,
controlPoint4.dy, endPoint2.dx, endPoint2.dy)
..lineTo(0, size.height)
..close();
var paint2 = Paint()..color = themeColorDarkest;
canvas.drawPath(path2, paint2);
}

@override
bool shouldRepaint(covariant CustomPainter oldDelegate) {
return false;
}
}

Finally, in the AuthWrapper class make the following changes.

@override
Widget build(BuildContext context) {
return ResponsiveBuilder(
builder: (responsiveUtil) => ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(20)),
child: Container(
constraints: const BoxConstraints(maxWidth: 900),
color: Colors.white,
child: Stack(
children: [
SingleChildScrollView(
child: RowOrColumn(
showRow: !responsiveUtil.isMobile,
columnMainAxisSize: MainAxisSize.min,
children: [
ExpandedIf(
flex: 4,
expanded: !responsiveUtil.isMobile,
child: AuthCurve(
responsiveUtil: responsiveUtil,
)),
ExpandedIf(
flex: 5,
expanded: !responsiveUtil.isMobile,
child: _isRegisterPage
? RegisterForm(
switchToLoginPage: () =>
_switchPage(isRegisterPage: false),
)
: LoginForm(
switchToRegisterPage: () =>
_switchPage(isRegisterPage: true),
))
],
)),
Positioned(
top: 10,
right: 10,
child: RawMaterialButton(
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
onPressed: () => Navigator.of(context).pop(),
constraints: BoxConstraints.tight(const Size(22, 22)),
fillColor: responsiveUtil.value<Color>(
mobile: Colors.white,
desktop: themeColorDarkest,
tablet: themeColorDarkest),
shape: const CircleBorder(),
child: Icon(
Icons.close,
size: 20,
color: responsiveUtil.value<Color>(
mobile: themeColorDarkest,
desktop: Colors.white,
tablet: Colors.white),
),
),
)
],
),
),
),
);
}

Here in the AuthCurve widget, we’ve wrapped the entire content with the ResponsiveBuilder widget so that we can access it throughout the widget tree. Then we used it to render the UI differently in certain places for mobile and other devices. We have a RowOrColumn widget to render the curve component and form the component horizontally or vertically as per the given condition. We also placed the close button here so that its position is always at the top right and inverted its fillColor and icon color for mobile and non-mobile devices to match its contrast with the background.
We can also use this ResposiveBuilder class in the login and register form to adjust the design as per our requirement which is the task you can do as practice.

With this, we’ve reached the end of this tutorial. If you’ve reached here then pat yourself cuz we’ve covered a lot in designing this login and register form with validation and also making it responsive.

Please do leave suggestions or questions if any in the comment section below. Also, feel free to clap 👏 as much as you can and share with other people to show support. Thank you!

Please find all the links that are mentioned in the tutorial above in the following section.

  1. Fvm Tutorial (video tutorial)
  2. Responsive Design Tutorial
  3. Responsive Util and Responsive Widget gist
  4. Completed Repo Link

#responsive #design #flutter #login_responsive_design_flutter #register_responsive_design_flutter #form_validation_flutter #curves_in_flutter #bezier_vurves #clip_path #custom_painter

--

--