This post is about implementing a full authentication flow in Flutter, using various AWS Amplify Auth methods. The aim is to build a robust authentication flow using appropriate state management techniques to separate UI, logic, and authentication code and present user-friendly error messages.
The widget tree is straightforward. We will present a sign in page for the user, and once the authentication is done, we will show a homepage with a sign-out button.
We packed the code with cool concepts and ideas. We used providers, enums, custom buttons, and more. We tried to follow best practices to produce a modular, testable & maintainable code.
We used the Amplify Admin UI to configure the authentication mechanisms.
We will use two providers in this flow:
AppUser: This is the primary provider where we will configure Amplify & use it to authenticate the user. We will use ChangeNotifier to track the authentication state.
class AppUser extends ChangeNotifier {
bool isSignedIn = false;
String username;
AppUser() {
if (!Amplify.isConfigured) configureAmplify();
}
void configureAmplify() async {
AmplifyAuthCognito authPlugin = AmplifyAuthCognito();
Amplify.addPlugins([authPlugin]);
try {
await Amplify.configure(amplifyconfig);
} catch (e) {
print('Error ' + e.toString());
} finally {
// For development let's make sure we are signed out
signOut();
}
}
void signIn(AuthProvider authProvider) async {
try {
await Amplify.Auth.signInWithWebUI(provider: authProvider);
isSignedIn = true;
notifyListeners();
} catch (e) {
throw e;
}
}
void signOut() async {
try {
await Amplify.Auth.signOut();
isSignedIn = false;
notifyListeners();
} on AuthException catch (e) {
print(e.message);
}
}
Future<bool> registerWithEmailAndPassword(
String email, String password) async {
try {
Map<String, String> userAttributes = {
'email': email,
'preferred_username': email,
// additional attributes as needed
};
await Amplify.Auth.signUp(
username: email,
password: password,
options: CognitoSignUpOptions(userAttributes: userAttributes));
return true;
} on AuthException catch (e) {
print(e.message);
throw e;
}
}
signInWithEmailAndPassword(String email, String password) async {
try {
SignInResult res = await Amplify.Auth.signIn(
username: email.trim(),
password: password.trim(),
);
isSignedIn = res.isSignedIn;
} catch (e) {
throw e;
}
}
confirmRegisterWithCode(String email, String code) async {
try {
SignUpResult res = await Amplify.Auth.confirmSignUp(
username: email, confirmationCode: code);
isSignedIn = res.isSignUpComplete;
notifyListeners();
return true;
} on AuthException catch (e) {
throw e;
}
}
}
EmailSignIn: is the provider for the email & password auth. We will use the ChangeNotifier to update the UI, e.g., setting the button texts, error messages...etc.
class EmailSignIn with EmailAndPasswordValidator, ChangeNotifier {
final AppUser appUser;
String email;
String password;
EmailSignInFormType formType;
bool isLoading;
bool submitted;
String code;
EmailSignIn({
@required this.appUser,
this.email = '',
this.password = '',
this.formType = EmailSignInFormType.signIn,
this.isLoading = false,
this.submitted = false,
this.code = '',
});
String get primaryButtonText {
switch (formType) {
case EmailSignInFormType.signIn:
return 'Sign In';
case EmailSignInFormType.register:
return 'Create an account';
case EmailSignInFormType.confirm:
return 'Confirm Sign Up';
}
}
String get secondaryButtonText {
return formType == EmailSignInFormType.signIn
? 'Need an account? Register'
: 'Have an account? Sign in';
}
String get passwordErrorText {
bool showErrorText = submitted && !passwordValidator.isValid(password);
return showErrorText ? invalidPasswordErrorText : null;
}
String get emailErrorText {
bool showErrorText = submitted && !emailValidator.isValid(email);
return showErrorText ? invalidEmailErrorText : null;
}
bool get submitEnabled {
return emailValidator.isValid(email) &&
passwordValidator.isValid(password) &&
!isLoading;
}
void updateEmail(String email) => updateWith(email: email);
void updateCode(String code) => updateWith(code: code);
void updatePassword(String password) => updateWith(password: password);
void toggleFormType() {
updateWith(
submitted: false,
email: '',
password: '',
code: '',
isLoading: false,
formType: this.formType == EmailSignInFormType.signIn
? EmailSignInFormType.register
: EmailSignInFormType.signIn);
}
Future<void> submit() async {
updateWith(submitted: true, isLoading: true);
try {
switch (formType) {
case EmailSignInFormType.signIn:
final user =
await appUser.signInWithEmailAndPassword(email, password);
break;
case EmailSignInFormType.register:
final isSignedUp =
await appUser.registerWithEmailAndPassword(email, password);
if (isSignedUp) {
updateWith(
formType: EmailSignInFormType.confirm,
isLoading: false,
submitted: false);
}
break;
case EmailSignInFormType.confirm:
final user = await appUser.confirmRegisterWithCode(email, code);
}
} catch (e) {
updateWith(isLoading: false);
rethrow;
}
}
void updateWith({
String email,
String password,
EmailSignInFormType formType,
bool isLoading,
bool submitted,
String code,
}) {
this.email = email ?? this.email;
this.password = password ?? this.password;
this.formType = formType ?? this.formType;
this.isLoading = isLoading ?? this.isLoading;
this.submitted = submitted ?? this.submitted;
this.code = code ?? this.code;
notifyListeners();
}
}
For the social sign-in button, we created a StatelessWidget to customize the button based on the Auth provider
class SocialSignInButton extends StatelessWidget {
final Color color;
final String text;
final Color textColor;
final double height;
static const double borderRadius = 4.0;
final VoidCallback onPressed;
final Buttons button;
const SocialSignInButton({
Key key,
@required this.color,
@required this.onPressed,
this.height: 50,
@required this.button,
@required this.text,
@required this.textColor,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return SizedBox(
height: height,
child: ElevatedButton(
onPressed: onPressed,
style: ElevatedButton.styleFrom(
primary: color,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(borderRadius))),
),
child: buildRow(),
),
);
}
Row buildRow() {
switch (button) {
case Buttons.Google:
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Image.asset('images/google-logo.png'),
Text(
text,
style: TextStyle(color: textColor, fontSize: 15),
),
Opacity(opacity: 0.0, child: Image.asset('images/google-logo.png')),
],
);
case Buttons.Email:
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Icon(
Icons.email,
),
Text(
text,
style: TextStyle(color: textColor, fontSize: 15),
),
Opacity(
opacity: 0.0,
child: Icon(
Icons.email,
),
),
],
);
case Buttons.Facebook:
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Image.asset('images/facebook-logo.png'),
Text(
text,
style: TextStyle(color: textColor, fontSize: 15),
),
Opacity(
opacity: 0.0, child: Image.asset('images/facebook-logo.png')),
],
);
case Buttons.Amazon:
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Image.asset('images/amazon-logo.png'),
Text(
text,
style: TextStyle(color: textColor, fontSize: 15),
),
Opacity(opacity: 0.0, child: Image.asset('images/amazon-logo.png')),
],
);
case Buttons.Apple:
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Icon(
FontAwesomeIcons.apple,
color: Colors.white,
),
Text(
text,
style: TextStyle(color: textColor, fontSize: 15),
),
Opacity(
opacity: 0.0,
child: Icon(
FontAwesomeIcons.apple,
),
),
],
);
}
}
}
We used platform-aware dialogs to display errors to the users.
Future<dynamic> showErrorDialog(
BuildContext context, {
@required String title,
@required String content,
String cancelActionText,
@required String defaultActionText,
}) {
if (!Platform.isIOS) {
return showDialog(
barrierDismissible: false,
context: context,
builder: (context) => AlertDialog(
title: Text(title),
content: Text(content),
actions: [
if (cancelActionText != null)
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(cancelActionText),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(defaultActionText),
)
],
),
);
}
return showCupertinoDialog(
context: context,
builder: (context) => CupertinoAlertDialog(
title: Text(title),
content: Text(content),
actions: [
if (cancelActionText != null)
CupertinoDialogAction(
onPressed: () => Navigator.of(context).pop(false),
child: Text(cancelActionText),
),
CupertinoDialogAction(
onPressed: () => Navigator.of(context).pop(true),
child: Text(defaultActionText),
)
],
),
);
}
We build a stateful widget to manage the Email & Password auth. The user can choose to create an account or sign in. we are using a basic validator to make sure the email & password are not empty.
class EmailSignInForm extends StatefulWidget {
final EmailSignIn model;
const EmailSignInForm({Key key, this.model}) : super(key: key);
static Widget create(BuildContext context) {
final appUser = Provider.of<AppUser>(context, listen: false);
return ChangeNotifierProvider<EmailSignIn>(
create: (_) => EmailSignIn(appUser: appUser),
child: Consumer<EmailSignIn>(
builder: (_, model, __) => EmailSignInForm(model: model),
),
);
}
@override
_EmailSignInFormState createState() => _EmailSignInFormState();
}
class _EmailSignInFormState extends State<EmailSignInForm> {
final TextEditingController _emailController = TextEditingController();
final TextEditingController _passwordController = TextEditingController();
final TextEditingController _codeController = TextEditingController();
final FocusNode _codeFocusNode = FocusNode();
final FocusNode _emailFocusNode = FocusNode();
final FocusNode _passwordFocusNode = FocusNode();
EmailSignIn get model => widget.model;
void _emailEditingComplete() {
if (model.emailValidator.isValid(model.email))
FocusScope.of(context).requestFocus(_passwordFocusNode);
else
FocusScope.of(context).requestFocus(_emailFocusNode);
}
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
_codeController.dispose();
_codeFocusNode.dispose();
_emailFocusNode.dispose();
_passwordFocusNode.dispose();
super.dispose();
}
Future<void> _submit() async {
try {
await model.submit();
if (model.submitted) {
Navigator.of(context).pop();
}
} catch (e) {
showErrorDialog(
context,
title: 'Error',
content: e.message,
defaultActionText: 'Ok',
);
}
}
void _toggleFormType() {
model.toggleFormType();
_emailController.clear();
_passwordController.clear();
_codeController.clear();
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: model.formType == EmailSignInFormType.confirm
? _buildConfirmchildren()
: _buildFormchildren(),
),
);
}
List<Widget> _buildFormchildren() {
return [
TextField(
decoration: InputDecoration(
enabled: model.isLoading == false,
labelText: 'Email',
hintText: 'test@test.com',
errorText: model.emailErrorText,
),
controller: _emailController,
autocorrect: false,
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
focusNode: _emailFocusNode,
onEditingComplete: () => _emailEditingComplete(),
onChanged: model.updateEmail,
),
SizedBox(
height: 8.0,
),
TextField(
decoration: InputDecoration(
enabled: model.isLoading == false,
labelText: 'Password',
errorText: model.passwordErrorText,
),
obscureText: true,
controller: _passwordController,
textInputAction: TextInputAction.done,
focusNode: _passwordFocusNode,
onEditingComplete: _submit,
onChanged: model.updatePassword,
),
SizedBox(
height: 8.0,
),
ElevatedButton(
onPressed: model.submitEnabled ? _submit : null,
child: Text(
model.primaryButtonText,
),
),
SizedBox(
height: 8.0,
),
TextButton(
onPressed: !model.isLoading ? _toggleFormType : null,
child: Text(model.secondaryButtonText),
)
];
}
List<Widget> _buildConfirmchildren() {
return [
TextField(
decoration: InputDecoration(
enabled: model.isLoading == false,
labelText: 'Confirmation Code',
hintText: 'The code we sent you',
errorText: model.emailErrorText,
),
controller: _codeController,
autocorrect: false,
keyboardType: TextInputType.text,
textInputAction: TextInputAction.done,
focusNode: _passwordFocusNode,
onEditingComplete: _submit,
onChanged: model.updateCode,
),
SizedBox(
height: 8.0,
),
ElevatedButton(
onPressed: model.submitEnabled ? _submit : null,
child: Text(
model.primaryButtonText,
),
),
SizedBox(
height: 8.0,
),
];
}
}
The LandingPage will watch for the AppUser status to determine displaying the HomePage or the SignInPage
class LandingPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
final appUser = context.watch<AppUser>().isSignedIn;
print(appUser);
return appUser ? HomePage() : SignInPage();
}
}
The SignInPage will present all social auth options besides the Email & Password option.
class SignInPage extends StatelessWidget {
void _signInWithEmail(BuildContext context) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => EmailSignInPage(), fullscreenDialog: true),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Amplify Auth Demo'),
elevation: 10,
),
body: _buildContent(context),
backgroundColor: Colors.grey[200],
);
}
Widget _buildContent(BuildContext context) {
return Padding(
padding: EdgeInsets.all(16),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
child: Text(
'Sign In',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.w600,
),
),
height: 50.0,
),
SizedBox(
height: 48.0,
),
SocialSignInButton(
button: Buttons.Google,
onPressed: () =>
context.read<AppUser>().signIn(AuthProvider.google),
color: Colors.white,
text: 'Sign in with Google',
textColor: Colors.black87,
),
SizedBox(
height: 8.0,
),
SocialSignInButton(
button: Buttons.Facebook,
onPressed: () =>
context.read<AppUser>().signIn(AuthProvider.facebook),
color: Color(0xFF334D92),
text: 'Sign in with Facebook',
textColor: Colors.white,
),
SizedBox(
height: 8.0,
),
SocialSignInButton(
button: Buttons.Amazon,
onPressed: () =>
context.read<AppUser>().signIn(AuthProvider.amazon),
color: Colors.black54,
text: 'Sign in with Amazon',
textColor: Colors.white,
),
SizedBox(
height: 8.0,
),
Text(
'Or',
style: TextStyle(
fontSize: 14,
color: Colors.black87,
),
textAlign: TextAlign.center,
),
SizedBox(
height: 8.0,
),
SocialSignInButton(
button: Buttons.Email,
onPressed: () => _signInWithEmail(context),
color: Colors.deepOrange,
text: 'Sign in with email',
textColor: Colors.white,
),
],
),
);
}
}
Finally, the HomePage will allow the user to sign out and go back to the SignInPage
class HomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Amplify Auth Demo'),
actions: [
TextButton(
onPressed: () => context.read<AppUser>().signOut(),
child: Text(
'Logout',
style: TextStyle(color: Colors.white, fontSize: 14),
),
),
],
),
);
}
}
Check the code here
Reference Authentication Flow with Flutter & AWS Amplify
YouTube video demo here:
This project shows how to implement a full authentication flow in Flutter, using various AWS Amplify sign-in methods.
Project goals
This project shows how to:
- use the various AWS Amplify sign-in methods
- build a robust authentication flow
- use appropriate state management techniques to separate UI, logic and authentication code
- handle errors and present user-friendly error messages
- write production-ready code following best practices
Blog post: https://dev.to/offlineprogrammer/authentication-flow-with-flutter-aws-amplify-41fa
Follow me on Twitter for more tips about #coding, #learning, #technology...etc.
Check my Apps on Google Play
Top comments (3)
Thanks for posting. I appreciate the relative easy with which authentication can be built out. But how would you actually go and test an app which has Amplify as a root dependency?
Thanks for your comment. Are you asking about Mocking? if yes then check here docs.amplify.aws/cli/usage/mock/ let me know of any questions
it is such an awesome content, but could you please make a video of setting up google auth client id and configuring amplify auth, please