Using Supabase Services in Flutter

Using Supabase Services in Flutter

We will cover briefly:

  1. Sign In using the Magic Link
  2. CRUD operations using Postgres
  3. Uploading images using Storage

Intro to Supabase

Supabase

Before diving into Supabase, let’s provide an introduction to what Supabase is.

Supabase is an open-source alternative to Firebase that provides a set of backend services, including authentication, database, and storage.

Supabase offers some features like

  • Auth service: for user authentication.
  • Database service: standard PostgreSQL.
  • Realtime service: sending messages and states to clients.
  • Edge functions: Running server-side TypeScript functions, distributed globally at the edge
  • Storage service: Storing and serving files.
Supabase in Flutter
Using Supabase Services in Flutter

To integrate Supabase into a Flutter application, we’ll utilize the supabase_flutter package. Initializing the Supabase client can be done using the following code snippet.

void main() async {
  await Supabase.initialize(
    url: SUPABASE_URL,
    anonKey: SUPABASE_ANON_KEY,
  );
}

final supabase = Supabase.instance.client;

Sign In using Magic Link

It allows users to sign in to your app using Supabase Auth with the convenience of a magic link.

  • To enable the native app to open when a user clicks on a link, it is necessary to configure deep links. When utilizing Supabase auth, certain scenarios result in the app opening upon link click. To support these scenarios effectively, setting up deep links is essential.
Sign In Using Magic Link
Sign In Using Magic Link

When users enter their email addresses, an email containing a verification link is sent to them. Users can then click on the link to verify their email and gain access to the app. This method eliminates the need for traditional password-based authentication and provides a secure and user-friendly login experience.

// Magic link login
await supabase.auth.signInWithOtp(email: 'my_email@example.com');
OTP sent to the email
OTP sent to the email

If you do not receive the OTP, make sure to verify the following:

  • Ensure that the email authentication provider is enabled within the Authentication Supabase settings.

After receiving the email, you should be able to sign into your app by clicking on it. To achieve this, you need to register a redirect URL inside Supabase.

  • Go to the Supabase project Authentication Settings page.
  • Enter your app redirect callback on Additional Redirect URLs field.

The redirect callback url should have the format [YOUR_SCHEME]://[YOUR_HOSTNAME]

Register redirect URL
Register redirect URL

Once you successfully enter the app, your profile information is stored within the authenticated user’s data.

Authenticated users
Authenticated users
  1. When the user comes back to the app, we verify if there is a current session with the Supabase client.
  2. If there is an active session (i.e., the user is already authenticated), we redirect the user to the home screen
  3. If there is no active session (i.e., the user is not authenticated or the session has expired), we redirect the user to the login screen to initiate the login process.
void redirectAfterAuth() {
 final session = _supabaseService.supabaseClient.auth.currentSession;
  
 if (session != null) {
   _navigationService.pushNamedAndRemoveUntil(
      AppRoutes.homeRoute,
      (_) => false,
   );
 } else {
   _navigationService.pushNamedAndRemoveUntil(
      AppRoutes.loginRoute,
      (_) => false,
   );
 }
}

AddOns:

You have the option to personalize the email messages utilized during authentication processes. You are able to modify the content of the following email templates:

  • Email confirmation during signup
  • User invitation
  • Magic Link authentication
  • Changing email address
  • Password reset notification

Listen to Auth Changes

We listen to the changes in supabase authentication using the auth.onAuthStateChangemethod. This allows us to receive notifications whenever an authentication event occurs. We receive a AuthState which includes

class AuthState {
  final AuthChangeEvent event;
  final Session? session;

  AuthState(this.event, this.session);
}

We extract the session data and store it within our application’s global information. Upon the user’s app launch, we verify the presence of the session and guide the redirection process accordingly.

final session = _supabaseService.supabaseClient.auth.currentSession;

if (session != null) {
  // Go to home 
       
} else {
  // Go to login
}

The session class comprises the following params

class Session {
  final String? providerToken;
  final String? providerRefreshToken;
  final String accessToken;
  
  final int? expiresIn;

  final String? refreshToken;
  final String tokenType;
  final User user;
} 

Store data using Postgres

User profiles and logged mood entries are securely stored and retrieved using the Supabase database. The PostgreSQL database allows for efficient and reliable data storage, ensuring that user information and mood records are safely managed.

Postgres Database
Postgres Database

We create two tables inside Postgres:

  • mood_tracker : For storing the user’s moods information
  • profiles : For storing the user’s profile information
Mood Logger
Mood Logger
  • Upon reaching the home screen, users have the option to select their mood by clicking on the emoji buttons. Subsequently, the chosen mood will be stored in the mood_tracker table in the Postgres database.
Future<bool> updateMood(MoodEntry mood) async {
  try {
    await supabase!.from('mood_tracker').upsert(mood.toJson());
    return true;
  } catch (error) {
    debugPrint('Unexpected error occurred ${error.toString()}');
  }

  return false;
}

MoodEntry is a model class that consists of the following params:

class MoodEntry {
  final String moodName;
  final String? updatedAt;
  final String? data;
  final String? id;
  final String userId;
  final String date;
}
  • We utilize the Supabase client, previously established in the initial step, to invoke the upsert method. We pass the model as JSON, and we perform the necessary data insertion or update in the database.

When performing an upsert, the database system first attempts to find a matching record based on certain criteria, such as a unique key or primary key. If a matching record is found, the operation updates the existing record with the provided data. If no matching record is found, a new record is inserted with the provided data.

This is how the data gets stored inside the table

mood saved in Postgres
mood saved in Postgres

Upon navigating to the calendar screen, when the user chooses a specific date, we retrieve the corresponding mood recorded for that particular day.

Future<List<MoodEntry>> fetchMoodRecords(String dateFrom, String dateTo) async {
  try {
    final data = await supabase!
        .from('mood_tracker')
        .select()
        .gte(ColumnResource.moodTrackerUpdatedAt, dateFrom)
        .lte(ColumnResource.moodTrackerUpdatedAt, dateTo);

    final modelResp = convertToModelList(data);

    return modelResp;
  } catch (error) {
    debugPrint('Unexpected error occurred ${error.toString()}');
  }

  return [];
}
  • We use the .from() method to specify the table to query. The .select() method indicates that all columns should be selected.
  • The .gte() method filters the data to include only rows where the value in the moodTrackerUpdatedAt column is greater than or equal to the dateFrom value.
  • Similarly, the .lte() method filters the data to include only rows where the value in the moodTrackerUpdatedAt column is less than or equal to the dateTo value.
Mood fetch from PostGres
Mood fetch from PostGres

Getting the Profile

When the users click on the gear icon button, we show them the Profile Screen We query the profiles table and the fetched data is then structured into a UserModel format.

Profile Button
Profile Button
final userId = supabase?.auth.currentUser!.id;

final data = await supabase!
    .from('profiles')
    .select()
    .eq('id', userId)
    .single() as Map<String, dynamic>;

final profile = UserProfile.fromJson(data);
return profile;
  • .select(): This specifies that a SELECT operation is being performed on the profiles table.
  • .eq('id', userId): This adds a filter condition to the query. It specifies that only rows where the id column is equal to the value of userId should be selected.
  • .single(): This indicates that you expect only a single result to be returned from the query.

Note: The currentUser property represents the currently authenticated user and the id property of the user object retrieves their unique identifier

The data returned is then converted to UserProfile (a model class)

class UserProfile {
  String id;
  String? updatedAt;
  String? username;
  String? avatarUrl;
  String? website;
}

In addition, users have the option to modify their profile details by accessing the profile screen. Upon making changes to their name, they can save the updates by clicking the Update button. Internally, we call the upsert operation (as mentioned above) for saving changes to the database.

Profile Screen
Profile Screen

Uploading images using Storage

User-uploaded profile images are securely stored in Supabase storage. The generated URLs are then retrieved within the application to display user avatars, providing a seamless and efficient way to manage user profile images.

We create a avatar bucket inside our project’s storage. Buckets are distinct containers for files and folders.

Storage
Storage

The client app should support picking image files, from the local file explorer and sending over them to the Supabase Storage. Choosing files from the system is done through image_picker

Once the user clicks on the Upload Photo button, we call the ImagePicker.pickImage

final imageFile = await picker.pickImage(
  source: ImageSource.gallery,
  maxWidth: 300,
  maxHeight: 300,
);
  • The source parameter is set ImageSource.gallerywhich specifies that the image should be picked from the device’s gallery.
  • The maxWidth and maxHeight parameters limit the size of the picked image.

Next, we get the bytes from the image by calling

final bytes = await imageFile.readAsBytes();

Finally, we call the uploadBinary function from Supabase storage.

final imageUrl = await _supabaseService.uploadBinary(
   fileDetails.fileName,
   fileDetails.bytes,
   fileDetails.imageFile,
);

Once the file gets uploaded, we can create a signedUrlto download file without requiring permission. This URL can be valid for a set number of seconds. In our case, we set it to 1 year

final imageUrlResponse = await supabase!.storage
   .from('avatars')
   .createSignedUrl(filePath, 60 * 60 * 24 * 365 * 10);

Bonus: App SignOut

We call the signOut function from the Supabase’s auth for signing out the current user, if there is a logged-in user.

await supabase?.auth.signOut();

Source code