Hey!
Recently, I had a chat with a friend about mobile development and Flutter, and the more I learned about it, the more interested I became. I decided to try it for myself and document my journey of creating my first app through a blog post. (spoiler: I'm hooked...)
The app that I will be building is a notetaking app, and it will evolve visually and functionally with each update.
Setup ⚙️
Setting up Flutter was both similar and different from other technologies I have used before. Although the syntax was different, it followed the same routine.
Since I am using Firebase as a backend for this project, installation and setup instructions are included.
Setting up Flutter and Firebase
To set up Flutter and Firebase, I followed these instructions:
Flutter - Get Started - MacOS
Flutter/Firebase Setup
I used Flutter Doctor
in between installations to ensure everything was okay before moving on.
Here are two errors I encountered during the Flutter setup and their solutions:
(Note: for the second error, I copied the jbr
folder, renamed it to jre
, and the error was resolved.)
-
Creating the project.
flutter create --org com.yourdomainname mynotes
-
Adding Firebase. In the newly created project folder, use the following command in the terminal:
flutter pub add firebase_core
Once everything is installed, the pubspec.yaml should include the following dependencies:
dependencies:
flutter:
sdk: flutter
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.2
firebase_core: ^2.6.1
firebase_auth: ^4.2.8
cloud_firestore: ^4.4.2
firebase_analytics: ^10.1.3
I customized my project to support Android and iOS. However, for this project, I will only be working with Android as I do not have an Apple Developer Account.
Now that setup is complete, let's move on to the fun stuff!
Home
For the Home
view, I will be working directly from main.dart
, as this is where MaterialApp
is by default, and where the app initializes.
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
runApp(MaterialApp(
title: 'Home',
theme: ThemeData(
primarySwatch: Colors.orange,
),
home: const LoginView(),
routes: {
loginRoute: (context) => const LoginView(),
registerRoute: (context) => const RegisterView(),
notesRoute: (context) => const NotesView(),
verifyEmailRoute: (context) => const VerifyEmailView()
},
));
}
In our main()
function, I initialized Firebase to be able to manage authentication later on. I made it async
to make it wait for the response from Firebase.initializeApp()
before moving on. In the MaterialApp
, I modified the color slightly to Orange and set LoginView()
to be the entry-point.
To navigate between views, I added routes that can be easily called later. The routes are imported via lib/views/constants/routes.dart
that I created.
const loginRoute = '/login/';
const registerRoute = '/register/';
const notesRoute = '/notes/';
const verifyEmailRoute = '/verifyEmail/';
Now, let's go through the code for HomePage
.
class HomePage extends StatelessWidget {
const HomePage({super.key});
@override
Widget build(BuildContext context) {
return StreamBuilder<User?>(
stream: FirebaseAuth.instance.authStateChanges(),
builder: (context, snapshot) {
switch (snapshot.connectionState) {
case ConnectionState.done:
final user = FirebaseAuth.instance.currentUser;
if (user != null) {
if (user.emailVerified) {
return const NotesView();
} else {
return const VerifyEmailView();
}
} else {
return const LoginView();
}
default:
return const CircularProgressIndicator();
}
},
);
}
}
In here we do a bunch of things.
The HomePage
is actually just checking the authentication state of the user. If the user is authenticated, the NotesView()
is displayed, otherwise the LoginView()
is displayed.
To check the user's authentication state, we're using FirebaseAuth.instance.authStateChanges()
which returns a Stream<User?>
. If the connection is done, we can get the current user with FirebaseAuth.instance.currentUser
, if there is one. If not, we'll show the LoginView()
.
So to round up the logic:
- There is a user =>
NotesView();
- User is Authenticated and Verified =>
NotesView()
; - Any other situation =>
LoginView()
;
The CircularProgressIndicator() is just displayed while waiting for the connection to be done.
That's all for HomePage!
Registration
To be able to login, we first need to register - right? Let's implement this!
To implement the registration view, we create a new file called register_view.dart
in the lib/views
folder. This file contains a stateful widget class called RegisterView
.
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/material.dart';
import 'package:mynotes/utilities/show_error_dialog.dart';
import 'package:mynotes/views/constants/routes.dart';
class RegisterView extends StatefulWidget {
const RegisterView({super.key});
@override
State<RegisterView> createState() => _RegisterViewState();
}
class _RegisterViewState extends State<RegisterView> {
late final TextEditingController _email;
late final TextEditingController _password;
@override
void initState() {
_email = TextEditingController();
_password = TextEditingController();
super.initState();
}
@override
void dispose() {
_email.dispose();
_password.dispose();
super.dispose();
}
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Register!'),
),
body: Column(
children: [
TextField(
controller: _email,
enableSuggestions: false,
autocorrect: false,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(hintText: 'Enter your email')),
TextField(
controller: _password,
obscureText: true,
enableSuggestions: false,
autocorrect: false,
decoration: const InputDecoration(hintText: 'Enter your password'),
),
TextButton(
onPressed: () async {
final email = _email.text;
final password = _password.text;
try {
final userCredential =
await FirebaseAuth.instance.createUserWithEmailAndPassword(
email: email,
password: password,
);
Navigator.of(context).pushNamed(verifyEmailRoute);
final user = FirebaseAuth.instance.currentUser;
await user?.sendEmailVerification();
} on FirebaseAuthException catch (e) {
showErrorDialog(context, e.code.toString());
} catch (e) {
showErrorDialog(context, e.toString());
}
},
child: const Text('Register'),
),
TextButton(
onPressed: () {
Navigator.of(context)
.pushNamedAndRemoveUntil(loginRoute, (route) => false);
},
child: const Text('Already registered? Login here.'))
],
),
);
}
}
TextEditingController
The widget has two TextEditingController
objects named _email and _password. These controllers are used to keep what the user enters in the TextField
widgets.
initState
This is called when the widget first gets created. It basically just initializes _email
and _password
with empty strings.
dispose
This one was a bit tricky to understand for me at first. But this basically disposes/removes the _email
and _password
controllers once our widgets are removed from the tree(view changes).
build
and Scaffold
Here we're building the actual container/skeleton of for how the view should look. The Scaffold
is basically the frame holding everything, and then we add other Widgets such as AppBar
, TextField
, TextButton
etc.
TextField
We use these widgets to get the input from the user. These fields are then connected to each of controllers created earlier: _email
and _password
TextButton
The first TextButton widget is the registration button. When pressed, it attempts to create a new user account using the entered email and password.
- Successful creation => verifyEmailView();
- Unsuccessful creation => Displays an error popup with the error message.
Here's the entire process of how it looks in Firebase's Console as well.
That's all for Register, now lets check the LoginView!
Login
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/material.dart';
import 'dart:developer' as devtools show log;
import 'package:mynotes/views/constants/routes.dart';
import '../utilities/show_error_dialog.dart';
class LoginView extends StatefulWidget {
const LoginView({super.key});
@override
State<LoginView> createState() => _LoginViewState();
}
class _LoginViewState extends State<LoginView> {
late final TextEditingController _email;
late final TextEditingController _password;
@override
void initState() {
_email = TextEditingController();
_password = TextEditingController();
super.initState();
}
@override
void dispose() {
_email.dispose();
_password.dispose();
super.dispose();
}
Widget build(BuildContext context) {
return Scaffold(
resizeToAvoidBottomInset: false,
appBar: AppBar(
title: const Text('Login 🔑'),
),
body: Container(
decoration: const BoxDecoration(
image: DecorationImage(
image: NetworkImage(
'https://static.vecteezy.com/system/resources/previews/009/376/704/original/abstract-dark-orange-blob-element-free-png.png'),
fit: BoxFit.contain,
alignment: Alignment(1, 1))),
child: Column(
children: [
TextField(
controller: _email,
enableSuggestions: false,
autocorrect: false,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
hintText: 'Enter your email',
),
),
TextField(
controller: _password,
obscureText: true,
enableSuggestions: false,
autocorrect: false,
decoration: const InputDecoration(
hintText: 'Enter your password',
),
),
TextButton(
onPressed: () async {
final email = _email.text;
final password = _password.text;
try {
final userCredential =
await FirebaseAuth.instance.signInWithEmailAndPassword(
email: email,
password: password,
);
final user = FirebaseAuth.instance.currentUser;
if (user?.emailVerified ?? false) {
Navigator.of(context).pushNamedAndRemoveUntil(
notesRoute,
(route) => false,
);
} else {
Navigator.of(context).pushNamedAndRemoveUntil(
verifyEmailRoute, (route) => false);
}
devtools.log(userCredential.toString());
} on FirebaseAuthException catch (e) {
await showErrorDialog(context, (e.code.toString()));
} catch (e) {
await showErrorDialog(context, e.toString());
}
},
child: const Text('Login'),
),
TextButton(
onPressed: () {
Navigator.of(context)
.pushNamedAndRemoveUntil(registerRoute, (route) => false);
},
child: const Text('Not registered? Click here!'))
],
),
),
);
}
}
Similar to the RegisterView()
we have controllers for our email_
and _password
so I won't be going through these again.
The important thing on this page is how we handle the Login itself with the FirebaseAuth
.
First, we assign the result of FirebaseAuth.instance.signInWithEmailAndPassword()
to a object called UserCredential
.
We then run some logic:
- User is verified =>
notesView()
- User not verified =>
verifyEmailView()
If there are any errors, it will show an error popup similar to the one on the RegisterView.
The last button directly navigates to the RegisterView()
.
That's about it for the Login. But how about Logging out?
NotesView & Logout
This one was a bit tricky to understand at first, as you generally want a logout option to be easily accessible. For now I decided to try adding actions in the AppBar on the notesView and use that for logging out.
enum MenuAction { logout }
class NotesView extends StatefulWidget {
const NotesView({super.key});
@override
State<NotesView> createState() => _NotesViewState();
}
class _NotesViewState extends State<NotesView> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('My Notes 📝'),
actions: [
PopupMenuButton<MenuAction>(
onSelected: (value) async {
switch (value) {
case MenuAction.logout:
final shouldLogout = await showLogOutDialog(context);
if (shouldLogout) {
await FirebaseAuth.instance.signOut();
Navigator.of(context)
.pushNamedAndRemoveUntil(loginRoute, (_) => false);
}
}
},
itemBuilder: (context) {
return const [
PopupMenuItem<MenuAction>(
value: MenuAction.logout, child: Text('Logout')),
PopupMenuItem<MenuAction>(child: Text('Second Page'))
];
},
)
],
),
body: const Text('Hello NotesView'),
);
}
}
Future<bool> showLogOutDialog(BuildContext context) {
return showDialog<bool>(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('Sign Out Alert'),
content: const Text('Are you sure you want to sign out?'),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop(false);
},
child: const Text('Cancel')),
TextButton(
onPressed: () {
Navigator.of(context).pop(true);
},
child: const Text('Logout'))
]);
},
).then((value) => value ?? false);
}
First we define an enum MenuAction
which has the value logout
. This is to define the options in our PopupMenu
in the AppBar
.
In the AppBar
of NotesView
we add the Actions widget that contains a PopupMenuButton
. The PopupMenuButton
is created with a list of PopupMenuItem
widgets. In this case, there are two items, but only one with the value MenuAction.logout
, which obviously is for logging out.
onSelected
As it's name, when the user selects an item in the menu. In this case when the user selects the MenuAction.logout
we will show an message confirming that the user really want to log out.
If they confirm, we call FirebaseAuth.instance.sigOut()
to log out the user and then push them over to the LoginView
again.
showLogOutDialog
As mentioned earlier, this shows a popup that requests the user confirm that they want to logout. The function returns a Future<bool>
. true
if they confirm, or false
if they cancel and want to stay logged in.
But why is the showLogOutDialog
a "Future
"?
The easiest way I can explain, and how I understand it is:
a value that will be available at some point in the future, but not necessarily immediately.
In our case, this would be when/if the user clicks Confirm/Cancel in the popout.
And as Futures
are non-blocking, it also lets the app keep running in the background while Future is waiting for it's value. Basically a way to perform async operations and make the app responsive while loading data.
That's about it!
Last thing I will go through is the showErrorDialog(
) which I've been using throughout the app.
showErrorDialog
lib/utilities/show_error_dialog.dart
import 'package:flutter/material.dart';
Future<void> showErrorDialog(
BuildContext context,
String text,
) {
return showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('An error occured'),
content: Text(text),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: const Text('Ok'),
),
],
);
},
);
}
Just as the logout popup, this is also a Future object as it is waiting for the value of errors that might happen.
Overall, this function can be called whenever there is an error that needs to be displayed to the user in a pop-up dialog box.
And that's it for Part 1. Phew! That was quite a lot. It's been insanely fun building with Flutter so far, and I'm definitely going to dig a lot deeper into it and continue on this app. Hopefully Part 2 will be up next week 🤞
Here is the current final result.
Top comments (0)