going through a tutorial on user registration with Flutter and Supabase to understand how to use Supabase.
I am going through a tutorial on user registration with Flutter and Supabase to understand how to use Supabase.
Summary
I am going through the user registration section of the official tutorial "Build a User Management App with Flutter" to understand the integration between Flutter and Supabase. As I work through it, I will summarize my output to easily recall the process when looking back later.
The completed source code for the tutorial is available here:
https://github.com/supabase/supabase/tree/master/examples/user-management/flutter-user-management
In a previous blog post titled "Running a Supabase Database in a Local Environment for Development" I covered the process of setting up a Supabase environment locally and deploying it to a remote environment. Since this is likely the approach during actual development, I will follow the same strategy this time.
I have pushed the source code I wrote for this tutorial to the following repository:
https://github.com/tkugimot/flutter-supabase-quickstart
Creating a new Flutter app
In the tutorial, Supabase setup is performed first. However, since I want to manage Supabase migration files in the same repository, I will create the app first.
$ flutter create supabase_quickstart
$ cd supabase_quickstart
Certainly, since you want to manage it with Git, you can initialize Git using the command git init
.
$ git init
$ git add .
$ git commit -m 'Initial Commit'
After opening the project in the IDE, go ahead and install the supabase_flutter
package.
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
supabase_flutter: ^1.10.3. # Add this line
$ flutter pub get
Setting up the Supabase project:
First, create a Supabase project in the local environment. The following command will create a supabase
directory.
$ supabase init
$ supabase start
Accessing http://localhost:54323 allows you to view the dashboard.
Next, prepare the migration files. This procedure is outlined in the tutorial.
$ supabase migration new user_management_starter
A file named [datetime]_user_management_starter.sql
has been created in supabase/migrations
. Inside this file, you can copy and paste the SQL code provided in the tutorial.
-- Create a table for public profiles
create table profiles (
id uuid references auth.users not null primary key,
updated_at timestamp with time zone,
username text unique,
full_name text,
avatar_url text,
website text,
constraint username_length check (char_length(username) >= 3)
);
-- Set up Row Level Security (RLS)
-- See https://supabase.com/docs/guides/auth/row-level-security for more details.
alter table profiles
enable row level security;
create policy "Public profiles are viewable by everyone." on profiles
for select using (true);
create policy "Users can insert their own profile." on profiles
for insert with check (auth.uid() = id);
create policy "Users can update own profile." on profiles
for update using (auth.uid() = id);
-- This trigger automatically creates a profile entry when a new user signs up via Supabase Auth.
-- See https://supabase.com/docs/guides/auth/managing-user-data#using-triggers for more details.
create function public.handle_new_user()
returns trigger as $$
begin
insert into public.profiles (id, full_name, avatar_url)
values (new.id, new.raw_user_meta_data->>'full_name', new.raw_user_meta_data->>'avatar_url');
return new;
end;
$$ language plpgsql security definer;
create trigger on_auth_user_created
after insert on auth.users
for each row execute procedure public.handle_new_user();
-- Set up Storage!
insert into storage.buckets (id, name)
values ('avatars', 'avatars');
-- Set up access controls for storage.
-- See https://supabase.com/docs/guides/storage/security/access-control#policy-examples for more details.
create policy "Avatar images are publicly accessible." on storage.objects
for select using (bucket_id = 'avatars');
create policy "Anyone can upload an avatar." on storage.objects
for insert with check (bucket_id = 'avatars');
create policy "Anyone can update their own avatar." on storage.objects
for update using (auth.uid() = owner) with check (bucket_id = 'avatars');
Use the following command to apply the migration.
$ supabase db reset
Confirm that the table has been created.
$ psql 'postgresql://postgres:postgres@localhost:54322/postgres'
psql (16.0, server 15.1 (Ubuntu 15.1-1.pgdg20.04+1))
Type "help" for help.
postgres=> \d
List of relations
Schema | Name | Type | Owner
------------+-------------------------+-------+----------------
extensions | pg_stat_statements | view | supabase_admin
extensions | pg_stat_statements_info | view | supabase_admin
public | profiles | table | postgres
(3 rows)
Create a new project from the Supabase dashboard in the remote environment at https://supabase.com/dashboard/projects. Link it with the local Supabase.
Use the {projectId} from the URL of the created project to link it:
https://supabase.com/dashboard/project/{projectId}
$ supabase link --project-ref <projectId>
Enter your database password (or leave blank to skip):
Finished supabase link.
Reflect the content of the local environment to the remote environment.
$ supabase db push
Setting up deep link:
Setting up deep link:
This involves configuring a redirection link for when users click the sign-in button to return to the app. You can set any value; for this case, let's use "io.tkugimot.flutterquickstart://login-callback/".
Configure this in Supabase's dashboard under Redirect URLs.
Then, add the configuration for iOS.
<!-- ... other tags -->
<plist>
<dict>
<!-- ... other tags -->
<!-- Add this array for Deep Links -->
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLSchemes</key>
<array>
<string>io.tkugimot.flutterquickstart</string>
</array>
</dict>
</array>
<!-- ... other tags -->
</dict>
</plist>
Modify the implementation of the Main:
You can obtain the url
and anonKey
from Supabase's API Settings.
Future<void> main() async {
await Supabase.initialize(
url: 'YOUR_SUPABASE_URL',
anonKey: 'YOUR_SUPABASE_ANON_KEY',
authFlowType: AuthFlowType.pkce,
);
runApp(MyApp());
}
final supabase = Supabase.instance.client;
Creating a splash screen (the screen displayed when the app is launched):
import 'package:flutter/material.dart';
import 'package:supabase_quickstart/main.dart';
class SplashPage extends StatefulWidget {
const SplashPage({super.key});
@override
_SplashPageState createState() => _SplashPageState();
}
class _SplashPageState extends State<SplashPage> {
@override
void initState() {
super.initState();
_redirect();
}
Future<void> _redirect() async {
await Future.delayed(Duration.zero);
if (!mounted) {
return;
}
final session = supabase.auth.currentSession;
if (session != null) {
Navigator.of(context).pushReplacementNamed('/account');
} else {
Navigator.of(context).pushReplacementNamed('/login');
}
}
@override
Widget build(BuildContext context) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
}
Creating a login page:
Set the emailRedirectTo
value, which should match the value you set in Redirect URLs earlier.
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:supabase_quickstart/main.dart';
class LoginPage extends StatefulWidget {
const LoginPage({super.key});
@override
_LoginPageState createState() => _LoginPageState();
}
class _LoginPageState extends State<LoginPage> {
bool _isLoading = false;
bool _redirecting = false;
late final TextEditingController _emailController = TextEditingController();
late final StreamSubscription<AuthState> _authStateSubscription;
Future<void> _signIn() async {
try {
setState(() {
_isLoading = true;
});
await supabase.auth.signInWithOtp(
email: _emailController.text.trim(),
emailRedirectTo:
kIsWeb ? null : 'io.tkugimot.flutterquickstart://login-callback/',
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Check your email for a login link!')),
);
_emailController.clear();
}
} on AuthException catch (error) {
SnackBar(
content: Text(error.message),
backgroundColor: Theme.of(context).colorScheme.error,
);
} catch (error) {
SnackBar(
content: const Text('Unexpected error occurred'),
backgroundColor: Theme.of(context).colorScheme.error,
);
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
@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('/account');
}
});
super.initState();
}
@override
void dispose() {
_emailController.dispose();
_authStateSubscription.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Sign In')),
body: ListView(
padding: const EdgeInsets.symmetric(vertical: 18, horizontal: 12),
children: [
const Text('Sign in via the magic link with your email below'),
const SizedBox(height: 18),
TextFormField(
controller: _emailController,
decoration: const InputDecoration(labelText: 'Email'),
),
const SizedBox(height: 18),
ElevatedButton(
onPressed: _isLoading ? null : _signIn,
child: Text(_isLoading ? 'Loading' : 'Send Magic Link'),
),
],
),
);
}
}
Creating an account page:
import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:supabase_quickstart/main.dart';
class AccountPage extends StatefulWidget {
const AccountPage({super.key});
@override
_AccountPageState createState() => _AccountPageState();
}
class _AccountPageState extends State<AccountPage> {
final _usernameController = TextEditingController();
final _websiteController = TextEditingController();
var _loading = true;
/// Called once a user id is received within `onAuthenticated()`
Future<void> _getProfile() async {
setState(() {
_loading = true;
});
try {
final userId = supabase.auth.currentUser!.id;
final data = await supabase
.from('profiles')
.select<Map<String, dynamic>>()
.eq('id', userId)
.single();
_usernameController.text = (data['username'] ?? '') as String;
_websiteController.text = (data['website'] ?? '') as String;
} on PostgrestException catch (error) {
SnackBar(
content: Text(error.message),
backgroundColor: Theme.of(context).colorScheme.error,
);
} catch (error) {
SnackBar(
content: const Text('Unexpected error occurred'),
backgroundColor: Theme.of(context).colorScheme.error,
);
} finally {
if (mounted) {
setState(() {
_loading = false;
});
}
}
}
/// Called when user taps `Update` button
Future<void> _updateProfile() async {
setState(() {
_loading = true;
});
final userName = _usernameController.text.trim();
final website = _websiteController.text.trim();
final user = supabase.auth.currentUser;
final updates = {
'id': user!.id,
'username': userName,
'website': website,
'updated_at': DateTime.now().toIso8601String(),
};
try {
await supabase.from('profiles').upsert(updates);
if (mounted) {
const SnackBar(
content: Text('Successfully updated profile!'),
);
}
} on PostgrestException catch (error) {
SnackBar(
content: Text(error.message),
backgroundColor: Theme.of(context).colorScheme.error,
);
} catch (error) {
SnackBar(
content: const Text('Unexpected error occurred'),
backgroundColor: Theme.of(context).colorScheme.error,
);
} finally {
if (mounted) {
setState(() {
_loading = false;
});
}
}
}
Future<void> _signOut() async {
try {
await supabase.auth.signOut();
} on AuthException catch (error) {
SnackBar(
content: Text(error.message),
backgroundColor: Theme.of(context).colorScheme.error,
);
} catch (error) {
SnackBar(
content: const Text('Unexpected error occurred'),
backgroundColor: Theme.of(context).colorScheme.error,
);
} finally {
if (mounted) {
Navigator.of(context).pushReplacementNamed('/login');
}
}
}
@override
void initState() {
super.initState();
_getProfile();
}
@override
void dispose() {
_usernameController.dispose();
_websiteController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Profile')),
body: _loading
? const Center(child: CircularProgressIndicator())
: ListView(
padding: const EdgeInsets.symmetric(vertical: 18, horizontal: 12),
children: [
TextFormField(
controller: _usernameController,
decoration: const InputDecoration(labelText: 'User Name'),
),
const SizedBox(height: 18),
TextFormField(
controller: _websiteController,
decoration: const InputDecoration(labelText: 'Website'),
),
const SizedBox(height: 18),
ElevatedButton(
onPressed: _loading ? null : _updateProfile,
child: Text(_loading ? 'Saving...' : 'Update'),
),
const SizedBox(height: 18),
TextButton(onPressed: _signOut, child: const Text('Sign Out')),
],
),
);
}
}
Modify main.dart
import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:supabase_quickstart/pages/account_page.dart';
import 'package:supabase_quickstart/pages/login_page.dart';
import 'package:supabase_quickstart/pages/splash_page.dart';
Future<void> main() async {
await Supabase.initialize(
url: 'YOUR_SUPABASE_URL',
anonKey: 'YOUR_SUPABASE_ANON_KEY',
);
runApp(MyApp());
}
final supabase = Supabase.instance.client;
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Supabase Flutter',
theme: ThemeData.dark().copyWith(
primaryColor: Colors.green,
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: Colors.green,
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
foregroundColor: Colors.white,
backgroundColor: Colors.green,
),
),
),
initialRoute: '/',
routes: <String, WidgetBuilder>{
'/': (_) => const SplashPage(),
'/login': (_) => const LoginPage(),
'/account': (_) => const AccountPage(),
},
);
}
}
Testing the Login:
Once you've reached this point, it's time to launch the app and try the login and user information registration flow.
If you're running on a physical device, you need to turn on debug mode first. If, after doing that, you encounter an error preventing the app from launching, right-click on the supabase_quickstart/ios
directory, select "Flutter → Open iOS/macOS module in XCode," and open XCode.
Could not build the precompiled application for the device.
Error (Xcode): Signing for "Runner" requires a development team. Select a development team in the Signing & Capabilities editor.
/Users/tkugimot/workspace/flutter-apps/supabase_quickstart/ios/Runner.xcodeproj
Error launching application on iPhone.
Then, Click TARGETS → Runner → Team.
If you don't have an Apple Developer account, you will need to create one. (It seems there might be a workaround for not having an account, but I haven't investigated it. I apologize for not having that information.)
I previously wrote a series of articles on releasing a simple app with Flutter, titled "Developing and Releasing a Simple App in Flutter Where Tapping Icons Produces Sounds - Part 4/4 Remaining Tasks Before Release" In that series, I summarized the steps for publishing a basic app. Feel free to refer to it if you're interested.
If you've successfully launched the app on a physical device, the screen similar to the following will appear. Enter your email address and click on "Send Magic Link."
When you do this, an email will be sent to you. Open the email on your iPhone and click the link.
By pressing "Open" here, the app will return, and the following user information input screen will be displayed.
Entering your information and pressing "Update" will...
The data has been successfully registered in the Supabase database.
Supabase looks quite promising. Recently, my work and personal life have been quite busy, and I haven't had much time. However, I've learned most of the necessary technologies, so I'm thinking of resuming the development of my original Flutter app soon.