This article is the second part of the Flutter tutorial series. During the series, you will learn how to build cross-platform apps without worrying about the backend.
In this article, I will show you how you can make a secure chat application by introducing authorization to the basic chat app that we created previously.
We will store the chat data on Supabase.
Supabase utilizes the built in authorization mechanism of PostgreSQL called Row Level Security or RLS to prevent unauthorized access from accessing or writing data to your database.
RLS allows developers to define row-by-row conditions that evaluate to either true
or false
to either allow the access or deny it.
We will take a look at more specific examples of authorization using RLS throughout this article.
What we created in the previous article
Before we jump in, let's go over what we built in the previous article, because we will be building on top of it. If you have not gone through it, I recommend you to go check it out.
In the previous article, we created a basic real-time chat application. Users will register or sign in using an email address and password. Once they are signed in, they are taken to a chat page, where they can view and send messages to everyone in the app. There are no Chat rooms, and everyone's messages were sent to the same chat room.
You can also find a complete code example here to follow along.
Overview of the final app
The app will allow us to have 1 on 1 chat with other users in the app. To enable this, we will introduce a new rooms page. The rooms page serves two purposes here, one is to initiate a conversation with other users, and the other is to display existing chat rooms. At the top of the app, we see a list of other users' icons. A user can tap the icon to start a 1 on 1 conversation. Below the icons, there is a list of rooms that the user is a part of.
Sessing up the scene
Install additional dependencies
We will install flutter_bloc for state management. Introducing a state management solution will allow us to handle the shared message and profile data efficiently between the rooms page and the chats page. We can use any state management solution for this, but we are going with bloc in this example. Add the following in your pubspec.yaml file to install flutter_bloc in your app.
_10flutter_bloc: ^8.0.0
Modifying the table schema
Since the app has evolved, we also need to update our table schema. In order to store rooms data, we will add a rooms table. We will also modify the messages table to add a foreign key constraint to the rooms table so that we can tell which message belongs to which room.
We will also introduce a create_new_room
function, which is a database function that handles chat room creation. It knows to create a new room if a chat room with the two users does not exist yet, or to just return the room ID if it already exists.
_60-- *** Table definitions ***_60_60create table if not exists public.rooms (_60 id uuid not null primary key default gen_random_uuid(),_60 created_at timestamp with time zone default timezone('utc' :: text, now()) not null_60);_60comment on table public.rooms is 'Holds chat rooms';_60_60create table if not exists public.room_participants (_60 profile_id uuid references public.profiles(id) on delete cascade not null,_60 room_id uuid references public.rooms(id) on delete cascade not null,_60 created_at timestamp with time zone default timezone('utc' :: text, now()) not null,_60 primary key (profile_id, room_id)_60);_60comment on table public.room_participants is 'Relational table of users and rooms.';_60_60alter table public.messages_60add column room_id uuid references public.rooms(id) on delete cascade not null;_60_60-- *** Add tables to the publication to enable realtime ***_60_60alter publication supabase_realtime add table public.room_participants;_60_60-- Creates a new room with the user and another user in it._60-- Will return the room_id of the created room_60-- Will return a room_id if there were already a room with those participants_60create or replace function create_new_room(other_user_id uuid) returns uuid as $$_60 declare_60 new_room_id uuid;_60 begin_60 -- Check if room with both participants already exist_60 with rooms_with_profiles as (_60 select room_id, array_agg(profile_id) as participants_60 from room_participants_60 group by room_id_60 )_60 select room_id_60 into new_room_id_60 from rooms_with_profiles_60 where create_new_room.other_user_id=any(participants)_60 and auth.uid()=any(participants);_60_60_60 if not found then_60 -- Create a new room_60 insert into public.rooms default values_60 returning id into new_room_id;_60_60 -- Insert the caller user into the new room_60 insert into public.room_participants (profile_id, room_id)_60 values (auth.uid(), new_room_id);_60_60 -- Insert the other_user user into the new room_60 insert into public.room_participants (profile_id, room_id)_60 values (other_user_id, new_room_id);_60 end if;_60_60 return new_room_id;_60 end_60$$ language plpgsql security definer;
Setup deep links
Something we skipped in the previous article was sending confirmation emails to users when they signup. Since today is about security, let's properly send confirmation emails to people who signup.
When we send confirmation emails, the users need to be brought back to the app somehow.
Since supabase_flutter has a mechanism to detect and handle deep links, we will register a io.supabase.chat://login
as our deep link for the app and bring the users back after confirming their email address.
For iOS we edit the info.plist file to register the deep link.
_20<!-- ... other tags -->_20<plist>_20<dict>_20 <!-- ... other tags -->_20_20 <!-- Add this array for Deep Links -->_20 <key>CFBundleURLTypes</key>_20 <array>_20 <dict>_20 <key>CFBundleTypeRole</key>_20 <string>Editor</string>_20 <key>CFBundleURLSchemes</key>_20 <array>_20 <string>io.supabase.chat</string>_20 </array>_20 </dict>_20 </array>_20 <!-- ... other tags -->_20</dict>_20</plist>
For Android we edit the AndroidManifest.xml to register the deep link.
_20<manifest ...>_20 <!-- ... other tags -->_20 <application ...>_20 <activity ...>_20 <!-- ... other tags -->_20_20 <!-- Add this intent-filter for Deep Links -->_20 <intent-filter>_20 <action android:name="android.intent.action.VIEW" />_20 <category android:name="android.intent.category.DEFAULT" />_20 <category android:name="android.intent.category.BROWSABLE" />_20 <!-- Accepts URIs that begin with YOUR_SCHEME://YOUR_HOST -->_20 <data_20 android:scheme="io.supabase.chat"_20 android:host="login" />_20 </intent-filter>_20_20 </activity>_20 </application>_20</manifest>
We also need to set the deep link in our Supabase dashboard. Go to Authentication > URL Configuration
in your dashboard and add io.supabase.chat://login
as one of the redirect URLs.
And that is it for deep link configuration.
Building out the main application
Step1: Create rooms page
The rooms page will load two types of data, recently added users and a list of rooms that the user belongs to. We will be using bloc to load these two types of data and display them on the rooms page.
Let's start out by creating states for the rooms page.
The rooms page would have four different states, loading, loaded, empty, and error. We will display different UI on the rooms page depending on what state it is.
Satrt by defining the Room
model. Create a lib/models/room.dart
file and add the following code.
_50import 'package:my_chat_app/models/message.dart';_50_50class Room {_50 Room({_50 required this.id,_50 required this.createdAt,_50 required this.otherUserId,_50 this.lastMessage,_50 });_50_50 /// ID of the room_50 final String id;_50_50 /// Date and time when the room was created_50 final DateTime createdAt;_50_50 /// ID of the user who the user is talking to_50 final String otherUserId;_50_50 /// Latest message submitted in the room_50 final Message? lastMessage;_50_50 Map<String, dynamic> toMap() {_50 return {_50 'id': id,_50 'createdAt': createdAt.millisecondsSinceEpoch,_50 };_50 }_50_50 /// Creates a room object from room_participants table_50 Room.fromRoomParticipants(Map<String, dynamic> map)_50 : id = map['room_id'],_50 otherUserId = map['profile_id'],_50 createdAt = DateTime.parse(map['created_at']),_50 lastMessage = null;_50_50 Room copyWith({_50 String? id,_50 DateTime? createdAt,_50 String? otherUserId,_50 Message? lastMessage,_50 }) {_50 return Room(_50 id: id ?? this.id,_50 createdAt: createdAt ?? this.createdAt,_50 otherUserId: otherUserId ?? this.otherUserId,_50 lastMessage: lastMessage ?? this.lastMessage,_50 );_50 }_50}
We will proceed with defining the states for the rooms page. Create lib/cubit/rooms/rooms_state.dart
file and paste the following code.
You may see some errors, but we will take care of them in the next step.
_28part of 'rooms_cubit.dart';_28_28@immutable_28abstract class RoomState {}_28_28class RoomsLoading extends RoomState {}_28_28class RoomsLoaded extends RoomState {_28 final List<Profile> newUsers;_28 final List<Room> rooms;_28_28 RoomsLoaded({_28 required this.rooms,_28 required this.newUsers,_28 });_28}_28_28class RoomsEmpty extends RoomState {_28 final List<Profile> newUsers;_28_28 RoomsEmpty({required this.newUsers});_28}_28_28class RoomsError extends RoomState {_28 final String message;_28_28 RoomsError(this.message);_28}
Now that we have the states defined, we will create rooms_cubit.
A cubit is a class within the flutter_bloc library where we will make requests to Supabase to get the data and transform them into states and emit them to the UI widgets.
Let's create a lib/cubit/rooms/rooms_cubit.dart
file and complete the cubit.
_140import 'dart:async';_140_140import 'package:flutter/material.dart';_140import 'package:flutter_bloc/flutter_bloc.dart';_140import 'package:my_chat_app/cubits/profiles/profiles_cubit.dart';_140import 'package:my_chat_app/models/profile.dart';_140import 'package:my_chat_app/models/message.dart';_140import 'package:my_chat_app/models/room.dart';_140import 'package:my_chat_app/utils/constants.dart';_140_140part 'rooms_state.dart';_140_140class RoomCubit extends Cubit<RoomState> {_140 RoomCubit() : super(RoomsLoading());_140_140 final Map<String, StreamSubscription<Message?>>_140 _messageSubscriptions = {};_140_140 late final String _myUserId;_140_140 /// List of new users of the app for the user to start talking to_140 late final List<Profile> _newUsers;_140_140 /// List of rooms_140 List<Room> _rooms = [];_140 StreamSubscription<List<Map<String, dynamic>>>?_140 _rawRoomsSubscription;_140 bool _haveCalledGetRooms = false;_140_140 Future<void> initializeRooms(BuildContext context) async {_140 if (_haveCalledGetRooms) {_140 return;_140 }_140 _haveCalledGetRooms = true;_140_140 _myUserId = supabase.auth.currentUser!.id;_140_140 late final List data;_140_140 try {_140 data = await supabase_140 .from('profiles')_140 .select()_140 .not('id', 'eq', _myUserId)_140 .order('created_at')_140 .limit(12);_140 } catch (_) {_140 emit(RoomsError('Error loading new users'));_140 }_140_140 final rows = List<Map<String, dynamic>>.from(data);_140 _newUsers = rows.map(Profile.fromMap).toList();_140_140 /// Get realtime updates on rooms that the user is in_140 _rawRoomsSubscription =_140 supabase.from('room_participants').stream(_140 primaryKey: ['room_id', 'profile_id'],_140 ).listen((participantMaps) async {_140 if (participantMaps.isEmpty) {_140 emit(RoomsEmpty(newUsers: _newUsers));_140 return;_140 }_140_140 _rooms = participantMaps_140 .map(Room.fromRoomParticipants)_140 .where((room) => room.otherUserId != _myUserId)_140 .toList();_140 for (final room in _rooms) {_140 _getNewestMessage(_140 context: context, roomId: room.id);_140 BlocProvider.of<ProfilesCubit>(context)_140 .getProfile(room.otherUserId);_140 }_140 emit(RoomsLoaded(_140 newUsers: _newUsers,_140 rooms: _rooms,_140 ));_140 }, onError: (error) {_140 emit(RoomsError('Error loading rooms'));_140 });_140 }_140_140 // Setup listeners to listen to the most recent message in each room_140 void _getNewestMessage({_140 required BuildContext context,_140 required String roomId,_140 }) {_140 _messageSubscriptions[roomId] = supabase_140 .from('messages')_140 .stream(primaryKey: ['id'])_140 .eq('room_id', roomId)_140 .order('created_at')_140 .limit(1)_140 .map<Message?>(_140 (data) => data.isEmpty_140 ? null_140 : Message.fromMap(_140 map: data.first,_140 myUserId: _myUserId,_140 ),_140 )_140 .listen((message) {_140 final index = _rooms_140 .indexWhere((room) => room.id == roomId);_140 _rooms[index] =_140 _rooms[index].copyWith(lastMessage: message);_140 _rooms.sort((a, b) {_140 /// Sort according to the last message_140 /// Use the room createdAt when last message is not available_140 final aTimeStamp = a.lastMessage != null_140 ? a.lastMessage!.createdAt_140 : a.createdAt;_140 final bTimeStamp = b.lastMessage != null_140 ? b.lastMessage!.createdAt_140 : b.createdAt;_140 return bTimeStamp.compareTo(aTimeStamp);_140 });_140 if (!isClosed) {_140 emit(RoomsLoaded(_140 newUsers: _newUsers,_140 rooms: _rooms,_140 ));_140 }_140 });_140 }_140_140 /// Creates or returns an existing roomID of both participants_140 Future<String> createRoom(String otherUserId) async {_140 final data = await supabase.rpc('create_new_room',_140 params: {'other_user_id': otherUserId});_140 emit(RoomsLoaded(rooms: _rooms, newUsers: _newUsers));_140 return data as String;_140 }_140_140 @override_140 Future<void> close() {_140 _rawRoomsSubscription?.cancel();_140 return super.close();_140 }_140}
Now that we have the states and cubit to power our rooms page, it's time to create the RoomsPage
.
We have two list views, one horizontal list view to display other users, and one vertical list views with list tiles representing each room that the user is a part of.
We will create a lib/pages/rooms_page.dart
file with the following content.
_187import 'package:flutter/material.dart';_187import 'package:flutter_bloc/flutter_bloc.dart';_187import 'package:my_chat_app/cubits/profiles/profiles_cubit.dart';_187_187import 'package:my_chat_app/cubits/rooms/rooms_cubit.dart';_187import 'package:my_chat_app/models/profile.dart';_187import 'package:my_chat_app/pages/chat_page.dart';_187import 'package:my_chat_app/pages/register_page.dart';_187import 'package:my_chat_app/utils/constants.dart';_187import 'package:timeago/timeago.dart';_187_187/// Displays the list of chat threads_187class RoomsPage extends StatelessWidget {_187 const RoomsPage({Key? key}) : super(key: key);_187_187 static Route<void> route() {_187 return MaterialPageRoute(_187 builder: (context) => BlocProvider<RoomCubit>(_187 create: (context) =>_187 RoomCubit()..initializeRooms(context),_187 child: const RoomsPage(),_187 ),_187 );_187 }_187_187 @override_187 Widget build(BuildContext context) {_187 return Scaffold(_187 appBar: AppBar(_187 title: const Text('Rooms'),_187 actions: [_187 TextButton(_187 onPressed: () async {_187 await supabase.auth.signOut();_187 Navigator.of(context).pushAndRemoveUntil(_187 RegisterPage.route(),_187 (route) => false,_187 );_187 },_187 child: const Text('Logout'),_187 ),_187 ],_187 ),_187 body: BlocBuilder<RoomCubit, RoomState>(_187 builder: (context, state) {_187 if (state is RoomsLoading) {_187 return preloader;_187 } else if (state is RoomsLoaded) {_187 final newUsers = state.newUsers;_187 final rooms = state.rooms;_187 return BlocBuilder<ProfilesCubit,_187 ProfilesState>(_187 builder: (context, state) {_187 if (state is ProfilesLoaded) {_187 final profiles = state.profiles;_187 return Column(_187 children: [_187 _NewUsers(newUsers: newUsers),_187 Expanded(_187 child: ListView.builder(_187 itemCount: rooms.length,_187 itemBuilder: (context, index) {_187 final room = rooms[index];_187 final otherUser =_187 profiles[room.otherUserId];_187_187 return ListTile(_187 onTap: () =>_187 Navigator.of(context)_187 .push(ChatPage.route(_187 room.id)),_187 leading: CircleAvatar(_187 child: otherUser == null_187 ? preloader_187 : Text(otherUser_187 .username_187 .substring(0, 2)),_187 ),_187 title: Text(otherUser == null_187 ? 'Loading...'_187 : otherUser.username),_187 subtitle: room.lastMessage !=_187 null_187 ? Text(_187 room.lastMessage!_187 .content,_187 maxLines: 1,_187 overflow: TextOverflow_187 .ellipsis,_187 )_187 : const Text(_187 'Room created'),_187 trailing: Text(format(_187 room.lastMessage_187 ?.createdAt ??_187 room.createdAt,_187 locale: 'en_short')),_187 );_187 },_187 ),_187 ),_187 ],_187 );_187 } else {_187 return preloader;_187 }_187 },_187 );_187 } else if (state is RoomsEmpty) {_187 final newUsers = state.newUsers;_187 return Column(_187 children: [_187 _NewUsers(newUsers: newUsers),_187 const Expanded(_187 child: Center(_187 child: Text(_187 'Start a chat by tapping on available users'),_187 ),_187 ),_187 ],_187 );_187 } else if (state is RoomsError) {_187 return Center(child: Text(state.message));_187 }_187 throw UnimplementedError();_187 },_187 ),_187 );_187 }_187}_187_187class _NewUsers extends StatelessWidget {_187 const _NewUsers({_187 Key? key,_187 required this.newUsers,_187 }) : super(key: key);_187_187 final List<Profile> newUsers;_187_187 @override_187 Widget build(BuildContext context) {_187 return SingleChildScrollView(_187 padding: const EdgeInsets.symmetric(vertical: 8),_187 scrollDirection: Axis.horizontal,_187 child: Row(_187 children: newUsers_187 .map<Widget>((user) => InkWell(_187 onTap: () async {_187 try {_187 final roomId =_187 await BlocProvider.of<RoomCubit>(_187 context)_187 .createRoom(user.id);_187 Navigator.of(context)_187 .push(ChatPage.route(roomId));_187 } catch (_) {_187 context.showErrorSnackBar(_187 message:_187 'Failed creating a new room');_187 }_187 },_187 child: Padding(_187 padding: const EdgeInsets.all(8.0),_187 child: SizedBox(_187 width: 60,_187 child: Column(_187 children: [_187 CircleAvatar(_187 child: Text(user.username_187 .substring(0, 2)),_187 ),_187 const SizedBox(height: 8),_187 Text(_187 user.username,_187 maxLines: 1,_187 overflow: TextOverflow.ellipsis,_187 ),_187 ],_187 ),_187 ),_187 ),_187 ))_187 .toList(),_187 ),_187 );_187 }_187}
You may see some errors, but they will go away once we edit the chat page!
Step 2: Modify the chat page to load messages in the room
Our ChatPage
will have a similar layout as the previous one, but will only display messages sent to a single room. We will start by creating MessagesState
. The messages page will also have four different states, loading, loaded, empty, and error.
Create a lib/cubits/chat/chat_state.dart
file with the following code.
_18part of 'chat_cubit.dart';_18_18@immutable_18abstract class ChatState {}_18_18class ChatInitial extends ChatState {}_18_18class ChatLoaded extends ChatState {_18 ChatLoaded(this.messages);_18 final List<Message> messages;_18}_18_18class ChatEmpty extends ChatState {}_18_18class ChatError extends ChatState {_18 ChatError(this.message);_18 final String message;_18}
Now let's create chat cubit to retrieve the data from our database and emit it as states.
Create a lib/cubits/chat/chat_cubit.dart
file and paste the following.
_72import 'dart:async';_72_72import 'package:bloc/bloc.dart';_72import 'package:meta/meta.dart';_72import 'package:my_chat_app/models/message.dart';_72import 'package:my_chat_app/utils/constants.dart';_72_72part 'chat_state.dart';_72_72class ChatCubit extends Cubit<ChatState> {_72 ChatCubit() : super(ChatInitial());_72_72 StreamSubscription<List<Message>>? _messagesSubscription;_72 List<Message> _messages = [];_72_72 late final String _roomId;_72 late final String _myUserId;_72_72 void setMessagesListener(String roomId) {_72 _roomId = roomId;_72_72 _myUserId = supabase.auth.currentUser!.id;_72_72 _messagesSubscription = supabase_72 .from('messages')_72 .stream(primaryKey: ['id'])_72 .eq('room_id', roomId)_72 .order('created_at')_72 .map<List<Message>>(_72 (data) => data_72 .map<Message>(_72 (row) => Message.fromMap(map: row, myUserId: _myUserId))_72 .toList(),_72 )_72 .listen((messages) {_72 _messages = messages;_72 if (_messages.isEmpty) {_72 emit(ChatEmpty());_72 } else {_72 emit(ChatLoaded(_messages));_72 }_72 });_72 }_72_72 Future<void> sendMessage(String text) async {_72 /// Add message to present to the user right away_72 final message = Message(_72 id: 'new',_72 roomId: _roomId,_72 profileId: _myUserId,_72 content: text,_72 createdAt: DateTime.now(),_72 isMine: true,_72 );_72 _messages.insert(0, message);_72 emit(ChatLoaded(_messages));_72_72 try {_72 await supabase.from('messages').insert(message.toMap());_72 } catch (_) {_72 emit(ChatError('Error submitting message.'));_72 _messages.removeWhere((message) => message.id == 'new');_72 emit(ChatLoaded(_messages));_72 }_72 }_72_72 @override_72 Future<void> close() {_72 _messagesSubscription?.cancel();_72 return super.close();_72 }_72}
Chat cubit is pretty simple. It sets a real-time listener to the database using the stream method and emits an empty state if there are no messages in the room, or emits a loaded state if there are messages.
Because we are using cubit, we need to modify the MessagesPage widget as well.
Open lib/pages/chat_page.dart
and let's update it.
_193import 'package:flutter/material.dart';_193import 'package:flutter_bloc/flutter_bloc.dart';_193import 'package:my_chat_app/components/user_avatar.dart';_193import 'package:my_chat_app/cubits/chat/chat_cubit.dart';_193_193import 'package:my_chat_app/models/message.dart';_193import 'package:my_chat_app/utils/constants.dart';_193import 'package:timeago/timeago.dart';_193_193/// Page to chat with someone._193///_193/// Displays chat bubbles as a ListView and TextField to enter new chat._193class ChatPage extends StatelessWidget {_193 const ChatPage({Key? key}) : super(key: key);_193_193 static Route<void> route(String roomId) {_193 return MaterialPageRoute(_193 builder: (context) => BlocProvider<ChatCubit>(_193 create: (context) => ChatCubit()..setMessagesListener(roomId),_193 child: const ChatPage(),_193 ),_193 );_193 }_193_193 @override_193 Widget build(BuildContext context) {_193 return Scaffold(_193 appBar: AppBar(title: const Text('Chat')),_193 body: BlocConsumer<ChatCubit, ChatState>(_193 listener: (context, state) {_193 if (state is ChatError) {_193 context.showErrorSnackBar(message: state.message);_193 }_193 },_193 builder: (context, state) {_193 if (state is ChatInitial) {_193 return preloader;_193 } else if (state is ChatLoaded) {_193 final messages = state.messages;_193 return Column(_193 children: [_193 Expanded(_193 child: ListView.builder(_193 padding: const EdgeInsets.symmetric(vertical: 8),_193 reverse: true,_193 itemCount: messages.length,_193 itemBuilder: (context, index) {_193 final message = messages[index];_193 return _ChatBubble(message: message);_193 },_193 ),_193 ),_193 const _MessageBar(),_193 ],_193 );_193 } else if (state is ChatEmpty) {_193 return Column(_193 children: const [_193 Expanded(_193 child: Center(_193 child: Text('Start your conversation now :)'),_193 ),_193 ),_193 _MessageBar(),_193 ],_193 );_193 } else if (state is ChatError) {_193 return Center(child: Text(state.message));_193 }_193 throw UnimplementedError();_193 },_193 ),_193 );_193 }_193}_193_193/// Set of widget that contains TextField and Button to submit message_193class _MessageBar extends StatefulWidget {_193 const _MessageBar({_193 Key? key,_193 }) : super(key: key);_193_193 @override_193 State<_MessageBar> createState() => _MessageBarState();_193}_193_193class _MessageBarState extends State<_MessageBar> {_193 late final TextEditingController _textController;_193_193 @override_193 Widget build(BuildContext context) {_193 return Material(_193 color: Theme.of(context).cardColor,_193 child: Padding(_193 padding: EdgeInsets.only(_193 top: 8,_193 left: 8,_193 right: 8,_193 bottom: MediaQuery.of(context).padding.bottom,_193 ),_193 child: Row(_193 children: [_193 Expanded(_193 child: TextFormField(_193 keyboardType: TextInputType.text,_193 maxLines: null,_193 autofocus: true,_193 controller: _textController,_193 decoration: const InputDecoration(_193 hintText: 'Type a message',_193 border: InputBorder.none,_193 focusedBorder: InputBorder.none,_193 contentPadding: EdgeInsets.all(8),_193 ),_193 ),_193 ),_193 TextButton(_193 onPressed: () => _submitMessage(),_193 child: const Text('Send'),_193 ),_193 ],_193 ),_193 ),_193 );_193 }_193_193 @override_193 void initState() {_193 _textController = TextEditingController();_193 super.initState();_193 }_193_193 @override_193 void dispose() {_193 _textController.dispose();_193 super.dispose();_193 }_193_193 void _submitMessage() async {_193 final text = _textController.text;_193 if (text.isEmpty) {_193 return;_193 }_193 BlocProvider.of<ChatCubit>(context).sendMessage(text);_193 _textController.clear();_193 }_193}_193_193class _ChatBubble extends StatelessWidget {_193 const _ChatBubble({_193 Key? key,_193 required this.message,_193 }) : super(key: key);_193_193 final Message message;_193_193 @override_193 Widget build(BuildContext context) {_193 List<Widget> chatContents = [_193 if (!message.isMine) UserAvatar(userId: message.profileId),_193 const SizedBox(width: 12),_193 Flexible(_193 child: Container(_193 padding: const EdgeInsets.symmetric(_193 vertical: 8,_193 horizontal: 12,_193 ),_193 decoration: BoxDecoration(_193 color: message.isMine_193 ? Colors.grey[300]_193 : Theme.of(context).primaryColor,_193 borderRadius: BorderRadius.circular(8),_193 ),_193 child: Text(message.content),_193 ),_193 ),_193 const SizedBox(width: 12),_193 Text(format(message.createdAt, locale: 'en_short')),_193 const SizedBox(width: 60),_193 ];_193 if (message.isMine) {_193 chatContents = chatContents.reversed.toList();_193 }_193 return Padding(_193 padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 18),_193 child: Row(_193 mainAxisAlignment:_193 message.isMine ? MainAxisAlignment.end : MainAxisAlignment.start,_193 children: chatContents,_193 ),_193 );_193 }_193}
Step 3: Handle user sign-up/ sign-in
Because we have modified the setting of our Supabase to send a confirmation email, we need to make some modifications to the register page and login page as well.
The main change is how we handle navigation.
Previously, we were able to navigate the user to ChatPage
right after sign-in was complete.
This would no longer work, as we now have to wait for the user to confirm their email address.
In this case, we would want to listen to auth state of the user and navigate when the user is signed in with a session.
This allows us to react when the user confirmed their email addresses.
_169import 'dart:async';_169_169import 'package:flutter/material.dart';_169import 'package:my_chat_app/pages/login_page.dart';_169import 'package:my_chat_app/pages/rooms_page.dart';_169import 'package:my_chat_app/utils/constants.dart';_169import 'package:supabase_flutter/supabase_flutter.dart';_169_169class RegisterPage extends StatefulWidget {_169 const RegisterPage(_169 {Key? key, required this.isRegistering})_169 : super(key: key);_169_169 static Route<void> route({bool isRegistering = false}) {_169 return MaterialPageRoute(_169 builder: (context) =>_169 RegisterPage(isRegistering: isRegistering),_169 );_169 }_169_169 final bool isRegistering;_169_169 @override_169 State<RegisterPage> createState() => _RegisterPageState();_169}_169_169class _RegisterPageState extends State<RegisterPage> {_169 final bool _isLoading = false;_169_169 final _formKey = GlobalKey<FormState>();_169_169 final _emailController = TextEditingController();_169 final _passwordController = TextEditingController();_169 final _usernameController = TextEditingController();_169_169 late final StreamSubscription<AuthState>_169 _authSubscription;_169_169 @override_169 void initState() {_169 super.initState();_169_169 bool haveNavigated = false;_169 // Listen to auth state to redirect user when the user clicks on confirmation link_169 _authSubscription =_169 supabase.auth.onAuthStateChange.listen((data) {_169 final session = data.session;_169 if (session != null && !haveNavigated) {_169 haveNavigated = true;_169 Navigator.of(context)_169 .pushReplacement(RoomsPage.route());_169 }_169 });_169 }_169_169 @override_169 void dispose() {_169 super.dispose();_169_169 // Dispose subscription when no longer needed_169 _authSubscription.cancel();_169 }_169_169 Future<void> _signUp() async {_169 final isValid = _formKey.currentState!.validate();_169 if (!isValid) {_169 return;_169 }_169 final email = _emailController.text;_169 final password = _passwordController.text;_169 final username = _usernameController.text;_169 try {_169 await supabase.auth.signUp(_169 email: email,_169 password: password,_169 data: {'username': username},_169 emailRedirectTo: 'io.supabase.chat://login',_169 );_169 context.showSnackBar(_169 message:_169 'Please check your inbox for confirmation email.');_169 } on AuthException catch (error) {_169 context.showErrorSnackBar(message: error.message);_169 } catch (error) {_169 debugPrint(error.toString());_169 context.showErrorSnackBar(_169 message: unexpectedErrorMessage);_169 }_169 }_169_169 @override_169 Widget build(BuildContext context) {_169 return Scaffold(_169 appBar: AppBar(_169 title: const Text('Register'),_169 ),_169 body: Form(_169 key: _formKey,_169 child: ListView(_169 padding: formPadding,_169 children: [_169 TextFormField(_169 controller: _emailController,_169 decoration: const InputDecoration(_169 label: Text('Email'),_169 ),_169 validator: (val) {_169 if (val == null || val.isEmpty) {_169 return 'Required';_169 }_169 return null;_169 },_169 keyboardType: TextInputType.emailAddress,_169 ),_169 spacer,_169 TextFormField(_169 controller: _passwordController,_169 obscureText: true,_169 decoration: const InputDecoration(_169 label: Text('Password'),_169 ),_169 validator: (val) {_169 if (val == null || val.isEmpty) {_169 return 'Required';_169 }_169 if (val.length < 6) {_169 return '6 characters minimum';_169 }_169 return null;_169 },_169 ),_169 spacer,_169 TextFormField(_169 controller: _usernameController,_169 decoration: const InputDecoration(_169 label: Text('Username'),_169 ),_169 validator: (val) {_169 if (val == null || val.isEmpty) {_169 return 'Required';_169 }_169 final isValid =_169 RegExp(r'^[A-Za-z0-9_]{3,24}$')_169 .hasMatch(val);_169 if (!isValid) {_169 return '3-24 long with alphanumeric or underscore';_169 }_169 return null;_169 },_169 ),_169 spacer,_169 ElevatedButton(_169 onPressed: _isLoading ? null : _signUp,_169 child: const Text('Register'),_169 ),_169 spacer,_169 TextButton(_169 onPressed: () {_169 Navigator.of(context)_169 .push(LoginPage.route());_169 },_169 child:_169 const Text('I already have an account'))_169 ],_169 ),_169 ),_169 );_169 }_169}
Login page becomes simpler.
All it is doing is taking a user's email and password and logging them in.
It is not doing any navigation whatsoever.
This is because LoginPage
is navigated on top of RegisterPage
, the auth state listener on RegisterPage
is still active, and therefore can take care of the navigation.
_80import 'package:flutter/material.dart';_80import 'package:my_chat_app/utils/constants.dart';_80import 'package:supabase_flutter/supabase_flutter.dart';_80_80class LoginPage extends StatefulWidget {_80 const LoginPage({Key? key}) : super(key: key);_80_80 static Route<void> route() {_80 return MaterialPageRoute(_80 builder: (context) => const LoginPage());_80 }_80_80 @override_80 _LoginPageState createState() => _LoginPageState();_80}_80_80class _LoginPageState extends State<LoginPage> {_80 bool _isLoading = false;_80 final _emailController = TextEditingController();_80 final _passwordController = TextEditingController();_80_80 Future<void> _signIn() async {_80 setState(() {_80 _isLoading = true;_80 });_80 try {_80 await supabase.auth.signInWithPassword(_80 email: _emailController.text,_80 password: _passwordController.text,_80 );_80 } on AuthException catch (error) {_80 context.showErrorSnackBar(message: error.message);_80 } catch (_) {_80 context.showErrorSnackBar(_80 message: unexpectedErrorMessage);_80 }_80 if (mounted) {_80 setState(() {_80 _isLoading = true;_80 });_80 }_80 }_80_80 @override_80 void dispose() {_80 _emailController.dispose();_80 _passwordController.dispose();_80 super.dispose();_80 }_80_80 @override_80 Widget build(BuildContext context) {_80 return Scaffold(_80 appBar: AppBar(title: const Text('Sign In')),_80 body: ListView(_80 padding: formPadding,_80 children: [_80 TextFormField(_80 controller: _emailController,_80 decoration:_80 const InputDecoration(labelText: 'Email'),_80 keyboardType: TextInputType.emailAddress,_80 ),_80 spacer,_80 TextFormField(_80 controller: _passwordController,_80 decoration: const InputDecoration(_80 labelText: 'Password'),_80 obscureText: true,_80 ),_80 spacer,_80 ElevatedButton(_80 onPressed: _isLoading ? null : _signIn,_80 child: const Text('Login'),_80 ),_80 ],_80 ),_80 );_80 }_80}
Notice that I have not used bloc anywhere on the register or login page.
I try to only use state management libraries for pages that have some complexity.
Since both register and login pages are relatively simple, I am going with the good old setState
.
We should also modify the splash page to redirect signed-in users to the RoomsPage by default.
_51import 'package:flutter/material.dart';_51import 'package:my_chat_app/pages/register_page.dart';_51import 'package:my_chat_app/pages/rooms_page.dart';_51import 'package:my_chat_app/utils/constants.dart';_51import 'package:supabase_flutter/supabase_flutter.dart';_51_51/// Page to redirect users to the appropriate page depending on the initial auth state_51class SplashPage extends StatefulWidget {_51 const SplashPage({Key? key}) : super(key: key);_51_51 @override_51 SplashPageState createState() => SplashPageState();_51}_51_51class SplashPageState extends State<SplashPage> {_51 @override_51 void initState() {_51 getInitialSession();_51 super.initState();_51 }_51_51 Future<void> getInitialSession() async {_51 // quick and dirty way to wait for the widget to mount_51 await Future.delayed(Duration.zero);_51_51 try {_51 final session =_51 await SupabaseAuth.instance.initialSession;_51 if (session == null) {_51 Navigator.of(context).pushAndRemoveUntil(_51 RegisterPage.route(), (_) => false);_51 } else {_51 Navigator.of(context).pushAndRemoveUntil(_51 RoomsPage.route(), (_) => false);_51 }_51 } catch (_) {_51 context.showErrorSnackBar(_51 message: 'Error occurred during session refresh',_51 );_51 Navigator.of(context).pushAndRemoveUntil(_51 RegisterPage.route(), (_) => false);_51 }_51 }_51_51 @override_51 Widget build(BuildContext context) {_51 return const Scaffold(_51 body: Center(child: CircularProgressIndicator()),_51 );_51 }_51}
Finally we implement those ProfilesCubit
that you saw here and there throughout the code.
This cubit will act as in memory cache of all the profiles data so that the app does not have to go fetch the same profiles every single time it needs it.
Create profiles_state.dart
and profiles_cubit.dart
under lib/cubits/
and add the following code.
_14part of 'profiles_cubit.dart';_14_14@immutable_14abstract class ProfilesState {}_14_14class ProfilesInitial extends ProfilesState {}_14_14class ProfilesLoaded extends ProfilesState {_14 ProfilesLoaded({_14 required this.profiles,_14 });_14_14 final Map<String, Profile?> profiles;_14}
_33import 'dart:async';_33_33import 'package:bloc/bloc.dart';_33import 'package:meta/meta.dart';_33import 'package:my_chat_app/models/profile.dart';_33import 'package:my_chat_app/utils/constants.dart';_33_33part 'profiles_state.dart';_33_33class ProfilesCubit extends Cubit<ProfilesState> {_33 ProfilesCubit() : super(ProfilesInitial());_33_33 /// Map of app users cache in memory with profile_id as the key_33 final Map<String, Profile?> _profiles = {};_33_33 Future<void> getProfile(String userId) async {_33 if (_profiles[userId] != null) {_33 return;_33 }_33_33 final data = await supabase_33 .from('profiles')_33 .select()_33 .match({'id': userId}).single();_33_33 if (data == null) {_33 return;_33 }_33 _profiles[userId] = Profile.fromMap(data);_33_33 emit(ProfilesLoaded(profiles: _profiles));_33 }_33}
We will make the ProfilesCubit
accessible from anywhere in the app with the following code in main.dart
file.
_36import 'package:flutter/material.dart';_36import 'package:flutter_bloc/flutter_bloc.dart';_36import 'package:my_chat_app/cubits/profiles/profiles_cubit.dart';_36import 'package:my_chat_app/utils/constants.dart';_36import 'package:supabase_flutter/supabase_flutter.dart';_36import 'package:my_chat_app/pages/splash_page.dart';_36_36Future<void> main() async {_36 WidgetsFlutterBinding.ensureInitialized();_36_36 await Supabase.initialize(_36 // TODO: Replace credentials with your own_36 url: 'supabase_url',_36 anonKey: 'supabase_anon_key',_36 authCallbackUrlHostname: 'login',_36 );_36_36 runApp(const MyApp());_36}_36_36class MyApp extends StatelessWidget {_36 const MyApp({Key? key}) : super(key: key);_36_36 @override_36 Widget build(BuildContext context) {_36 return BlocProvider<ProfilesCubit>(_36 create: (context) => ProfilesCubit(),_36 child: MaterialApp(_36 title: 'SupaChat',_36 debugShowCheckedModeBanner: false,_36 theme: appTheme,_36 home: const SplashPage(),_36 ),_36 );_36 }_36}
Step 4: Authorization with Row Level Security (RLS)
At this point, we seemingly have a complete app. But if we open the app right now, we will see every users' room along with all the messages that have ever been sent. This is because we have not set up Row Level Security to prevent users from accessing rooms that don't belong to them. There are two ways we can define Row Level Security policies in Supabase: with the GUI or through SQL. Today we will use SQL. Let's run the following SQL to set the security policy.
_34-- Returns true if the signed in user is a participant of the room_34create or replace function is_room_participant(room_id uuid)_34returns boolean as $$_34 select exists(_34 select 1_34 from room_participants_34 where room_id = is_room_participant.room_id and profile_id = auth.uid()_34 );_34$$ language sql security definer;_34_34_34-- *** Row level security polities ***_34_34_34alter table public.profiles enable row level security;_34create policy "Public profiles are viewable by everyone."_34 on public.profiles for select using (true);_34_34_34alter table public.rooms enable row level security;_34create policy "Users can view rooms that they have joined"_34 on public.rooms for select using (is_room_participant(id));_34_34_34alter table public.room_participants enable row level security;_34create policy "Participants of the room can view other participants."_34 on public.room_participants for select using (is_room_participant(room_id));_34_34_34alter table public.messages enable row level security;_34create policy "Users can view messages on rooms they are in."_34 on public.messages for select using (is_room_participant(room_id));_34create policy "Users can insert messages on rooms they are in."_34 on public.messages for insert with check (is_room_participant(room_id) and profile_id = auth.uid());
Notice that we have created a handy is_room_participant
function that will return whether a particular user is a participant or not in a specific room.
With the Row Level Security policies set up, our application is complete. We now have a real-time chat application with proper authentication and authorization in place.
Conclusions/ Future Improvements
Continuing from our previous article, we added proper authorization to our chat application using Row Level Security, which enabled us to add 1 on 1 chat feature. We used bloc for our state management solution. One thing we could have done differently if we were to write test codes was to pass the supabase instance as a parameter of the cubit so that we could write tests using the bloc_test package.
We could also explore some cool feature improvement. At the top of the rooms page, we are loading the newest created users to start a conversation. This is fine, but it only allows users to start a conversation with new users. We can for example update this to a list of users that are online at the same time. We can implement this using the presence feature of Supabase.