Create a Chat Room App with Twilio Video and Flutter / BLoC

January 04, 2022
Written by
Arnaud Phommasone
Contributor
Opinions expressed by Twilio contributors are their own
Reviewed by
Mia Adjei
Twilion

In this blog post, I’m going to show you how to create a video chat room mobile app with Twilio Video and Flutter. The app will be implemented with the Business Logic Components pattern, known as BLoC. BLoC is a design pattern created by Google to help separate business logic from the presentation layer and enable a developer to reuse code more efficiently. Flutter is Google’s free, open-source UI toolkit for building applications for mobile, web, and desktop from one single codebase.

In this tutorial, you’ll create a Flutter app which will use an unofficial Flutter package for interfacing with Twilio Video, and you will allow users of this app to host a call and be joined by multiple other users. The tutorial will focus more on creating a serverless endpoint to get access tokens for our users to connect and enter the same chat room, which will be represented as a grid of video streams.

In this blog post, we assume that you already have some basic knowledge of Dart.

Set up the local dev environment

Before getting started, you will need to set up the following:

For the project, you will also need the following tools, but we will walk through them later in the tutorial:

Additionally, a little existing knowledge of Dart and BLoC will be required.

Sign up for a Twilio account

First, you’ll need to sign up for a Twilio account if you don't already have one.

After entering all the required account information, select Twilio Programmable Video as the product you would like to use.

Screenshot of Twilio Console, showing Programmable Video section

Once you are logged in to the Twilio Console, you will see your Account SID displayed there. Make a note of this value, then navigate to the API Keys page and click the Create API key button. Enter a friendly name for your API key, then create the key and make a note of its SID and secret key — these are the live credentials you will need later in the project.

Create the backend service

For the sake of simplicity, we will create a Twilio Service to generate an access token we can use in our chatroom.

Navigate to the Services page in the Functions section of the Twilio Console.

Click the Create Service button and name the new service TwilioChatRoomAccessToken.

Create a new function by clicking the Add+ button and naming the function accessToken. This function will be used as our serverless service path. By default, it’s set as protected, so make sure to make it public in the dropdown menu next to the function's name.

Dropdown menu to change from `protected` to `public`

 

In the function body, copy and paste the following code. Replace <ACCOUNT_SID>, <TWILIO_API_KEY> and <TWILIO_API_SECRET> with the values you made note of earlier.

// This is your new function. To start, set the name and path on the left.
exports.handler = function(context, event, callback) {
 const AccessToken = Twilio.jwt.AccessToken;
 const twilioAccountSid = "<ACCOUNT_SID>";
 const twilioApiKey = "<TWILIO_API_KEY>";
 const twilioApiSecret = "<TWILIO_API_SECRET>";
 const identity = event.user;
 const token = new AccessToken(
   twilioAccountSid,
   twilioApiKey,
   twilioApiSecret,
   {identity: identity}
 );
  const videoGrant = new AccessToken.VideoGrant({
   room: 'Flutter ChatRoom' // the specific room's name
 });

 token.addGrant(videoGrant);
  const response = new Twilio.Response();
  const headers = {
   "Access-Control-Allow-Origin": "*", // change this to your client-side URL
   "Access-Control-Allow-Methods": "GET,PUT,POST,DELETE,OPTIONS",
   "Access-Control-Allow-Headers": "Content-Type",
   "Content-Type": "application/json"
 };
      
 response.setHeaders(headers);
 response.setBody({
   accessToken: token.toJwt()
 });

 return callback(null, response);
};

When you are done, make sure to Save and then Deploy All.

Deploy All button

Once you deploy, you will have access to a URL like the following, since it will be public: https://twiliochatroomaccesstoken-XXXX.twil.io/accessToken.

If you make a request to this URL, you will receive a response containing an access token:

{
  accessToken: <TOKEN>
}

Start the client-side app project

I’ve created a sample project with the architecture already set up to save time. You can download the starting project here.

Open the project in your code editor. If you open the file pubspec.yaml, you will see that we are using the following packages:

twilio_programmable_video: ^0.10.0+1

http: ^0.12.0+3

# Bloc
flutter_bloc: ^6.1.3
equatable: ^2.0.0

uuid: ^3.0.4

Simple BLoC architecture with Cubit

We want the mobile application to present a screen for a user to be able to join the call, and another screen representing the video room.

If you look at the lib directory, you can see that we have set up a basic architecture by using BLoC (Business Logic Component) and set up the folders as follows:

conference
 |_ conference_cubit.dart
 |_ conference_page.dart
 |_ participant_widget.dart
room
 |_ join_room_cubit.dart
 |_ join_room_page.dart
Shared
 |_ twilio_service.dart
main.dart

The folders represent each feature of the application (a page to join a room, a page for the chat room). Each page will be separated into:

  • A Cubit file. This is a class that stores an observable state, powered by streams but exposed in a friendly way. We will call any business logic here such as making API calls or connecting to Twilio.
  • A UI file for presenting the widgets.

One of the main issues with Flutter is with dependency injection, and knowing how to pass down the widget hierarchy tree without retaining a pointer of that object for each widget.

In this project, we will use two convenient components:

  • BlocProvider: It will inject an object available for each widget of the UI tree. We want to inject our Cubit object, as it’s encapsulating the business logic.
  • BlocConsumer: It will read any available object provided by the BlocProvider. We want to get the Cubit object and the state in order to manage our UI.
    • Important Note: A BlocConsumer of an object needs to be encapsulated by a BlocProvider.

Make sure that you run the command flutter pub get on your command line to get all of the dependencies before you run the app.

Once you have done this, connect your mobile device and run the app with the following command:

flutter run --no-sound-null-safety

Once the app is running, it will open in a browser window, and you should see this first screen:

Sign in screen with input for name and a button labeled "Enter the room"

Write your Twilio API service

I like to start writing the service first when building an application. For this project, we will write a singleton class (accessible from anywhere) to make a GET call and retrieve an access token for our user to join a video room.

Let's write the service in the lib/shared/twilio_service.dart file and review together what it's doing:


import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;

class TwilioFunctionsService {
 TwilioFunctionsService._();
 static final instance = TwilioFunctionsService._();

 final http.Client client = http.Client();
 final accessTokenUrl = '<SERVERLESS_TWILIO_URL>';


 Future<dynamic> createToken(String identity) async {
   try {
     Map<String, String> header = {
       'Content-Type': 'application/json',
     };
     var url = Uri.parse(accessTokenUrl + '?user=' + identity);
     final response = await client.get(url, headers: header);
     Map<String, dynamic> responseMap = jsonDecode(response.body);
     return responseMap;
   } catch (error) {
     throw Exception([error.toString()]);
   }
 }
}

Line 6-7: We declare a singleton object.

Line 10: The base URL, pointing to your server. Replace <SERVERLESS_TWILIO_URL> with your own serverless URL endpoint you deployed by using Twilio Serverless Services.

Line 13: The createToken function will generate an access token for a user with a given identity.

Line 19-20: We receive the response and then parse it into a dictionary / map object to be returned.

Create your Cubit object to retrieve the access token from the Join Room page

Let’s write the method for when a user clicks on the submit button from the UI. We will need to retrieve an access token from the service we wrote above, and then load it using different Cubit states (RoomInitial, RoomError, RoomLoaded, and RoomLoading). Open lib/room/join_room_cubit.dart. Update your submit function so it matches the one below:


 submit() async {
   emit(RoomLoading());
   String? token;
   String? identity;
   try {
     if (name != null) {
       final twilioRoomTokenResponse =
           await backendService.createToken(name!);
       token = twilioRoomTokenResponse['accessToken'];
       identity = twilioRoomTokenResponse['user'];
     }

     if (token != null && identity != null) {
       emit(RoomLoaded(name: name ?? '', token: token, identity: identity));
     } else {
       emit(RoomError(error: 'Access token is empty!'));
     }
   } catch (e) {
     emit(RoomError(
         error: 'Something wrong happened when getting the access token'));
   } finally {}
 }

Line 2: We are about to make an API call, so we will define our state as RoomLoading.

Line 6-21: We retrieve the access token. If it’s not empty then we can set the state to RoomLoaded. Otherwise we emit a RoomError since we didn’t receive any valid tokens.

Now let’s write the UI code for the Join Room page!

Create the listener and join a room

Open lib/room/join_room_page.dart. The only thing we will change is the following behavior:

  • We want to listen to different states of the app.
  • If we have received any tokens, we should push the user to a new screen.

Between the listener brackets of the BlocConsumer<RoomCubit, RoomState> let’s add the following code:


           child: BlocConsumer<RoomCubit, RoomState>(
               listener: (context, state) async {
             if (state is RoomLoaded) {
               await Navigator.of(context).push(
                 MaterialPageRoute<ConferencePage>(
                     fullscreenDialog: true,
                     builder: (BuildContext context) =>
                         // ConferencePage(roomModel: bloc),
                         BlocProvider(
                           create: (BuildContext context) => ConferenceCubit(
                             identity: state.identity,
                             token: state.token,
                             name: state.name,
                           ),
                           child: ConferencePage(),
                         )),
               );
             }
           }, builder: (context, state) {

Line 3-18 : The listener: (context, state) callback is watching the different states of the cubit object. We will need to listen for when the API call is finished to push to a different screen called ConferencePage by passing in a ConferenceCubit.

If you try to run the app now, entering your name and pressing the Enter the room button, it will look like the image below:

Entering a name and clicking the button, then being taken to a loading screen

 

Write your Cubit object to handle joining a Twilio room and creating participants

Before going further, let’s define some important terms used in Twilio Video:

  • Room: represents the virtual space where end-users communicate.
  • Participant: represents a client that is connected to a Room. A participant can only be connected to one room.
  • Track: a stream of bytes, containing the data generated by a source such as a microphone or camera. A participant can subscribe to these tracks.
  • RemoteParticipant: Represents the rest of the clients excluding yourself (LocalParticipant).

To join the room we will create:

  • a function called connect() to be called automatically when we land on the Conference Page
  • a function called disconnect() for when we want to leave the room
  • callback listeners to handle connection / disconnection of the participants
  • a function called _buildParticipant() to build the participant widget
  • a function called _addOrUpdateParticipant() to build the participants widget

Here in lib/conference/conference_cubit.dart, there’s a comment that says /// TODO: Implement connect, listeners and disconnect methods.

Replace the TODO line with the code below, after which I have listed the explanation of some key lines in the code:

 connect() async {
   print('[ APPDEBUG ] ConferenceRoom.connect()');
   try {
     await TwilioProgrammableVideo.setSpeakerphoneOn(true);

     final sources = await CameraSource.getSources();
     _cameraCapturer = CameraCapturer(
       sources.firstWhere((source) => source.isFrontFacing),
     );
     trackId = Uuid().v4();

     var connectOptions = ConnectOptions(
       token,
       roomName: name,
       preferredAudioCodecs: [OpusCodec()],
       audioTracks: [LocalAudioTrack(true, 'audio_track-$trackId')],
       dataTracks: [
         LocalDataTrack(
           DataTrackOptions(name: 'data_track-$trackId'),
         )
       ],
       videoTracks: [LocalVideoTrack(true, _cameraCapturer)],
       enableNetworkQuality: true,
       networkQualityConfiguration: NetworkQualityConfiguration(
         remote: NetworkQualityVerbosity.NETWORK_QUALITY_VERBOSITY_MINIMAL,
       ),
       enableDominantSpeaker: true,
     );

     _room = await TwilioProgrammableVideo.connect(connectOptions);

     _streamSubscriptions.add(_room.onConnected.listen(_onConnected));
     _streamSubscriptions.add(_room.onDisconnected.listen(_onDisconnected));
     _streamSubscriptions.add(_room.onReconnecting.listen(_onReconnecting));
     _streamSubscriptions
         .add(_room.onConnectFailure.listen(_onConnectFailure));
   } catch (err) {
     print('[ APPDEBUG ] $err');
     rethrow;
   }
 }

 Future<void> disconnect() async {
   print('[ APPDEBUG ] ConferenceRoom.disconnect()');
   await _room.disconnect();
 }

 void _onDisconnected(RoomDisconnectedEvent event) {
   print('[ APPDEBUG ] ConferenceRoom._onDisconnected');
 }

 void _onReconnecting(RoomReconnectingEvent room) {
   print('[ APPDEBUG ] ConferenceRoom._onReconnecting');
 }

 void _onConnected(Room room) {
   print('[ APPDEBUG ] ConferenceRoom._onConnected => state: ${room.state}');

   // When connected for the first time, add remote participant listeners
   _streamSubscriptions
       .add(_room.onParticipantConnected.listen(_onParticipantConnected));
   _streamSubscriptions.add(
       _room.onParticipantDisconnected.listen(_onParticipantDisconnected));
   final localParticipant = room.localParticipant;
   if (localParticipant == null) {
     print(
         '[ APPDEBUG ] ConferenceRoom._onConnected => localParticipant is null');
     return;
   }

   // Only add ourselves when connected for the first time too.
   _participants.add(_buildParticipant(
       child: localParticipant.localVideoTracks[0].localVideoTrack.widget(),
       id: identity));

   for (final remoteParticipant in room.remoteParticipants) {
     var participant = _participants.firstWhereOrNull(
         (participant) => participant.id == remoteParticipant.sid);
     if (participant == null) {
       print(
           '[ APPDEBUG ] Adding participant that was already present in the room ${remoteParticipant.sid}, before I connected');
       _addRemoteParticipantListeners(remoteParticipant);
     }
   }
   reload();
 }

 void _onConnectFailure(RoomConnectFailureEvent event) {
   print('[ APPDEBUG ] ConferenceRoom._onConnectFailure: ${event.exception}');
 }

 void _onParticipantConnected(RoomParticipantConnectedEvent event) {
   print(
       '[ APPDEBUG ] ConferenceRoom._onParticipantConnected, ${event.remoteParticipant.sid}');
   _addRemoteParticipantListeners(event.remoteParticipant);
   reload();
 }

 void _onParticipantDisconnected(RoomParticipantDisconnectedEvent event) {
   print(
       '[ APPDEBUG ] ConferenceRoom._onParticipantDisconnected: ${event.remoteParticipant.sid}');
   _participants.removeWhere(
       (ParticipantWidget p) => p.id == event.remoteParticipant.sid);
   reload();
 }

 void _addRemoteParticipantListeners(RemoteParticipant remoteParticipant) {
   _streamSubscriptions.add(remoteParticipant.onVideoTrackSubscribed
       .listen(_addOrUpdateParticipant));
   _streamSubscriptions.add(remoteParticipant.onAudioTrackSubscribed
       .listen(_addOrUpdateParticipant));
 }

 void _addOrUpdateParticipant(RemoteParticipantEvent event) {
   print(
       '[ APPDEBUG ] ConferenceRoom._addOrUpdateParticipant(), ${event.remoteParticipant.sid}');
   final participant = _participants.firstWhereOrNull(
     (ParticipantWidget participant) =>
         participant.id == event.remoteParticipant.sid,
   );

   if (participant != null) {
     print(
         '[ APPDEBUG ] Participant found: ${participant.id}, updating A/V enabled values');
   } else {
     if (event is RemoteVideoTrackSubscriptionEvent) {
       print(
           '[ APPDEBUG ] New participant, adding: ${event.remoteParticipant.sid}');
       _participants.insert(
         0,
         _buildParticipant(
           child: event.remoteVideoTrack.widget(),
           id: event.remoteParticipant.sid,
         ),
       );
       reload();
     }
   }
 }

Line 1-41: connect() — The function to connect to the Twilio Video room, in which we:

  • Set general rules (here, the output audio by default is the phone speaker)
  • Allow access to the camera
  • Connect to the room
  • Set listeners for connection events from remote streams in that room

For sake of simplicity, we won’t handle any case or scenario other than connection from a participant.

Line 43-46: disconnect() — Handling the participant's disconnection.

Line 56-86: _onConnected() — When a participant (ourself) is connected to the room for the first time, the application will listen to different streams from that participant (similar to a room). The application adds the local participant, as well as any remote participants who are already connected to the room, to the list of participants on the video call.

Line 92-105: _onParticipantConnected() and _onParticipantDisconnected() — These are the listeners that listen for participants’ connection and disconnection events.

Line 114-139: _addOrUpdateParticipant() — Adding a remote participant. Read the code carefully; it’s only possible to get the video widget from the remote participant after receiving a RemoteParticipantEvent (event -> video track -> widget). A common misunderstanding is to assume you can get the video widget from the list of remote participants in a room.

We are almost ready to go! We haven’t done anything fancy here — just connecting to our room, listening for new connections, and adding them to our list of participants. There’s just a little left to do.

Connect and disconnect from the Conference Room

The last thing remaining is to connect automatically when we create the cubit object for the conference room.

Go to the file lib/conference/conference_cubit.dart, and change the constructor as follows:


ConferenceCubit({
   required this.name,
   required this.token,
   required this.identity,
 }) : super(ConferenceInitial()) {
   connect();
 }

In lib/conference/conference_page.dart, find the call button widget, which looks like the code below:

                 Positioned(
                     bottom: 60,
                     child: IconButton(
                       color: Colors.red,
                       icon: Icon(
                         Icons.call_end_sharp,
                         color: Colors.white,
                       ),
                       onPressed: () async {},
                     ))


And then add the two highlighted lines below in the onPressed callback. In this code, we are grabbing the ConferenceCubit so we can disconnect from the Twilio call page, and then we will navigate back to the login screen:


                    child: IconButton(
                       color: Colors.red,
                       icon: Icon(
                         Icons.call_end_sharp,
                         color: Colors.white,
                       ),
                       onPressed: () async {
                         context.read<ConferenceCubit>().disconnect();
                         Navigator.of(context).pop();
                       },
                     )

Now that you have all the code you need, you can run the application on your device. If you have an Apple developer account, don’t forget to open XCode and set up automatic signing:

Signing menu in Xcode

It’s recommended to run the app on a real device, as you will be using the front camera (which is not available on an iOS simulator). Once you have signed in with your name, you will see the application running as shown in the gif below.

GIF showing the author joining the video room and waving into the camera

 

Conclusion

There are many other features you can add to this example application, including:

  • Showing the names of the participants
  • Showing the participants' audio and video statuses (audio muted / unmuted, video off / on) and some buttons to change the local participant's status
  • Improved handling of the connection / disconnection of participants

That should be enough to get you started with your first Twilio Video and Flutter project while utilizing a simple and easy BLoC architecture.

Hi, I’m Arnaud Phommasone, a 34 year old French mobile developer specializing in iOS and Flutter, who has been building mobile apps for 11 years now. Feel free to reach me on LinkedIn: https://www.linkedin.com/in/aphommasone.