Saturday, June 22, 2024
Google search engine
HomeImplementing Auth using AWS Amplify in Flutter

Implementing Auth using AWS Amplify in Flutter

Learn How To Implement Email and Social Auth Using AWS Amplify Studio In Flutter

In this article, we are going to use AWS Amplify for Authentication. We will auth via email and use provider Goggle for Social Auth for a demo app.

If you’re looking for the best flutter app development company for your mobile application then feel free to contact us at — support@flutterdevs.com.


Table of Content

Authentication

AWS Amplify

Pre-requisites

AWS Setup

Auth with Email and Password

Auth with Social Provider(Google)

Android Platform Setup

Demo Setup

Demo Video

Conclusion

Github

Reference


Authentication

Authentication helps personalize the experience with user-specific information when we develop an application. It’s also a crucial component for privacy and security for the user. Primarily we use Firebase or Rest API for auth in Flutter. In this article, we will use AWS Amplify for authentication.


AWS Amplify

We are always looking for genuine and secure cloud-based authentication systems and what features they offer.

AWS Amplify is a complete solution that lets frontend web and mobile developers easily build, ship, and host full-stack applications on AWS, with the flexibility to leverage the breadth of AWS services as use cases evolve. No cloud expertise is needed.

AWS Amplify looks similar to all the other cloud-based authentication systems. It provides pre-built sign-up, sign-in, and forgot password authentication for user management.

It also provides Multi-Factor Authentication(MFA) to our app. It could handy when we are developing defense or national security-related apps. Amplify also supports login with a social provider such as Facebook, Apple, Google Sign-In, or Login With Amazon and provides fine-grained access control to mobile and web applications.

It gives us two options the Amplify Studio or Amplify CLI to configure an app backend and use the Amplify libraries and UI components to connect our app to our backend.

For this article, to keep things simple, I will use Amplify Studio to configure the app backend and setState() to state management.

We auth the app by two-way

  • Email and Password
  • Social Provider(Google)

Pre-requisites

Before starting make sure of these steps.

Read the below link for more details

https://docs.amplify.aws/lib/project-setup/prereq/q/platform/flutter/

Note:

  • Import Amplify packages into your project inside the pubspec.yaml file:
amplify_core: '<1.0.0'
amplify_auth_cognito: '<1.0.0'
  • To make sure you have installed the proper amplify cli version run the below command:
amplify --version

it’s time to connect to the AWS cloud and for that, we need to initialize the amplify, use the following command to initialize the Amplify:

amplify init

AWS Setup:-

First of all, create an app on Amplify Studio.

click on Launch Studio

Connect your app to this backend environment using the Amplify CLI by running the following command from your project root folder.amplify pull –appId d1chk4cmm2hbfu –envName staging

Inside the Admin UI, Click ‘Authentication’ to get the screen below.

Default ‘Email’ pre-selected.I removed it.

The Authentication screen has 2 panels, the 1st for configuring Sign-in, and the 2nd for configuring sign-up.

First, we focus on Amplify Studio


Auth with Email and Password:

Let’s configure the login mechanism. Select Email from the dropdown menu.

Do the same thing for the signup mechanism.

You can also set up a password policy for sign-up and formatting verification messages. I removed all the password checks and set the character length at 8 to keep things easy.

Click the ‘Deploy’ button and then ‘Confirm Deployment’ to apply signup and login mechanisms.

run the below command again in the terminal to get the latest configuration.amplify pull –appId d1chk4cmm2hbfu –envName staging


Auth with Social Provider(Google):

Let’s configure the login mechanism. Select Any Social provider that you want to use, I selected Google.

Let’s follow some initial steps to set up for web client Id and Secret.

  • Go to Google developer console.
  • Click NEW PROJECT
  • Type in a project name and click CREATE
  • Once the project is created, from the left Navigation menu, select APIs & Services, then select Credentials.
  • Click CONFIGURE CONSENT SCREEN and Click CREATE
  • Type in App Information and Developer contact information which are required fields and click SAVE AND CONTINUE three times (OAuth consent screen -> Scopes -> Test Users) to finish setting up the consent screen.
  • Back to the Credentials tab, Create your OAuth2.0 credentials by choosing OAuth client ID from the Create credentials drop-down list.
  • Choose Web application as the Application type and name your OAuth Client then Click Create.
  • Take note of Your client ID and Your Client Secret. You will need them in Amplify Studio and Choose OK.
  • Select the client you created in the first step and click the edit button.
  • Copy your user pool domain which displays on Amplify Studio
User pool domain
  • Paste into Authorized Javascript origins.
  • Paste your user pool domain with the /oauth2/idpresponse endpoint into Authorized Redirect URIs and click save.
  • Paste Your client ID and Your Client Secret.
  • Type ‘myapp://’ in Sign-in & sign-out redirect URLs section. If You want or have another redirect URL for sign-out then click Separate Sign-in and Sign-out URLs and type other URLs for sign-out.
  • Click the ‘Deploy’ button and then ‘Confirm Deployment’ to apply signup and login mechanisms.
  • Run the below command again in the terminal to get the latest configuration.
amplify pull --appId d1chk4cmm2hbfu --envName staging

Android Platform Setup:

Add the following activity and queries tag to the AndroidManifest.xml file in your app’s android/app/src/main directory, replacing myapp with your redirect URI prefix if necessary.

<queries>
<intent>
<action android:name=
"android.support.customtabs.action.CustomTabsService" />
</intent>
</queries>
<application>
...
<activity
android:name="com.amplifyframework.auth.cognito.activities.HostedUIRedirectActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="myapp" />
</intent-filter>
</activity>
...
</application>

For more details use the below link.

https://docs.amplify.aws/lib/auth/social/q/platform/flutter/


Demo Setup:

Before coming to the UI part of the App. First, implement AWS API calls for the sign-in and sign-up.

I create AWSAuthRepo and call AWS Api.

class AWSAuthRepo {
Future<bool> getCurrentUser({required bool signedIn}) async {
final user = await Amplify.Auth.getCurrentUser();
if(user != null ){
signedIn = true;
}
else {
signedIn = false;
}
print('$user');
return signedIn;
}

Future<void> signUp(String email, String password, context) async{
try {
final CognitoSignUpOptions options = CognitoSignUpOptions();
await Amplify.Auth.signUp(username: email, password: password, options: options);
} on AmplifyException catch(e){
CommonUtils.showError(context, e.toString());
}
}

Future<void> signUpConfirmation(String email, String confirmationCode ) async{
try {
await Amplify.Auth.confirmSignUp(username: email, confirmationCode: confirmationCode);
} on Exception{
rethrow;
}
}

Future<void> signIn(String email, String password, context) async {
try {
await Amplify.Auth.signIn(username: email, password: password,);
await Amplify.Auth.getCurrentUser();
}
on AmplifyException catch(e){
CommonUtils.showError(context, e.toString());
}
}

Future<void> signOut() async {
try{
await Amplify.Auth.signOut();
}
on Exception{
rethrow;
}
}

Future<void> signInWithWebUI() async{
try {
final result =
await Amplify.Auth.signInWithWebUI(provider: AuthProvider.google);
print('Result: $result');
} on AmplifyException catch (e){
print(e.message);
}
}
}

To initialize amplify, call the following method inside the initState() method in the main file.

@override
void initState() {
super.initState();
_configureAmplify();
}

void _configureAmplify() async {
try {
await Amplify.addPlugin(AmplifyAuthCognito());
await Amplify.configure(amplifyconfig);
setState(() =>
_isAmplifyConfigured = true
);
print('Successfully configured');
} on Exception catch (e) {
print('Error configuring Amplify: $e');
}
}

Now let’s run the app to see if, is everything working fine

Note: If you do a hot restart, you will get the following exception.

PlatformException(AmplifyException, User-Agent was already configured successfully., {cause: null, recoverySuggestion: User-Agent is configured internally during Amplify configuration. This method should not be called externally.}, null)

Don’t worry about it because the library did not detect a hot restart. In future releases, they will fix this bug.

Now come to the UI part of Auth flow. I create four screens.

Sign-In Screen

class LogInPage extends StatefulWidget {
const LogInPage({Key? key, required this.title}) : super(key: key);
final String title;
@override
State<LogInPage> createState() => _LogInPageState();
}

class _LogInPageState extends State<LogInPage> {
AWSAuthRepo auth = AWSAuthRepo();
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
final TextEditingController emailTextController = TextEditingController();
final TextEditingController passwordTextController = TextEditingController();
bool validate = false;
bool configuredAmplify = false;

@override
void initState() {
// TODO: implement initState
super.initState();
}
void clearText(){
emailTextController.clear();
passwordTextController.clear();
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: (){
FocusScope.of(context).unfocus();
},
child: Scaffold(
backgroundColor: AppColors.lightBlueGrey,
appBar: AppBar(
automaticallyImplyLeading: false,
centerTitle: true,
title: Text(widget.title),
backgroundColor: AppColors.mediumTeal,
),
body:
SingleChildScrollView(
child: Form(
key: _formKey,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 32.0),
child: Column(
children: [
const SizedBox(height: 20,),
Text(AppString.loginPageText,
style: AppTextStyle.text1),
const SizedBox(height: 40,),
SizedBox(
width: MediaQuery.of(context).size.width,
height: 60,
child:
GrayGetTextField(
hint: 'User Email',
obscure: false,
controller: emailTextController,
isVisible: false,
onValidate: CommonUtils.isValidateEmail,
valueDidChange: (String? value) {
if(validate){
_formKey.currentState!.validate();}
}, inputType: TextInputType.emailAddress,)
),
const SizedBox(height: 20,),
SizedBox(
width: MediaQuery.of(context).size.width,
height: 60,
child: GrayGetTextField(
hint: 'Password',
obscure: true,
controller: passwordTextController,
isVisible: true,
onValidate: CommonUtils.isPasswordValid,
valueDidChange: (String? value) { if(validate){
_formKey.currentState!.validate();
} }, inputType: TextInputType.text,)
),
const SizedBox(height: 40,),
GestureDetector(
onTap: () async {
await auth.signIn(emailTextController.text.trim(), passwordTextController.text.trim(),context);
if(await auth.getCurrentUser(signedIn: true))
{
Navigator.pushNamed(context, Routes.welcomeScreen);
}

},
child: Container(
width: MediaQuery.of(context).size.width,
margin: const EdgeInsets.symmetric(horizontal: 18),
height: 53,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(30),
color: AppColors.mediumBlueGrey,
border: Border.all(color: AppColors.mediumTeal,width: 2)
),
child: Center(
child: Text(AppString.loginText,
style: AppTextStyle.text4,),
),
),
),
const SizedBox(height: 20),
Text( AppString.orWithText,
style: AppTextStyle.text3),
const SizedBox(height: 20),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 18),
child: SizedBox(
height: 53,
child: TextButton(
onPressed: () async{
await auth.signInWithWebUI();
if(await auth.getCurrentUser(signedIn: true))
{
Navigator.pushNamed(context, Routes.welcomeScreen);
}
if (!mounted) return;
},
style: ButtonStyle(
side: MaterialStateProperty.all(
const BorderSide(color: AppColors.mediumTeal, width: 2)),
backgroundColor: MaterialStateProperty.all(AppColors.mediumBlueGrey),
foregroundColor: MaterialStateProperty.all(AppColors.mediumBlueGrey),
minimumSize: MaterialStateProperty.all(
Size(MediaQuery.of(context).size.width, 58)),
shape: MaterialStateProperty.all(RoundedRectangleBorder(
borderRadius: BorderRadius.circular(40.0))),
padding: MaterialStateProperty.all(
const EdgeInsets.symmetric(vertical: 14),
),
textStyle: MaterialStateProperty.all(AppTextStyle.text2)),
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Image.asset(
AppIcons.googlePng,
scale: 23,
),
const SizedBox(
width: 10,
),
Text(AppString.signInWithGoogleText,
style: AppTextStyle.text4,),
],
),
),
),
),
const SizedBox(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(AppString.doesNotHaveText),
TextButton(
onPressed: (){
Navigator.pushNamed(context, Routes.signUpScreen);
},
child: const Text(AppString.signUpText,
style: TextStyle(color: AppColors.mediumTeal),)
),
],
),
],
),
),
),
)
)
);
}
}

When we run the application, we ought to get the screen’s output like the underneath screen capture.

Output

Sign-Up Screen

class SignUpPage extends StatefulWidget {
const SignUpPage({Key? key, required this.title}) : super(key: key);
final String title;
@override
State<SignUpPage> createState() => _SignUpPageState();
}

class _SignUpPageState extends State<SignUpPage> {
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
final TextEditingController emailTextController = TextEditingController();
final TextEditingController passwordTextController = TextEditingController();
final TextEditingController cPasswordTextController = TextEditingController();
bool validate = false;
AWSAuthRepo auth = AWSAuthRepo();

void clearText()
{
emailTextController.clear();
passwordTextController.clear();
cPasswordTextController.clear();
}

@override
void initState() {
// TODO: implement initState
super.initState();

}

@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: (){
FocusScope.of(context).unfocus();
},
child: Scaffold(
backgroundColor:AppColors.lightBlueGrey,
appBar: AppBar(
automaticallyImplyLeading: false,
centerTitle: true,
title: Text(widget.title),
backgroundColor: AppColors.mediumTeal,
),
body: SingleChildScrollView(
child: Form(
key: _formKey,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 32.0),
child: Column(
children: [
const SizedBox(height: 20,),
Text(AppString.signUpPageText,
style: AppTextStyle.text1),
const SizedBox(height: 40,),
SizedBox(
width: MediaQuery.of(context).size.width,
height: 60,
child: Center(
child:
GrayGetTextField(
hint: 'User Email',
obscure: false,
controller: emailTextController,
isVisible: false,
onValidate: CommonUtils.isValidateEmail,
valueDidChange: (String? value) {
if(validate){
_formKey.currentState!.validate();}
}, inputType: TextInputType.emailAddress,)
),
),
const SizedBox(height: 20,),
SizedBox(
width: MediaQuery.of(context).size.width,
height: 60,
child:Center(
child:
GrayGetTextField(
hint: 'Password',
obscure: true,
controller: passwordTextController,
isVisible: true,
onValidate: CommonUtils.isPasswordValid,
valueDidChange: (String? value) {
if(validate){
_formKey.currentState!.validate();}
}, inputType: TextInputType.text,)
),
),
const SizedBox(height: 20,),
SizedBox(
width: MediaQuery.of(context).size.width,
height: 51,
child:GrayGetTextField(
hint: 'Confirm Password',
obscure: true,
controller: cPasswordTextController,
isVisible: true,
onValidate: CommonUtils.isPasswordValid,
valueDidChange: (String? value) {
if(validate){
_formKey.currentState!.validate();}
}, inputType: TextInputType.text,),
),
const SizedBox(height: 40,),
GestureDetector(
onTap: () async {
if(_formKey.currentState?.validate() ?? false) {
if (_formKey.currentState!.validate() &&
cPasswordTextController.text !=
passwordTextController.text) {
CommonUtils.showSnackBar(context,
'Password not match');
return;
}
await auth.signUp(emailTextController.text.trim(),
passwordTextController.text.trim(), context);
if(await auth.getCurrentUser(signedIn: true))
{
Navigator.pushNamed(context, Routes.welcomeScreen);
}
if (!mounted) return;

}
},
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 18),
width: MediaQuery.of(context).size.width,
height: 53,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(30),
color: AppColors.mediumBlueGrey,
border: Border.all(color: AppColors.mediumTeal,width: 2)
),
child: Center(
child: Text(AppString.signUpText,
style: AppTextStyle.text4,)
),
),
),
const SizedBox(height: 20,),
Text( AppString.orWithText,
style: AppTextStyle.text3),
const SizedBox(height: 20),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 18.0),
child: SizedBox(
height: 53,
child: TextButton(
onPressed: ()async{
await auth.signInWithWebUI();
if(await auth.getCurrentUser(signedIn: true))
{
Navigator.pushNamed(context, Routes.welcomeScreen);
}
if (!mounted) return;
},
style: ButtonStyle(
side: MaterialStateProperty.all(
const BorderSide(color: AppColors.mediumTeal, width: 2)),
backgroundColor: MaterialStateProperty.all(AppColors.mediumBlueGrey),
foregroundColor: MaterialStateProperty.all(AppColors.mediumBlueGrey),
minimumSize: MaterialStateProperty.all(
Size(MediaQuery.of(context).size.width, 58)),
shape: MaterialStateProperty.all(RoundedRectangleBorder(
borderRadius: BorderRadius.circular(40.0))),
padding: MaterialStateProperty.all(
const EdgeInsets.symmetric(vertical: 14),
),
textStyle: MaterialStateProperty.all(AppTextStyle.text2)),
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Image.asset(
AppIcons.googlePng,
scale: 23,
),
const SizedBox(
width: 10,
),
Text(AppString.signUpWithGoogleText,
style: AppTextStyle.text4,
),
],
),
),
),
),
const SizedBox( height: 20,),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(AppString.haveAccountText),
TextButton(
onPressed: (){
Navigator.pushNamed(context, Routes.signInScreen);
},
child: const Text(AppString.loginText,
style: TextStyle(color: AppColors.mediumTeal),)
),
],
),
],
),
),
),
),
),
);
}
}

When we run the application, we ought to get the screen’s output like the underneath screen capture.

Confirmation Page

class ConfirmationSignUP extends StatefulWidget {
const ConfirmationSignUP({Key? key}) : super(key: key);

@override
State<ConfirmationSignUP> createState() => _ConfirmationSignUPState();
}

class _ConfirmationSignUPState extends State<ConfirmationSignUP> {
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
final TextEditingController emailTextController = TextEditingController();
final TextEditingController confirmationTextController = TextEditingController();
bool validate = false;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: (){
FocusScope.of(context).unfocus();
},
child: Scaffold(
backgroundColor: AppColors.lightBlueGrey,
appBar: AppBar(
automaticallyImplyLeading: false,
centerTitle: true,
title: Text("Confirmation Page"),
backgroundColor: AppColors.mediumTeal,
),
body:
SingleChildScrollView(
child: Center(
child: Form(
key: _formKey,
child: Column(
children: [
const SizedBox(height: 40,),
Text(AppString.confirmSignUpText,
style: AppTextStyle.text1),
const SizedBox(height: 70,),
Container(
width: MediaQuery.of(context).size.width,
padding: const EdgeInsets.symmetric(horizontal: 32),
height: 60,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
//color: AppColors.mediumBlueGrey
),
child:
GrayGetTextField(
hint: 'User Email',
obscure: false,
controller: emailTextController,
isVisible: false,
onValidate: CommonUtils.isValidateEmail,
valueDidChange: (String? value) {
if(validate){
_formKey.currentState!.validate();}
}, inputType: TextInputType.emailAddress,)
),
const SizedBox(height: 30,),
Container(
width: MediaQuery.of(context).size.width,
padding: const EdgeInsets.symmetric(horizontal: 32),
height: 60,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
//color: AppColors.mediumBlueGrey
),
child:
TextField(
controller: confirmationTextController,
decoration: const InputDecoration(
fillColor: AppColors.mediumBlueGrey,
filled: true,
border: InputBorder.none,
labelStyle: TextStyle(color: AppColors.mediumTeal),
hintText: 'Confirmation Code',
isDense: true,
contentPadding:
EdgeInsets.symmetric(horizontal: 12, vertical: 15),
),
)
),
SizedBox(height: MediaQuery.of(context).size.height*0.3,),
GestureDetector(
onTap: () async {
if (_formKey.currentState?.validate() ?? false)
{
if (confirmationTextController.text.isEmpty) {
CommonUtils.showSnackBar(context, 'Please Enter Confirmation Code');
return;}
else {
await AWSAuthRepo().signUpConfirmation(emailTextController.text.trim(), confirmationTextController.text.trim());
Navigator.pushNamed(context, Routes.welcomeScreen);
}
}
},
child: Container(
width: MediaQuery.of(context).size.width / 1.6,
height: 60,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(30),
color: AppColors.mediumTeal
),
child: Center(
child: Text(AppString.submitText,
style: AppTextStyle.text2,),
),
),
),
const SizedBox(height: 20),
],
),
),
),
)
),
);;
}
}

When we run the application, we ought to get the screen’s output like the underneath screen capture.

Home Screen

class Home extends StatefulWidget {

const Home({Key? key}) : super(key: key);
@override
State<Home> createState() => _HomeState();
}

class _HomeState extends State<Home> {
AWSAuthRepo auth = AWSAuthRepo();
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
final TextEditingController nameTextController = TextEditingController();
final TextEditingController phoneNoTextController = TextEditingController();

@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.lightBlueGrey,
bottomNavigationBar: BottomAppBar(
color: AppColors.lightBlueGrey,
child: GestureDetector(
onTap: () async {
auth.signOut();
Navigator.pushNamed(context, Routes.signInScreen);
},
child: Container(
key: const Key("signOutButton"),
margin: const EdgeInsets.symmetric(horizontal: 32),
width: MediaQuery.of(context).size.width,
height: 60,
decoration: BoxDecoration(borderRadius: BorderRadius.circular(30),
color: AppColors.mediumTeal),
child: Center(
child: Text(AppString.signOutText,
style: AppTextStyle.text2,),
),),


Demo Video

Sign-up with Email and Password

Auth with Social Provider(Google)

Conclusion

In this article, We learn auth user management using AWS Amplify Studio via Email and Password, and Social Login with Gmail.

There are some useful run commands.

amplify configure
amplify init
amplify auth
amplify add auth
amplify push
amplify status

To know more details about these use the below link

https://docs.amplify.aws/cli/start/workflows/#optional-update-projects-using-latest-amplify-cli

AWS Amplify also provides rebuild SignIn and SignUp UI, you can use it.

amplify_authenticator | Flutter Package
The Amplify Flutter Authenticator simplifies the process of authenticating users by providing a fully-customizable flow…pub.dev


GitHub

You can get the full code on the below link

GitHub — RitutoshAeologic/aws_demo
A new Flutter project. This project is a starting point for a Flutter application. A few resources to get you started…github.com


❤ ❤ Thanks for reading this article ❤❤

If I got something wrong? Let me know in the comments. I would love to improve.

Clap 👏 If this article helps you.


Reference :

https://docs.amplify.aws/lib/auth/getting-started/q/platform/flutter/

AWS Amplify Features | Front-End Web & Mobile | Amazon Web Services
With Amplify CLI and Libraries, add features such as auth, storage, data, AI/ML, and analytics to your app. Deploy web…aws.amazon.com


Feel free to connect with us:
And read more articles from FlutterDevs.com.

FlutterDevs team of Flutter developers to build high-quality and functionally-rich apps. Hire a flutter developer for your cross-platform Flutter mobile app project on an hourly or full-time basis as per your requirement! For any flutter-related queries, you can connect with us on Facebook, GitHub, Twitter, and LinkedIn.

We welcome feedback and hope that you share what you’re working on using #FlutterDevs. We truly enjoy seeing how you use Flutter to build beautiful, interactive web experiences.

RELATED ARTICLES

LEAVE A REPLY

Please enter your comment!
Please enter your name here

- Advertisment -
Google search engine

Most Popular

PDF with Flutter

Rest API in Flutter

Charts in Flutter

Spinkit In Flutter

Recent Comments