Back to Blog

Real-Time Messaging and Video with Dynamic Channels Using the Agora Flutter SDK

Yesterday, I was dying of boredom while attending my Chemistry lecture, so I started looking at the software my college is using for their lectures. In this software, they list out a bunch of subjects per user and then display how many students are currently attending lectures for that particular subject.

This intrigued me a bit as to how easily a person can create a channel and then others can join that channel to interact with the host. So I thought of making a clone of this software using the Agora Flutter SDK. If you have seen our quickstart guide, then you know that both users need to enter the same channel name to get on a call. This creates a problem because the user can’t share the channel once it has been created without a proper back end.

To avoid going through that complex back-end route where we maintain a database of all the users and the channels they create, I leveraged the Agora RTM SDK to simplify the process. This way when a user joins, every host sends out a message with their channel name along with the number of users in it.

So grab a cup of coffee and let’s get started!

Prerequisites

  • An Agora developer account (see How to get started)
  • Flutter SDK
  • VS Code or Android Studio
  • A basic understanding of Flutter development

Project Overview

  • A user logs in to the RTM channel named lobby using a unique username. This channel is used to display all the preexisting channels and to signal the user as new channels are created.
  • So instead of asking all the users present in the lobby to share the channel details, we just ask the host or the oldest member in the channel to share the details.
  • The message is sent in the form of channelName:memberCount. Once this message is received, we map the channel name to the member count. This map is then used to display the list of active channels.
  • A user clicks one of the active channels, and then all the users in that channel are taken to a group video call.

Project Setup

1. We begin by creating a Flutter project. Open your terminal, navigate to your dev folder, and enter the following:

flutter create agora_dynamic_channels

2. Navigate to your pubspec.yaml file. In that file, add the following dependencies:

dependencies:
flutter:
sdk: flutter
cupertino_icons: ^1.0.2
agora_rtc_engine: ^3.3.1
agora_rtm: ^0.9.14
permission_handler: 5.0.1
loader_overlay: ^2.0.0

pubspec.yaml

Be careful with the indentation when adding the packages because you might get an error if the indentation is off.

3. In your project folder, run the following command to install all the dependencies:

flutter pub get

4. Once we have all the dependencies, we can create the file structure. Navigate to the lib folder and create a file structure like this:

Real-Time Messaging and Video with Dynamic Channels Using the Agora Flutter SDK - Screenshot #1
Project Structure

Building the Login Page

The login page is pretty simple: we just add an input field for the username and a submit button. This channel name is then shared with the next page — lobby.

@override
Widget build(BuildContext context) {
return Form(
key: InputForm._loginformKey,
child: Container(
width: MediaQuery.of(context).size.width * 0.8,
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: MediaQuery.of(context).size.width * 0.5,
child: Image.network(
'https://investor.agora.io/system/files-encrypted/nasdaq_kms/inline-images/agoralightblue-logo-updated.png'),
),
VerticalSpacer(
percentage: 0.1,
),
Container(
width: MediaQuery.of(context).size.width * 0.8,
child: TextFormField(
decoration: InputDecoration(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(15),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(
15,
),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(15),
borderSide: BorderSide(
width: 3,
color: Colors.red,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(15),
borderSide: BorderSide(color: Colors.blue, width: 2)),
prefixIcon: Icon(Icons.person),
hintText: 'Username',
),
validator: (value) {
if (value.isEmpty) {
return 'Please enter your Email ID';
} else {
return null;
}
},
controller: _username,
),
),
VerticalSpacer(
percentage: 0.25,
),
Container(
alignment: Alignment.bottomCenter,
width: MediaQuery.of(context).size.width * 0.8,
height: MediaQuery.of(context).size.width * 0.16,
child: MaterialButton(
color: Colors.blue,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(15)),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => LobbyPage(
username: _username.text,
),
),
);
},
child: Text(
'Sign in My Account',
style: TextStyle(color: Colors.white),
),
),
),
],
)),
);
}

Login page

This will create a page like this :

Real-Time Messaging and Video with Dynamic Channels Using the Agora Flutter SDK - Screenshot #2
login page design

Building the Lobby

We initialize our lobby by making all the users join a common RTM channel named lobby. We use this channel to share the details of the channel that a user creates.

The following steps occur in a user journey at the lobby page:

  1. The Agora RTM channel is initialized to join the channel named lobby.
  2. The user receives messages from the other users in the lobby channel about the channel details.
  3. The user either creates a channel of their own or subscribes to a preexisting channel. If the user joins a preexisting channel, then we just send a message to the host, which in turn increases the user count and shares it with the lobby. Or if the user decides to create their channel, then we create another object of RTM Channel and share the channel details to all the users in the lobby.
  4. Based on either of the user actions, the user is redirected to the callpage.
AgoraRtmClient _client;
AgoraRtmChannel _channel;
AgoraRtmChannel _subchannel;
@override
void dispose() {
_channel.leave();
_client.logout();
_client.destroy();
_seniorMember.clear();
_channelList.clear();
super.dispose();
}
@override
void initState() {
super.initState();
_createClient();
}
void _createClient() async {
_client = await AgoraRtmClient.createInstance(appID);
_client.onConnectionStateChanged = (int state, int reason) {
if (state == 5) {
_client.logout();
print('Logout.');
setState(() {
_isLogin = false;
});
}
};
String userId = widget.username;
await _client.login(null, userId);
print('Login success: ' + userId);
setState(() {
_isLogin = true;
});
_client.onMessageReceived = (AgoraRtmMessage message, String peerId) {
print('Client message received : ${message.text}');
var data = message.text.split(':');
setState(() {
_channelList.putIfAbsent(data[0], () => int.parse(data[1]));
});
};
_channel = await _createChannel("lobby");
await _channel.join();
print('RTM Join channel success.');
setState(() {
_isInChannel = true;
});
_client.onConnectionStateChanged = (int state, int reason) {
print('Connection state changed: ' + state.toString() +', reason: ' + reason.toString());
if (state == 5) {
_client.logout();
print('Logout.');
setState(() {
_isLogin = false;
});
}
};
}
Future<AgoraRtmChannel> _createChannel(String name) async {
AgoraRtmChannel channel = await _client.createChannel(name);
channel.onMemberJoined = (AgoraRtmMember member) async {
print("Member joined: " + member.userId + ', channel: ' + member.channelId);
_seniorMember.values.forEach(
(element) async {
if (element.first == widget.username) {
// retrieve the number of users in a channel from the _channelList
for (int i = 0; i < _channelList.length; i++) {
if (_channelList.keys.toList()[i] == myChannel) {
setState(() {
x = _channelList.values.toList()[i];
});
}
}
String data = myChannel + ':' + x.toString();
await _client.sendMessageToPeer(
member.userId, AgoraRtmMessage.fromText(data));
}
},
);
};
channel.onMemberLeft = (AgoraRtmMember member) async {
print("Member left: " + member.userId + ', channel: ' + member.channelId);
await leaveCall(member.channelId, member.userId);
};
channel.onMessageReceived = (AgoraRtmMessage message, AgoraRtmMember member) async {
var data = message.text.split(':');
if (_channelList.keys.contains(data[0])) {
setState(() {
_channelList.update(data[0], (v) => int.parse(data[1]));
});
if (int.parse(data[1]) >= 2 && int.parse(data[1]) < 5) {
await _handleCameraAndMic(Permission.camera);
await _handleCameraAndMic(Permission.microphone);
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => CallPage(channelName: data[0]),
),
);
}
} else {
setState(() {
_channelList.putIfAbsent(data[0], () => int.parse(data[1]));
});
}
};
return channel;
}
Future<void> _createChannels(String channelName) async {
setState(() {
_channelList.putIfAbsent(channelName, () => 1);
_seniorMember.putIfAbsent(channelName, () => [widget.username]);
myChannel = channelName;
});
await _channel.sendMessage(AgoraRtmMessage.fromText('$channelName' + ':' + '1'));
_channelFieldController.clear();
_subchannel = await _client.createChannel(channelName);
await _subchannel.join();
}
Future<void> joinCall(
String channelName, int numberOfPeopleInThisChannel) async {
_subchannel = await _client.createChannel(channelName);
await _subchannel.join();
setState(() {
numberOfPeopleInThisChannel = numberOfPeopleInThisChannel + 1;
});
_subchannel.getMembers().then(
(value) => value.forEach(
(element) {
setState(() {
_seniorMember.update(
channelName, (value) => value + [element.toString()]);
});
},
),
);
setState(() {
_channelList.update(channelName, (value) => numberOfPeopleInThisChannel);
});
_channel.sendMessage(AgoraRtmMessage.fromText('$channelName' + ':' + '$numberOfPeopleInThisChannel'));
if (numberOfPeopleInThisChannel >= 2 && numberOfPeopleInThisChannel < 5) {
await _handleCameraAndMic(Permission.camera);
await _handleCameraAndMic(Permission.microphone);
await _subchannel.leave();
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => CallPage(channelName: channelName),
),
);
}
}

lobby.dart

That might be a lot to register, so let’s break it down:

  1. _createClient(): This function is triggered as soon as you join the page so that a user can join the RTM channel named lobby. It also listens to the broadcast message from the hosts to get a list of active channels along with the user count in those channels.
  2. _createChannel(): This function listens to event handlers to maintain a list of active users and channels.
  3. _createChannels(): This function is used when a user plans to create their channel instead of joining an existing channel.
  4. _joinCall(): This function is used to navigate to the call screen. But before that, it creates another object of the RTM channel, which is used to join the selected channel. This way the user can join this subchannel while still being connected to the lobby channel.

This will give you a page similar to this:

Real-Time Messaging and Video with Dynamic Channels Using the Agora Flutter SDK - Screenshot #3
Empty Lobby
Real-Time Messaging and Video with Dynamic Channels Using the Agora Flutter SDK - Screenshot #4
Channels created by the host

Building the Video Chat Page

Here, we create a simple group video chat application using the Agora RTC SDK. All the users who selected the same channel name are grouped here in the same call.

Future<void> initialize() async {
if (appID.isEmpty) {
setState(() {
_infoStrings.add(
'APP_ID missing, please provide your APP_ID in settings.dart',
);
_infoStrings.add('Agora Engine is not starting');
});
return;
}
await _initAgoraRtcEngine();
_addAgoraEventHandlers();
await _engine.joinChannel(null, widget.channelName, null, 0);
}
/// Create agora sdk instance and initialize
Future<void> _initAgoraRtcEngine() async {
_engine = await RtcEngine.create(appID);
await _engine.enableVideo();
}
/// Add agora event handlers
void _addAgoraEventHandlers() {
_engine.setEventHandler(RtcEngineEventHandler(
error: (code) {
setState(() {
final info = 'onError: $code';
_infoStrings.add(info);
});
},
joinChannelSuccess: (channel, uid, elapsed) {
setState(() {
final info = 'onJoinChannel: $channel, uid: $uid';
_infoStrings.add(info);
});
},
leaveChannel: (stats) {
setState(() {
_infoStrings.add('onLeaveChannel');
_users.clear();
});
},
userJoined: (uid, elapsed) {
setState(() {
final info = 'userJoined: $uid';
_infoStrings.add(info);
_users.add(uid);
});
},
userOffline: (uid, reason) {
setState(() {
final info = 'userOffline: $uid , reason: $reason';
_infoStrings.add(info);
_users.remove(uid);
});
},
firstRemoteVideoFrame: (uid, width, height, elapsed) {
setState(() {
final info = 'firstRemoteVideoFrame: $uid';
_infoStrings.add(info);
});
},
));
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Agora Group Video Calling'),
),
backgroundColor: Colors.black,
body: Center(
child: Stack(
children: <Widget>[
_viewRows(),
_toolbar(),
],
),
),
);
}
/// Helper function to get list of native views
List<Widget> _getRenderViews() {
final List<StatefulWidget> list = [];
list.add(RtcLocalView.SurfaceView());
_users.forEach((int uid) => list.add(RtcRemoteView.SurfaceView(uid: uid)));
return list;
}
/// Video view wrapper
Widget _videoView(view) {
return Expanded(child: Container(child: view));
}
/// Video view row wrapper
Widget _expandedVideoRow(List<Widget> views) {
final wrappedViews = views.map<Widget>(_videoView).toList();
return Expanded(
child: Row(
children: wrappedViews,
),
);
}
/// Video layout wrapper
Widget _viewRows() {
final views = _getRenderViews();
switch (views.length) {
case 1:
return Container(
child: Column(
children: <Widget>[_videoView(views[0])],
));
case 2:
return Container(
child: Column(
children: <Widget>[
_expandedVideoRow([views[0]]),
_expandedVideoRow([views[1]])
],
));
case 3:
return Container(
child: Column(
children: <Widget>[
_expandedVideoRow(views.sublist(0, 2)),
_expandedVideoRow(views.sublist(2, 3))
],
));
case 4:
return Container(
child: Column(
children: <Widget>[
_expandedVideoRow(views.sublist(0, 2)),
_expandedVideoRow(views.sublist(2, 4))
],
));
default:
}
return Container();
}

Call Page

We initialize the AgoraRtcEngine with the App ID. The object of this method is then used to call other functions required to set up the call.

We then create a function to manage all our event handlers, which are triggered when a user joins a channel or leaves a channel. This helps in maintaining a list of UIDs. We use this list to render video feeds.

A user then joins a particular channel using the joinChannel() method. In the joinChannel() method, you get the option to use tokens along with the UID and the channel name.

Note:This project is meant for reference purposes and development environments. It is not intended for production environments. Token authentication is recommended for all RTE apps running in production environments. For more information about token-based authentication in the Agora platform, see this guide: https://bit.ly/3sNiFRs

Once, you have implemented the above given code, you will have a screen similar to this:

Real-Time Messaging and Video with Dynamic Channels Using the Agora Flutter SDK - Screenshot #5
RTC Implementation

Conclusion

Awesome! You have implemented a video chat application with dynamic channels built using the Agora Flutter SDK. In this app, the superuser is selected depending upon who created the channel. This user shares the details of the channel with others. This functionality makes the application scalable.

You can get the complete code for this application here.

Other Resources

To learn more about the Agora Flutter SDK and other use cases, you can refer to the developer guide given here.

You can also have a look at the complete documentation for the functions discussed above and many more here.

And I invite you to join the Agora Developer Slack Community.

RTE Telehealth 2023
Join us for RTE Telehealth - a virtual webinar where we’ll explore how AI and AR/VR technologies are shaping the future of healthcare delivery.

Try Agora for Free

Sign up and start building! You don’t pay until you scale.
Try for Free