Awesome
<div align="center">Supabase
+ Flutter
Demo
A showcase of using Supabase
with Flutter
in a simple Flutter
Todo List App
with authentication
Why? 🤷
This SPIKE project is meant for us
to evaluate the possibility of implementing authentication
with Supabase
whilst developing
our Flutter
app.
What? 💭
Supabase
is a Firebase alternative,
which allows rapid development of a project
with a Postgres database, authentication
and several other features built-in.
We are going to be using Supabase
with Flutter
to create
a simple authentication scenario
in a Todo app.
Who? 👤
This quick demo is aimed at people in the @dwyl team
who need to understand how Supabase
can be integrated
with Flutter
to create an authentication flow.
How? 👩💻
Prerequisites? 📝
This Demo
builds upon the foundational work done
in our Flutter
Todo List Tutorial:
dwyl/flutter-todo-list-tutorial
it is
assumed knowledge.
If you haven't been through it, we suggest taking a few minutes to get up-to-speed.
If this is your first time using Flutter
,
we highly suggest you check out
dwyl/learn-flutter
for a primer on how to get up-and-running with Flutter
.
We recommend you access the following link and download the project files to get the exact same ones we'll be using in this tutorial.
https://github.com/dwyl/flutter-todo-list-tutorial/tree/6b8ac188fa4752c09c22af8915ee51e6e7464098
The reason we are accessing this link it's because it belongs to a PR that has not been merged and it's at a point of the project where no external API is used. Therefore, there is no need to setup a local server to run the application.
If the PR has been merged, the previous link might not work. If that's the case and this document is not updated, please open an issue or create a PR to update the documentation so it helps everyone! 🙏
0. Borrow Baseline Code
Let's start by cloning the code from
dwyl/flutter-todo-list-tutorial
.
Access the link
and download the project files.
https://github.com/dwyl/flutter-todo-list-tutorial/tree/6b8ac188fa4752c09c22af8915ee51e6e7464098
After cloning the code, run the following command to fetch the dependencies.
flutter pub get
If you want to know how to run on an emulator with Visual Studio Code, check https://github.com/dwyl/learn-flutter#0-setting-up-a-new-project.
If you want to learn how to run on a real-device, check https://github.com/dwyl/flutter-counter-example#running-on-a-real-device-.
Let's check if all tests pass.
Run flutter test --coverage
and you should see the terminal
stating all tests pass.
00:04 +2: All tests passed!
Note Testing the app was beyond the scope of this quick demo. However, we've kickstarted it (you can check some tests inside the
test
folder).We've passed the
supabase
variable needed to complete the authentication by dependency injection and have successfully mocked it. We just didn't have the time to add more tests to give the app more test coverage.You can help this by creating a PR if you want to! 😊
The base application should look like so, running on an emulator.
It's a simple Todo app, where you can toggle between the various states and see all, active and completed items.
1. Supabase
setup
Before we start building an authentication flow,
we need to setup an account on Supabase
.
Sign in Supabase
in the next link ->
https://app.supabase.com/sign-in?returnTo=%2Fproject%2F_%2Fsql
Choose your preferred provider. In this case, we are going to be logging in using Github.
After successfully creating your account, you should be prompted with a dashboard that allows you to create a new project.
<img width="686" alt="new_project" src="https://user-images.githubusercontent.com/17494745/215134792-5e6e4b4a-038d-44ad-939d-39b718728fb3.png">We are going to be creating a project
for this tutorial.
Click on the + New Project
button.
You will be redirected to this page.
<img width="1036" alt="new_project" src="https://user-images.githubusercontent.com/17494745/215134790-57af6bb4-04e3-408e-8313-0628b31ad0b6.png">In this tutorial,
we are going to create a project called
flutter-todo
.
The region closest to us is Central EU
.
You can choose the region that is closest to you,
which should yield less latency whilst making API calls.
Choose the Free
pricing plan,
choose a password for database access
and you should be sorted!
After clicking on Create new project
,
you will be redirected to the project dashboard.
Notice that there's a pill
stating "setting up project"
.
This means the database and API endpoints
are being configured.
If you wait a few minutes, you will see your dashboard change to something similar to the next image.
<img width="1340" alt="project_dashboard_done" src="https://user-images.githubusercontent.com/17494745/215134772-9fd64b31-4058-4c14-a53e-d8089e1467eb.png">Awesome! 🎉
Now we can start implementing authentication and add it to our Flutter application!
2. Creating table in Supabase
Let's start implementing this feature
by adding a database in our Supabase
project.
We are going to be saving the user information
in a database table.
For this, click on the SQL Editor
button
on the left side of your project.
If you scroll down,
you will find a button saying User Management Starter
.
In here, you will have a template
to create a table for the user profiles.
You may change the profiles
table to your heart's content.
This setup is a scaffold that will
execute a few setup steps for you.
It will add Row-level security, meaning each user are only allowed to query their own information.
<img width="1149" alt="table_creation" src="https://user-images.githubusercontent.com/17494745/216007783-185ccf31-fb5b-44b6-acb7-846d79bd2f0b.png">For now, just click Run
on the lower part of the screen.
Your terminal will yield a result
stating "Success. No rows returned."
.
Awesome! 🎉
You've just created the table to store your app's user information!
3. Getting the API
keys
Now that we have created our database table, we need to know how to make operations on it. For this, we can use the API that was generated on start-up.
Our Flutter app will call this API during the authentication process.
For this, we need to get the url
and the anon
key from the API settings.
For this, go to Project Settings
in your side bar.
And click on API
.
After this, you should be able
to see the necessary keys/information
to make requests to the API.
You will find the API url
,
anon
and service_role
keys
in this page.
4. Integrating with the app
Now that we have all the necessary
tables, API and keys created,
we may now start integrating Supabase
in our Flutter
app!
4.1 Adding Supabase
-specific dependencies and configurations
Let's start with the dependencies
we need that are related to Supabase
.
In pubspec.yaml
,
add supabase_flutter
to the dependencies section
and run flutter pub get
.
With this package, we will be able
to authenticate through Supabase
in a much easier manner.
By default,
if we were to implement authentication
with Supabase
,
people using the app would
only be able to complete the auth
process
with e-mail confirmation.
In order to simplify this tutorial, we will disable e-mail confirmation, so only an e-mail and password are needed to showcase the authentication flow in and out of the app.
To do this,
go to your project's dashboard
and click on Authentication
.
Click on Providers
and open the E-mail
blade.
Disable the Confirm e-mail
switch.
And that's it!
4.2 Adding main function and constants
Let's start coding! 🧑💻
Let's initialize the Supabase
client
inside our main
function
with the API credentials
that we visited prior.
Copy url
and anon
keys
and change the main
function
inside main.dart
like so.
import 'package:supabase_flutter/supabase_flutter.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await Supabase.initialize(
url: 'YOUR_SUPABASE_URL',
anonKey: 'YOUR_SUPABASE_ANON_KEY',
);
runApp(const ProviderScope(child: App()));
}
Lets create a file with constants
to make it easier to use the Supabase
client.
We will also add an
extension method declaration
to show a
snackbar
if any authentication error occurs.
These variables will be exposed on the app, and that's completely fine since we have Row Level Security enabled on our database by default.
Create a file called lib/constants.dart
and add the following code.
import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
final supabase = Supabase.instance.client;
extension ShowSnackBar on BuildContext {
void showSnackBar({
required String message,
Color backgroundColor = Colors.white,
}) {
ScaffoldMessenger.of(this).showSnackBar(SnackBar(
content: Text(message),
backgroundColor: backgroundColor,
));
}
void showErrorSnackBar({required String message}) {
showSnackBar(message: message, backgroundColor: Colors.red);
}
}
We will use the supabase
constant
and these snackbar utilities
during the authentication process
in the Login
and Sign-Up
screens.
4.3 Adding Splash
Screen
Let's add a splash screen that will be shown to users right after they open the app. It will show a loading state and retrieve the current session.
If the user already has an ongoing session, it will redirect the user accordingly.
Create a new directory in lib/pages
,
and create splash.dart
inside it.
Use the following code.
import 'package:flutter/material.dart';
import 'package:todo_app/constants.dart';
class SplashPage extends StatefulWidget {
const SplashPage({super.key});
@override
SplashPageState createState() => SplashPageState();
}
class SplashPageState extends State<SplashPage> {
bool _redirectCalled = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_redirect();
}
Future<void> _redirect() async {
await Future.delayed(Duration.zero);
if (_redirectCalled || !mounted) {
return;
}
_redirectCalled = true;
final session = supabase.auth.currentSession;
if (session != null) {
Navigator.of(context).pushReplacementNamed('/home');
} else {
Navigator.of(context).pushReplacementNamed('/login');
}
}
@override
Widget build(BuildContext context) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
}
In here, we are creating a
stateful widget
that shows a CircularProgressIndicator
while the app is loading.
The _redirect
function is called
every time a dependency is changed,
including when the widget is mounted.
Inside this function,
we check if there is any current session.
If there's one,
the user is redirected to /home
.
If not, it's redirected to /login
.
We've yet to implement these routes. Don't worry, we will in the future.
4.3 Creating a Sign-Up
and Login
Screen
Let's now create a Sign-Up
screen.
In this screen,
the person using the app will input
an e-mail and password.
If both are valid, a profile is created and the user is redirected to the app.
Inside lib/pages
,
create a file called signup.dart
and paste the following snippet of code.
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:todo_app/constants.dart';
class SignUpPage extends StatefulWidget {
const SignUpPage({super.key});
@override
_SignUpPageState createState() => _SignUpPageState();
}
class _SignUpPageState extends State<SignUpPage> {
bool _redirecting = false;
late final StreamSubscription<AuthState> _authStateSubscription;
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
final _formKey = GlobalKey<FormState>();
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
@override
void initState() {
_authStateSubscription = supabase.auth.onAuthStateChange.listen((data) {
if (_redirecting) return;
final session = data.session;
if (session != null) {
_redirecting = true;
Navigator.of(context).pushReplacementNamed('/home');
}
});
super.initState();
}
Future<void> _signUp() async {
try {
await supabase.auth.signUp(password: _passwordController.text, email: _emailController.text);
if (mounted) {
_emailController.clear();
_passwordController.clear();
_redirecting = true;
Navigator.of(context).pushReplacementNamed('/home');
}
} on AuthException catch (error) {
context.showErrorSnackBar(message: error.message);
} catch (error) {
context.showErrorSnackBar(message: 'Unexpected error occurred');
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Sign Up')),
body: SingleChildScrollView(
child: Form(
key: _formKey,
child: Column(
children: <Widget>[
const SizedBox(
height: 200,
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 15),
child: TextFormField(
controller: _emailController,
decoration: const InputDecoration(border: OutlineInputBorder(), labelText: 'Email', hintText: 'Enter a valid email'),
validator: (String? value) {
if (value!.isEmpty || !value.contains('@')) {
return 'Email is not valid';
}
return null;
},
),
),
Padding(
padding: const EdgeInsets.only(left: 15.0, right: 15.0, top: 15, bottom: 0),
//padding: EdgeInsets.symmetric(horizontal: 15),
child: TextFormField(
obscureText: true,
controller: _passwordController,
decoration: const InputDecoration(border: OutlineInputBorder(), labelText: 'Password', hintText: 'Enter secure password'),
validator: (String? value) {
if (value!.isEmpty) {
return 'Invalid password';
}
return null;
},
),
),
const SizedBox(
height: 20,
),
Container(
height: 50,
width: 250,
decoration: BoxDecoration(color: Colors.blue, borderRadius: BorderRadius.circular(20)),
child: TextButton(
onPressed: () {
if (_formKey.currentState!.validate()) {
_signUp();
}
},
child: const Text(
'Sign Up',
style: TextStyle(color: Colors.white, fontSize: 25),
),
),
),
const SizedBox(
height: 130,
),
],
),
),
),
);
}
}
Let's break this widget down.
We are adding three basic elements:
- two
TextFormFields
and associated controllers (_emailController
and_passwordController
) - these controllers will manage the state inside these form fields. - a
TextButton
, that, when pressed, triggers the_signUp()
function which, in turn, tries to sign up the user.
The _signUp()
function
uses the supabase
constant
we created in constants.dart
and tries to create the user.
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:todo_app/constants.dart';
class SignUpPage extends StatefulWidget {
const SignUpPage({super.key});
@override
SignUpPageState createState() => SignUpPageState();
}
class SignUpPageState extends State<SignUpPage> {
bool _redirecting = false;
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
final _formKey = GlobalKey<FormState>();
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
@override
void initState() {
supabase.auth.onAuthStateChange.listen((data) {
if (_redirecting) return;
final session = data.session;
if (session != null) {
_redirecting = true;
Navigator.of(context).pushReplacementNamed('/home');
}
});
super.initState();
}
Future<void> _signUp() async {
try {
await supabase.auth.signUp(password: _passwordController.text, email: _emailController.text);
if (mounted) {
_emailController.clear();
_passwordController.clear();
_redirecting = true;
Navigator.of(context).pushReplacementNamed('/home');
}
} on AuthException catch (error) {
context.showErrorSnackBar(message: error.message);
} catch (error) {
context.showErrorSnackBar(message: 'Unexpected error occurred');
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Sign Up')),
body: SingleChildScrollView(
child: Form(
key: _formKey,
child: Column(
children: <Widget>[
const SizedBox(
height: 200,
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 15),
child: TextFormField(
controller: _emailController,
decoration: const InputDecoration(border: OutlineInputBorder(), labelText: 'Email', hintText: 'Enter a valid email'),
validator: (String? value) {
if (value!.isEmpty || !value.contains('@')) {
return 'Email is not valid';
}
return null;
},
),
),
Padding(
padding: const EdgeInsets.only(left: 15.0, right: 15.0, top: 15, bottom: 0),
//padding: EdgeInsets.symmetric(horizontal: 15),
child: TextFormField(
obscureText: true,
controller: _passwordController,
decoration: const InputDecoration(border: OutlineInputBorder(), labelText: 'Password', hintText: 'Enter secure password'),
validator: (String? value) {
if (value!.isEmpty) {
return 'Invalid password';
}
return null;
},
),
),
const SizedBox(
height: 20,
),
Container(
height: 50,
width: 250,
decoration: BoxDecoration(color: Colors.blue, borderRadius: BorderRadius.circular(20)),
child: TextButton(
onPressed: () {
if (_formKey.currentState!.validate()) {
_signUp();
}
},
child: const Text(
'Sign Up',
style: TextStyle(color: Colors.white, fontSize: 25),
),
),
),
const SizedBox(
height: 130,
),
],
),
),
),
);
}
}
If an error occurs whe signing up, a snackbar is shown to the user detailing what went wrong.
If it's successful,
the user is redirected to /home
,
which is the main app itself.
Let's create the Login
page now!
Inside the same directory lib/pages
,
create login.dart
.
Use the following code.
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:todo_app/constants.dart';
import 'package:todo_app/pages/signup.dart';
class LoginPage extends StatefulWidget {
const LoginPage({super.key});
@override
LoginPageState createState() => LoginPageState();
}
class LoginPageState extends State<LoginPage> {
bool _redirecting = false;
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
final _formKey = GlobalKey<FormState>();
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
@override
void initState() {
supabase.auth.onAuthStateChange.listen((data) {
if (_redirecting) return;
final session = data.session;
if (session != null) {
_redirecting = true;
Navigator.of(context).pushReplacementNamed('/home');
}
});
super.initState();
}
Future<void> _signIn() async {
try {
await supabase.auth.signInWithPassword(email: _emailController.text, password: _passwordController.text);
if (mounted) {
_emailController.clear();
_passwordController.clear();
_redirecting = true;
Navigator.of(context).pushReplacementNamed('/home');
}
} on AuthException catch (error) {
context.showErrorSnackBar(message: error.message);
} catch (error) {
context.showErrorSnackBar(message: 'Unexpected error occurred');
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Login')),
body: SingleChildScrollView(
child: Form(
key: _formKey,
child: Column(
children: <Widget>[
const SizedBox(
height: 200,
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 15),
child: TextFormField(
controller: _emailController,
decoration: const InputDecoration(border: OutlineInputBorder(), labelText: 'Email', hintText: 'Enter a valid email'),
validator: (String? value) {
if (value!.isEmpty || !value.contains('@')) {
return 'Email is not valid';
}
return null;
},
),
),
Padding(
padding: const EdgeInsets.only(left: 15.0, right: 15.0, top: 15, bottom: 0),
//padding: EdgeInsets.symmetric(horizontal: 15),
child: TextFormField(
controller: _passwordController,
obscureText: true,
decoration: const InputDecoration(border: OutlineInputBorder(), labelText: 'Password', hintText: 'Enter secure password'),
validator: (String? value) {
if (value!.isEmpty) {
return 'Invalid password';
}
return null;
},
),
),
Padding(
padding: const EdgeInsets.only(top: 16.0),
child: Container(
height: 50,
width: 250,
decoration: BoxDecoration(color: Colors.blue, borderRadius: BorderRadius.circular(20)),
child: TextButton(
onPressed: () async {
if (_formKey.currentState!.validate()) {
_signIn();
}
},
child: const Text(
'Login',
style: TextStyle(color: Colors.white, fontSize: 25),
),
),
),
),
const SizedBox(
height: 130,
),
TextButton(
onPressed: () {
Navigator.push(context, MaterialPageRoute(builder: (_) => const SignUpPage()));
},
child: const Text('New User? Create Account')),
const SizedBox(
height: 30,
),
],
),
),
),
);
}
}
The Login
page implementation
shares many similarities to SignUp
's.
There are a few differences, though.
We added a TextButton
that,
when pressed,
redirects the user to the Sign Up
page,
in case he/she doesn't have an account.
Additionally, inside
initState()
,
which is executed when the widget is instantiated,
we check if there's any session change.
If there's one,
it is checked and, if valid,
the user is redirected to /home
.
In both of these pages,
the supabase
variable is used.
This constant uses the supabase_flutter
package we've installed which,
with the API keys of our project,
communicates with the API
and creates the user when signing up
and manages the user session.
You can check the users created in https://app.supabase.com/project/_/auth/users. Choose the project and you will see the table of users.
<img width="1201" alt="users" src="https://user-images.githubusercontent.com/17494745/216122696-75e2294c-5853-4775-8c13-4a44aea87cd8.png">This page is inside the
Authentication > Users
button in the project's sidebar.
4.4 Changing main.dart
Now that we have created the necessary pages, let's use them whe the app starts up.
Inside lib/main.dart
,
let's make a small change.
Locate the line:
final _currentTodo = Provider<Todo>((ref) => throw UnimplementedError());
and move it to providers.dart
,
while changing it to currentTodo
(from _currentTodo
, which makes a variable private).
Now change all the instances
of this variable from _currentTodo
to currentTodo
inside main.dart
.
This change is needed to avoid any
depend_on_referenced_packages
problems along the line.
Let's keep going.
Locate the App
class
and change it to the following.
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:todo_app/constants.dart';
import 'package:todo_app/pages/login.dart';
import 'package:todo_app/pages/splash.dart';
class App extends StatelessWidget {
const App({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp(initialRoute: '/', routes: <String, WidgetBuilder>{
'/': (_) => const SplashPage(),
'/login': (_) => const LoginPage(),
'/home': (_) => const Home(),
});
}
}
We've added the routes we mentioned earlier.
The last change we need to do
is to add a Logout
button,
so the user can logout of the app
and be redirected back to the
Login
page.
Inside the Home
class,
locate the build
method
and change the appBar
attribute inside the Scaffold
.
@override
Widget build(BuildContext context, WidgetRef ref) {
final todos = ref.watch(filteredTodos);
final newTodoController = useTextEditingController();
return GestureDetector(
onTap: () => FocusScope.of(context).unfocus(),
child: Scaffold(
appBar: AppBar(
title: const Text('Home'),
actions: [
IconButton(
icon: const Icon(Icons.logout),
tooltip: 'logout',
onPressed: () async {
final navigator = Navigator.of(context);
try {
await supabase.auth.signOut();
navigator.pushReplacementNamed('/');
} on AuthException catch (error) {
context.showErrorSnackBar(message: error.message);
} catch (error) {
context.showErrorSnackBar(message: 'Unexpected error occurred');
}
},
),
],
),
body:
...
We've added an IconButton
that,
when pressed,
uses the supabase
variable
to try and sign the user out.
If it's not successful,
a snackbar pops up,
detailing the error occurred.
5. Run the app!
That's all you need to do! Let's check our app running!
You should see something similar to the gif below.
As you can see, the user signs up, is instantly redirected to the app. You can logout and login again.
When logging in, if the user is not found or any field is invalid, a snackbar is shown on the bottom of the screen.
And that's it! Congratulations! 👏
You've just added authentication to your Flutter app that is secure, meaning no user can tamper with other profiles or try to impersonate anyone during the auth process (thanks to Row-Level Security).
6. Other features
This tutorial is intentionally simple,
as it's meant to showcase the authentication process
of Supabase
.
Each profile is stored in the database.
You might have noticed that,
when creating the profiles
table in the database,
users can also have an avatar_url
.
Supabase
allows you to store images
related to users.
Supabase
does this through AWS
,
using S3
buckets.
You may find more information on how to integrate this in https://supabase.com/docs/guides/getting-started/tutorials/with-flutter#bonus-profile-photos.