Home
/ Blog /
How to build a Google Meet Clone in FlutterMay 11, 202237 min read
Share
Today connecting with anyone across the world is really easy. Thanks to real-time communication we can now talk to our friends and family worldwide.
Using Flutter, you can build video-calling applications for Android, iOS, web, and Windows with ease. In this article, we will see how to build a Google Meet clone in Flutter.
I’ll use 100ms SDK to add video conferencing to our Flutter app.
100ms is a cloud platform that allows developers to add video and audio conferencing to Web, Android, and iOS applications.
100ms SDK Flutter Quickstart Guide
Setup
To get started we’ll need to set up our project on the 100ms dashboard.
Note: 100ms offers great customization where you add/remove roles, provide permissions, and more to modify them according to your use case.
Let us set up our Flutter project for the clone.
flutter create flutter_meet
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Meet',
home: const HomeScreen(),
);
}
}
Since we are building a clone we already have designs to refer to :p
Let’s get started by building a home screen with two buttons and an App Bar.
Note: You can also get the UI code from here.
We’ll create a new folder in lib called screens and a file in it called home_screen.dart
In home_screen.dart
, let us create a Scaffold with an app bar and body as follow.
class HomeScreen extends StatelessWidget {
const HomeScreen({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return SafeArea(
child: Scaffold(
backgroundColor: Theme.of(context).primaryColor,
appBar: AppBar(
backgroundColor: Theme.of(context).primaryColor,
elevation: 0,
title: Text("Meet"),
centerTitle: true,
actions: [
IconButton(
icon: Icon(Icons.account_circle),
onPressed: () {},
),
],
),
body: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
OutlinedButton(
onPressed: () async {},
child: const Text('New meeting'),
),
OutlinedButton(
style: Theme.of(context)
.outlinedButtonTheme
.style!
.copyWith(
side: MaterialStateProperty.all(
BorderSide(color: Colors.white)),
backgroundColor: MaterialStateColor.resolveWith(
(states) => Colors.transparent),
foregroundColor: MaterialStateColor.resolveWith(
(states) => Colors.white)),
onPressed: () {},
child: const Text('Join with a code'))
],
)
],
),
),
);
}
}
We’ll also need to update the ThemeData in main.dart
as:
theme: ThemeData(
primaryColor: Colors.grey[900],
outlinedButtonTheme: OutlinedButtonThemeData(
style: ButtonStyle(
shape: MaterialStateProperty.all(RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20.0))),
backgroundColor: MaterialStateColor.resolveWith(
(states) => Colors.blueAccent),
foregroundColor: MaterialStateColor.resolveWith(
(states) => Colors.white)))),
This would build:
We can also add a navigation drawer to our Scaffold as follows:
drawer: Drawer(
child: ListView(
padding: EdgeInsets.zero,
children: [
DrawerHeader(
decoration: BoxDecoration(
color: Theme.of(context).primaryColor,
),
child: const Text(
'Google Meet',
style: TextStyle(fontSize: 25, color: Colors.white),
),
),
ListTile(
title: Row(
children: [
Icon(Icons.settings_outlined),
SizedBox(
width: 10,
),
const Text('Settings'),
],
),
onTap: () {},
),
ListTile(
title: Row(
children: [
Icon(Icons.feedback_outlined),
SizedBox(
width: 10,
),
const Text('Send feedback'),
],
),
onTap: () {},
),
ListTile(
title: Row(
children: [
Icon(Icons.help_outline),
SizedBox(
width: 10,
),
const Text('Help'),
],
),
onTap: () {},
),
],
),
),
Now, to start a meeting the user can click on the ‘New meeting’ button which should push a bottom sheet to ‘Start an instant meeting’ as is on Google Meet.
Add the showModalBottomSheet
code to the onPressed of the OutlinedButton with the ‘New meeting’ text.
showModalBottomSheet<void>(
context: context,
builder: (BuildContext context) {
return Container(
height: 200,
child: ListView(
padding: EdgeInsets.zero,
children: <Widget>[
ListTile(
title: Row(
children: const [
Icon(Icons.video_call),
SizedBox(
width: 10,
),
Text('Start an instant meeting'),
],
),
onTap: () async {
},
),
ListTile(
title: Row(
children: [
Icon(Icons.close),
SizedBox(
width: 10,
),
const Text('Close'),
],
),
onTap: () {
Navigator.pop(context);
},
),
],
),
);
},
);
This should create:
With that done, we’ll have most of the UI of the HomeScreen created.
You can find the complete code here.
Before we set up the meeting, we can push in an empty screen with an AppBar when clicking on the ‘Start an instant meeting’ tile which would look like this:
Find the code for this here
To get started, we’ll need to add the Flutter package for 100ms SDK. You can find it on pub.dev here.
Add the 100ms Flutter SDK in your pubspec.yaml
as follows:
hmssdk_flutter: 1.1.0
We’ll also need to add a few other packages to pubspec.yaml
file namely:
permission_handler: 8.3.0
http: 0.13.4
provider: 6.0.2
draggable_widget: 2.0.0
cupertino_icons: 1.0.2
Note: The versions used are the latest at the time of writing this article.
I’ll explain why we’ll need it as we move along with the tutorial ✌️
Create a new folder service with a file sdk_initializer.dart
in it.
Add the following code to it:
import 'package:hmssdk_flutter/hmssdk_flutter.dart';
class SdkInitializer {
static HMSSDK hmssdk = HMSSDK();
}
This file would have a static instance of the HMSSDK .
Now, to join a video call, we can call the join method on HMSSDK with the config settings. This would require an authentication token and a room id.
In production your own server will generate these and manage user authentication.
To get an auth token, we need to send an HTTP post request to the Token endpoint which can be obtained from the dashboard.
Go to Developer -> Copy Token endpoint (under Access Credentials)
For example, my Token endpoint is: https://prod-in.100ms.live/hmsapi/adityathakur.app.100ms.live/
Create a new file called join_service.dart
inside the same services folder and paste the following code replacing the roomId with your room id (copied earlier) and the endPoint with your Token endpoint.
import 'package:hmssdk_flutter/hmssdk_flutter.dart';
import 'package:http/http.dart' as http;
import 'dart:convert';
class JoinService {
static Future<bool> join(HMSSDK hmssdk) async {
String roomId = "<Your Room ID>";
Uri endPoint = Uri.parse(
"https://prod-in.100ms.live/hmsapi/adityathakur.app.100ms.live/api/token");
http.Response response = await http.post(endPoint,
body: {'user_id': "user", 'room_id': roomId, 'role': "host"});
var body = json.decode(response.body);
if (body == null || body['token'] == null) {
return false;
}
HMSConfig config = HMSConfig(authToken: body['token'], userName: "user");
await hmssdk.join(config: config);
return true;
}
}
Note: Don’t forget to append the API/token to your Token endpoint as shown in the sample code above.
A successful HTTP post would return us a token that can be passed to the join method of hmssdk as config.
The only steps now left are to listen to these changes effectively and update our UI accordingly.
We’ll create a UserDataStore that would implement the abstract class HMSUpdateListener.
HMSUpdateListener listens to all the updates happening inside the room. 100ms SDK provides callbacks to the client app about any change or update happening in the room after a user has joined by implementing HMSUpdateListener.
The UserDataStore would extend the ChangeNotifier to notify of any changes.
Create a new folder inside lib called models and add data_store.dart
to it.
//Dart imports
import 'dart:developer';
//Package imports
import 'package:flutter/material.dart';
import 'package:hmssdk_flutter/hmssdk_flutter.dart';
//File imports
import 'package:google_meet/services/sdk_initializer.dart';
class UserDataStore extends ChangeNotifier
implements HMSUpdateListener, HMSActionResultListener {
HMSTrack? remoteVideoTrack;
HMSPeer? remotePeer;
HMSTrack? remoteAudioTrack;
HMSVideoTrack? localTrack;
bool _disposed = false;
late HMSPeer localPeer;
bool isRoomEnded = false;
void startListen() {
SdkInitializer.hmssdk.addUpdateListener(listener: this);
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
void leaveRoom() async {
SdkInitializer.hmssdk.leave(hmsActionResultListener: this);
}
@override
void notifyListeners() {
if (!_disposed) {
super.notifyListeners();
}
}
@override
void onJoin({required HMSRoom room}) {
for (HMSPeer each in room.peers!) {
if (each.isLocal) {
localPeer = each;
break;
}
}
}
@override
void onPeerUpdate({required HMSPeer peer, required HMSPeerUpdate update}) {
switch (update) {
case HMSPeerUpdate.peerJoined:
remotePeer = peer;
remoteAudioTrack = peer.audioTrack;
remoteVideoTrack = peer.videoTrack;
break;
case HMSPeerUpdate.peerLeft:
remotePeer = null;
break;
case HMSPeerUpdate.roleUpdated:
break;
case HMSPeerUpdate.metadataChanged:
break;
case HMSPeerUpdate.nameChanged:
break;
case HMSPeerUpdate.defaultUpdate:
break;
case HMSPeerUpdate.networkQualityUpdated:
break;
}
notifyListeners();
}
@override
void onTrackUpdate(
{required HMSTrack track,
required HMSTrackUpdate trackUpdate,
required HMSPeer peer}) {
switch (trackUpdate) {
case HMSTrackUpdate.trackAdded:
if (track.kind == HMSTrackKind.kHMSTrackKindAudio) {
if (!peer.isLocal) remoteAudioTrack = track;
} else if (track.kind == HMSTrackKind.kHMSTrackKindVideo) {
if (!peer.isLocal) {
remoteVideoTrack = track;
} else {
localTrack = track as HMSVideoTrack;
}
}
break;
case HMSTrackUpdate.trackRemoved:
if (track.kind == HMSTrackKind.kHMSTrackKindAudio) {
if (!peer.isLocal) remoteAudioTrack = null;
} else if (track.kind == HMSTrackKind.kHMSTrackKindVideo) {
if (!peer.isLocal) {
remoteVideoTrack = null;
} else {
localTrack = null;
}
}
break;
case HMSTrackUpdate.trackMuted:
if (track.kind == HMSTrackKind.kHMSTrackKindAudio) {
if (!peer.isLocal) remoteAudioTrack = track;
} else if (track.kind == HMSTrackKind.kHMSTrackKindVideo) {
if (!peer.isLocal) {
remoteVideoTrack = track;
} else {
localTrack = null;
}
}
break;
case HMSTrackUpdate.trackUnMuted:
if (track.kind == HMSTrackKind.kHMSTrackKindAudio) {
if (!peer.isLocal) remoteAudioTrack = track;
} else if (track.kind == HMSTrackKind.kHMSTrackKindVideo) {
if (!peer.isLocal) {
remoteVideoTrack = track;
} else {
localTrack = track as HMSVideoTrack;
}
}
break;
case HMSTrackUpdate.trackDescriptionChanged:
break;
case HMSTrackUpdate.trackDegraded:
break;
case HMSTrackUpdate.trackRestored:
break;
case HMSTrackUpdate.defaultUpdate:
break;
}
notifyListeners();
}
@override
void onHMSError({required HMSException error}) {
log(error.message??"");
}
@override
void onMessage({required HMSMessage message}) {}
@override
void onRoomUpdate({required HMSRoom room, required HMSRoomUpdate update}) {}
@override
void onUpdateSpeakers({required List<HMSSpeaker> updateSpeakers}) {}
@override
void onReconnected() {}
@override
void onReconnecting() {}
@override
void onRemovedFromRoom(
{required HMSPeerRemovedFromPeer hmsPeerRemovedFromPeer}) {}
@override
void onRoleChangeRequest({required HMSRoleChangeRequest roleChangeRequest}) {}
@override
void onChangeTrackStateRequest(
{required HMSTrackChangeRequest hmsTrackChangeRequest}) {}
@override
void onAudioDeviceChanged(
{HMSAudioDevice? currentAudioDevice,
List<HMSAudioDevice>? availableAudioDevice}) {}
@override
void onException(
{required HMSActionResultListenerMethod methodType,
Map<String, dynamic>? arguments,
required HMSException hmsException}) {
// TODO: implement onException
switch (methodType) {
case HMSActionResultListenerMethod.leave:
log("Leave room error ${hmsException.message}");
}
}
@override
void onSuccess(
{required HMSActionResultListenerMethod methodType,
Map<String, dynamic>? arguments}) {
switch (methodType) {
case HMSActionResultListenerMethod.leave:
isRoomEnded = true;
notifyListeners();
}
}
}
In the above code, we have implemented a few callbacks:
Audio is automatically connected, video needs to be configured.
Let us return to our screens folder to now update the UI with changes.
To use the camera and microphone in our video-calling app, we need to get the required permissions from the user!
We can use the previously added permission_handler package to do that easily.
void getPermissions() async {
await Permission.camera.request();
await Permission.microphone.request();
while ((await Permission.camera.isDenied)) {
await Permission.camera.request();
}
while ((await Permission.microphone.isDenied)) {
await Permission.microphone.request();
}
}
@override
void initState() {
SdkInitializer.hmssdk.build();
getPermissions();
super.initState();
}
We’ll also call the build()
on the hmssdk instance in the same initState()
.
Create a new function joinRoom()
which would handle the room joining functionality for us.
Future<bool> joinRoom() async {
setState(() {
_isLoading = true;
});
bool isJoinSuccessful = await JoinService.join(SdkInitializer.hmssdk);
if (!isJoinSuccessful) {
return false;
}
_dataStore = UserDataStore();
//Here we are attaching a listener to our DataStoreClass
_dataStore.startListen();
setState(() {
_isLoading = false;
});
return true;
}
In this method, we call on join of our previously created class JoinService .
We are also attaching a listener to our DataStoreClass and using a local _isLoading
while these tasks are completed. The _isLoading
is a boolean which is initially set to false.
We can use this _isLoading
to show a CircularProgressIndicator while the tasks are completed.
We need to now configure the onTap of our ‘Start an instant meeting’ as follows:
bool isJoined = await joinRoom();
if (isJoined) {
Navigator.of(context).push(
MaterialPageRoute( builder: (_) =>
ListenableProvider.value(
value: _dataStore,
child: MeetingScreen())));
} else {
const SnackBar(content: Text("Error"));
}
We wait for the joinRoom function, if the return value is true, we push the MeetingScreen wrapped with a ListenableProvider and value set to _dataStore
else we show an error SnackBar.
To summarize, the sequence to be followed while using hmssdk
is:
You can find the complete HomeScreen code here
The MeetingScreen would be a StatefulWidget. We’ll need that to setState()
whenever there are any changes in the local or remote users.
We’ll start by creating a few local variables to be used later:
bool isLocalAudioOn = true;
bool isLocalVideoOn = true;
final bool _isLoading = false;
And, an onLeave method (which would help leave the room and pop the screen):
Future<bool> leaveRoom() async {
SdkInitializer.hmssdk.leave();
Navigator.pop(context);
return false;
}
Now, when the local user starts and joins the meeting we’d show them a waiting screen with the text “You’re the only one here” as is on the Google Meet app.
When a remote user joins in, the video of them should be shown.
The MeetingScreen should also have buttons to mute/unmute the mic, switch on/off the video and disconnect the call.
This all can be implemented as follows:
//Package imports
import 'package:draggable_widget/draggable_widget.dart';
import 'package:flutter/material.dart';
import 'package:hmssdk_flutter/hmssdk_flutter.dart';
import 'package:provider/provider.dart';
//File imports
import 'package:google_meet/models/data_store.dart';
import 'package:google_meet/services/sdk_initializer.dart';
class MeetingScreen extends StatefulWidget {
const MeetingScreen({Key? key}) : super(key: key);
@override
_MeetingScreenState createState() => _MeetingScreenState();
}
class _MeetingScreenState extends State<MeetingScreen> {
bool isLocalAudioOn = true;
bool isLocalVideoOn = true;
final bool _isLoading = false;
@override
Widget build(BuildContext context) {
final _isVideoOff = context.select<UserDataStore, bool>(
(user) => user.remoteVideoTrack?.isMute ?? true);
final _peer =
context.select<UserDataStore, HMSPeer?>((user) => user.remotePeer);
final remoteTrack = context
.select<UserDataStore, HMSTrack?>((user) => user.remoteVideoTrack);
final localTrack = context
.select<UserDataStore, HMSVideoTrack?>((user) => user.localTrack);
return WillPopScope(
onWillPop: () async {
context.read<UserDataStore>().leaveRoom();
Navigator.pop(context);
return true;
},
child: SafeArea(
child: Scaffold(
body: (_isLoading)
? const CircularProgressIndicator()
: (_peer == null)
? Container(
color: Colors.black.withOpacity(0.9),
width: MediaQuery.of(context).size.width,
height: MediaQuery.of(context).size.height,
child: Stack(
children: [
Positioned(
child: IconButton(
onPressed: () {
context.read<UserDataStore>().leaveRoom();
Navigator.pop(context);
},
icon: const Icon(
Icons.arrow_back_ios,
color: Colors.white,
))),
Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: const [
Padding(
padding:
EdgeInsets.only(left: 20.0, bottom: 20),
child: Text(
"You're the only one here",
style: TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold),
),
),
Padding(
padding: EdgeInsets.only(left: 20.0),
child: Text(
"Share meeting link with others",
style: TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold),
),
),
Padding(
padding: EdgeInsets.only(left: 20.0),
child: Text(
"that you want in the meeting",
style: TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold),
),
),
Padding(
padding: EdgeInsets.only(left: 20.0, top: 10),
child: CircularProgressIndicator(
strokeWidth: 2,
),
),
],
),
DraggableWidget(
topMargin: 10,
bottomMargin: 130,
horizontalSpace: 10,
child: localPeerTile(localTrack),
),
],
),
)
: SizedBox(
height: MediaQuery.of(context).size.height,
width: MediaQuery.of(context).size.width,
child: Stack(
children: [
Container(
color: Colors.black.withOpacity(0.9),
child: _isVideoOff
? Center(
child: Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color:
Colors.blue.withAlpha(60),
blurRadius: 10.0,
spreadRadius: 2.0,
),
]),
child: const Icon(
Icons.videocam_off,
color: Colors.white,
size: 30,
),
),
)
: (remoteTrack != null)
? Container(
child: HMSVideoView(
scaleType: ScaleType.SCALE_ASPECT_FILL,
track: remoteTrack as HMSVideoTrack,
),
)
: const Center(child: Text("No Video"))),
Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: const EdgeInsets.only(bottom: 15),
child: Row(
mainAxisAlignment:
MainAxisAlignment.spaceEvenly,
children: [
GestureDetector(
onTap: () async {
context.read<UserDataStore>().leaveRoom();
Navigator.pop(context);
},
child: Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.red.withAlpha(60),
blurRadius: 3.0,
spreadRadius: 5.0,
),
]),
child: const CircleAvatar(
radius: 25,
backgroundColor: Colors.red,
child: Icon(Icons.call_end,
color: Colors.white),
),
),
),
GestureDetector(
onTap: () => {
SdkInitializer.hmssdk
.toggleCameraMuteState(),
setState(() {
isLocalVideoOn = !isLocalVideoOn;
})
},
child: CircleAvatar(
radius: 25,
backgroundColor:
Colors.transparent.withOpacity(0.2),
child: Icon(
isLocalVideoOn
? Icons.videocam
: Icons.videocam_off_rounded,
color: Colors.white,
),
),
),
GestureDetector(
onTap: () => {
SdkInitializer.hmssdk
.toggleMicMuteState(),
setState(() {
isLocalAudioOn = !isLocalAudioOn;
})
},
child: CircleAvatar(
radius: 25,
backgroundColor:
Colors.transparent.withOpacity(0.2),
child: Icon(
isLocalAudioOn
? Icons.mic
: Icons.mic_off,
color: Colors.white,
),
),
),
],
),
),
),
Positioned(
top: 10,
left: 10,
child: GestureDetector(
onTap: () {
context.read<UserDataStore>().leaveRoom();
Navigator.pop(context);
},
child: const Icon(
Icons.arrow_back_ios,
color: Colors.white,
),
),
),
Positioned(
top: 10,
right: 10,
child: GestureDetector(
onTap: () {
if (isLocalVideoOn) {
SdkInitializer.hmssdk.switchCamera();
}
},
child: CircleAvatar(
radius: 25,
backgroundColor:
Colors.transparent.withOpacity(0.2),
child: const Icon(
Icons.switch_camera_outlined,
color: Colors.white,
),
),
),
),
DraggableWidget(
topMargin: 10,
bottomMargin: 130,
horizontalSpace: 10,
child: localPeerTile(localTrack),
),
],
),
),
),
),
);
}
Widget localPeerTile(HMSVideoTrack? localTrack) {
return ClipRRect(
borderRadius: BorderRadius.circular(10),
child: Container(
height: 150,
width: 100,
color: Colors.black,
child: (isLocalVideoOn && localTrack != null)
? HMSVideoView(
track: localTrack,
)
: const Icon(
Icons.videocam_off_rounded,
color: Colors.white,
),
),
);
}
}
The code above renders a waiting screen while the local user waits for the remote user to join in and show their video once available.
You can join the room using your mobile device as a local user.
For remote user link:
Voila 🎉 We have successfully created a Google Meet clone in Flutter using 100ms SDK for video functionality.
You can find the complete code here.
Thank you! ✌️
Engineering
Share
Related articles
See all articles