Skip to content

dwyl/supabase-flutter-demo

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

36 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Supabase + Flutter Demo

A showcase of using Supabase with Flutter in a simple Flutter Todo List App with authentication

GitHub Workflow Status HitCount contributions welcome


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.

base-demo

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

sign-in

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.

new_project

We are going to be creating a project for this tutorial. Click on the + New Project button.

You will be redirected to this page.

new_project

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.

project_dashboard_loading

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.

project_dashboard_done

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.

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.

user_starter

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.

table_creation

For now, just click Run on the lower part of the screen. Your terminal will yield a result stating "Success. No rows returned.".

run

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.

settings

And click on API.

api_button

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.

keys

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.

auth

Click on Providers and open the E-mail blade. Disable the Confirm e-mail switch.

provider

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.

This page is inside the Authentication > Users button in the project's sidebar.

users

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.

e.g lib/providers.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.

final

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.

About

A SPIKE showcasing the use of Supabase with authentication on a simple Flutter Todo List App

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Contributors 4

  •  
  •  
  •  
  •