Build Video Call App with Flutter and ZEGOCLOUD SDK
Hello everyone, this time we will build a video call application using the Flutter framework with the video call SDK from ZEGOCLOUD. ZEGOCLOUD is a platform that provides global communication services such as Video Call, Live-Streaming, Chat API, and many more.
ZEGOCLOUD offers a ui kit to use their products. including to use their video call product, which you can check here. However, this time we will implement the video call SDK version or the full custom version.
First of all, you must have a zegocloud account first. The good news is, zegocloud offers free credit for new users. By registering a new account, you can get 10,000 minutes for free.
Prepare
After register, login to your account then open zegocloud dashboard.
- Create your zegocloud project.
- Select Voice & Video Call.
- Input project name, e.g., "VCall_App".
- Start with Custom SDKs.
- Open Detail Project ZEGOCLOUD in Project Information Tab, you will find AppID and AppSign. Kepp this secret and don't share to public.
After create ZEGOCLOUD project for Video Call. Move on to creating a Flutter App.
- Flutter: 3.29.0
- IDE: Visual Studio Code
- OS: Microsoft Windows 11
- Android Studio: 2024.2
- Create Flutter project.
- Run this command to add zego engine package
flutter pub add zego_express_engine
- Create new Class to store ZEGOCLOUD Secret keys.
class ZegocloudKeys {
static const appId = 0;
static const appSign = '';
}
- Check here to setup Android Gradle which are using Android Studio latest version and Flutter latest version.
Configure Project
Android
Add these permission to Android manifest inside manifest tag.
<!-- Permissions required by the SDK -->
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<!-- Permissions required by the Demo App -->
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-feature android:glEsVersion="0x00020000" android:required="true" />
<uses-feature android:name="android.hardware.camera" />
<uses-feature android:name="android.hardware.camera.autofocus" />
to prevent code obfuscation for Android devices, you can setup android guard then implement to gradle.
-keep class **.zego.** { *; }
# to shrink your code with R8 add below
-dontwarn com.itgsa.opensdk.mediaunit.KaraokeMediaHelper
- .gradle.kts
- .gradle
...
android {
...
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
proguardFiles(getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro")
}
}
}
...
...
android {
...
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
}
...
iOS
Add permission to Info.plist inside dict tag.
<key>NSCameraUsageDescription</key>
<string>We require camera access to connect</string>
<key>NSMicrophoneUsageDescription</key>
<string>We require microphone access to connect</string>
Login Page
First we create an entry point page to get user id and username. You can also add room id input from the beginning. For that, we will using login template from Flutter Delux.
Open auth sample page template, copy & paste to login_page.dart and modify like below.
lib/pages/login_page.dart
import 'package:flutter/material.dart';
import 'package:d_input/d_input.dart';
import 'package:gap/gap.dart';
class LoginPage extends StatefulWidget {
const LoginPage({super.key});
State<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends State<LoginPage> {
final _userIdController = TextEditingController();
final _userNameController = TextEditingController();
final _roomIdController = TextEditingController();
void _executeLogin() {
final userId = _userIdController.text;
if (userId == '') return _showInvalidMessage('User Id must be filled');
final userName = _userNameController.text;
if (userName == '') return _showInvalidMessage('User Name must be filled');
final roomId = _roomIdController.text;
if (roomId == '') return _showInvalidMessage('Room Id must be filled');
Navigator.pushNamed(
context,
'/call',
arguments: {'userId': userId, 'userName': userName, 'roomId': roomId},
);
}
void dispose() {
_userIdController.dispose();
_userNameController.dispose();
_roomIdController.dispose();
super.dispose();
}
Widget build(BuildContext context) {
return Scaffold(
body: LayoutBuilder(
builder: (context, constraints) {
return SingleChildScrollView(
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: constraints.maxHeight),
child: Padding(
padding: const EdgeInsets.all(30),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircleAvatar(radius: 80, child: Icon(Icons.call, size: 80)),
Gap(30),
Text(
'VCall App',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 30,
color: Theme.of(context).primaryColor,
),
),
Gap(70),
DInputMix(controller: _userIdController, hint: 'User Id'),
Gap(20),
DInputMix(
controller: _userNameController,
hint: 'User Name',
),
Gap(20),
DInputMix(controller: _roomIdController, hint: 'Room Id'),
Gap(20),
SizedBox(
width: double.infinity,
height: 50,
child: FilledButton(
style: ButtonStyle(
shape: WidgetStatePropertyAll(
RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
),
),
onPressed: _executeLogin,
child: Text('LOGIN'),
),
),
],
),
),
),
);
},
),
);
}
void _showInvalidMessage(String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message, style: TextStyle(color: Colors.white)),
duration: Duration(seconds: 2),
backgroundColor: Colors.red,
),
);
}
}
Run flutter pub add d_input gap
to add package required from template. Inside Material App, setup launch route to Login page.
import 'package:flutter/material.dart';
import 'package:flutter_zegocloud_vcall/pages/login_page.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
runApp(const MainApp());
}
class MainApp extends StatelessWidget {
const MainApp({super.key});
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
routes: {'/': (context) => LoginPage()},
);
}
}
Login Page will be like this:


Create Engine
Create function to initialize zegocloud engine, put inside main.dart and call inside main function.
import 'package:zego_express_engine/zego_express_engine.dart';
import 'package:flutter_zegocloud_vcall/config/zegocloud_keys.dart';
Future<void> createEngine() async {
await ZegoExpressEngine.createEngineWithProfile(
ZegoEngineProfile(
ZegocloudKeys.appId,
ZegoScenario.Default,
appSign: ZegocloudKeys.appSign,
),
);
}
void main() {
WidgetsFlutterBinding.ensureInitialized();
createEngine();
runApp(const MainApp());
}
Call Page
Arguments
Create Call Page class with Statefull Widget and create 3 argument to receive data were sent from Login Page.
...
const CallPage({
super.key,
required this.userId,
required this.userName,
required this.roomId,
});
final String userId;
final String userName;
final String roomId;
...
Register call page to route in MaterialApp.
...
return MaterialApp(
debugShowCheckedModeBanner: false,
routes: {
'/': (context) => LoginPage(),
'/call': (context) {
final data = ModalRoute.settingsOf(context)?.arguments as Map?;
if (data == null) {
return Scaffold(appBar: AppBar(title: Text('Invalid Argument')));
}
return CallPage(
userId: data['userId'],
userName: data['userName'],
roomId: data['roomId'],
);
},
},
);
...
User View
Create variable to handle user local and user remote.
Widget? localView;
int? localViewID;
Widget? remoteView;
int? remoteViewID;
UI
Build method inside call page will be like this:
...
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text("Call Page")),
body: Stack(
fit: StackFit.expand,
children: [
Positioned.fill(child: localView ?? ColoredBox(color: Colors.black)),
Positioned(
top: MediaQuery.sizeOf(context).height / 20,
right: MediaQuery.sizeOf(context).width / 20,
width: MediaQuery.sizeOf(context).width / 4,
child: AspectRatio(
aspectRatio: 9 / 16,
child:
remoteView ??
Container(
color: Colors.black,
child: Icon(Icons.not_interested, color: Colors.red),
),
),
),
Positioned(
bottom: MediaQuery.of(context).size.height / 20,
left: 0,
right: 0,
child: Center(
child: IconButton.filled(
style: IconButton.styleFrom(backgroundColor: Colors.red),
onPressed: () => Navigator.pop(context),
icon: Icon(Icons.call_end, size: 32),
),
),
),
],
),
);
}
...
Call Process
void initState() {
try {
startListen();
loginRoom();
} catch (e) {
log(e.toString());
}
super.initState();
}
void dispose() {
try {
stopListen();
logoutRoom();
} catch (e) {
log(e.toString());
}
super.dispose();
}
Functions
For User Local
loginRoom
Future<void> loginRoom() async {
final user = ZegoUser(widget.userId, widget.userName);
ZegoRoomConfig roomConfig =
ZegoRoomConfig.defaultConfig()..isUserStatusNotify = true;
final loginRoomResult = await ZegoExpressEngine.instance.loginRoom(
widget.roomId,
user,
config: roomConfig,
);
log(
'loginRoom: errorCode:${loginRoomResult.errorCode}, extendedData:${loginRoomResult.extendedData}',
);
if (loginRoomResult.errorCode != 0) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Call Failed')));
return;
}
startPreview();
publishStream();
}
logoutRoom
Future<void> logoutRoom() async {
stopPreview();
unpublishStream();
await ZegoExpressEngine.instance.logoutRoom(widget.roomId);
}
startPreview
Future<void> startPreview() async {
final canvasViewWidget = await ZegoExpressEngine.instance.createCanvasView((
viewID,
) {
localViewID = viewID;
ZegoExpressEngine.instance.startPreview(
canvas: ZegoCanvas(viewID, viewMode: ZegoViewMode.AspectFill),
);
});
localView = canvasViewWidget;
setState(() {});
}
stopPreview
Future<void> stopPreview() async {
ZegoExpressEngine.instance.stopPreview();
if (localViewID == null) return;
await ZegoExpressEngine.instance.destroyCanvasView(localViewID!);
if (!mounted) return;
localView = null;
localViewID = null;
setState(() {});
}
publishStream
Future<void> publishStream() async {
final streamID = '${widget.roomId}_${widget.userId}_call';
await ZegoExpressEngine.instance.startPublishingStream(streamID);
}
unpublishStream
Future<void> unpublishStream() async {
return ZegoExpressEngine.instance.stopPublishingStream();
}
For User Remote
startListen
void startListen() {
ZegoExpressEngine.onRoomUserUpdate = (
roomID,
updateType,
List<ZegoUser> users,
) {
log(
'onRoomUserUpdate: roomID: $roomID, updateType: ${updateType.name}, userList: ${users.map((e) => e.userID)}',
);
};
ZegoExpressEngine.onRoomStreamUpdate = (
roomID,
updateType,
List<ZegoStream> streams,
extendedData,
) {
log(
'onRoomStreamUpdate: roomID: $roomID, updateType: ${updateType.name}, streams: ${streams.map((e) => e.user.userName)}, extendedData: $extendedData',
);
if (updateType == ZegoUpdateType.Add) {
for (final stream in streams) {
startStream(stream.streamID);
}
} else {
for (final stream in streams) {
stopStream(stream.streamID);
}
}
};
ZegoExpressEngine.onRoomStateUpdate = (
roomID,
state,
errorCode,
extendedData,
) {
log(
'onRoomStateUpdate: roomID: $roomID, state: ${state.name}, errorCode: $errorCode, extendedData: $extendedData',
);
};
ZegoExpressEngine.onPublisherStateUpdate = (
streamID,
state,
errorCode,
extendedData,
) {
log(
'onPublisherStateUpdate: streamID: $streamID, state: ${state.name}, errorCode: $errorCode, extendedData: $extendedData',
);
};
}
stopListen
void stopListen() {
ZegoExpressEngine.onRoomUserUpdate = null;
ZegoExpressEngine.onRoomStreamUpdate = null;
ZegoExpressEngine.onRoomStateUpdate = null;
ZegoExpressEngine.onPublisherStateUpdate = null;
}
startStream
Future<void> startStream(String streamID) async {
final canvasViewWidget = await ZegoExpressEngine.instance.createCanvasView((
viewID,
) {
remoteViewID = viewID;
ZegoExpressEngine.instance.startPlayingStream(
streamID,
canvas: ZegoCanvas(viewID, viewMode: ZegoViewMode.AspectFill),
);
});
remoteView = canvasViewWidget;
setState(() {});
}
stopStream
Future<void> stopStream(String streamID) async {
await ZegoExpressEngine.instance.stopPlayingStream(streamID);
if (remoteViewID == null) return;
await ZegoExpressEngine.instance.destroyCanvasView(remoteViewID!);
if (!mounted) return;
remoteView = null;
remoteViewID = null;
setState(() {});
}
Call Page Full Code
Here for call page full code.
lib/pages/call_page.dart
import 'dart:developer';
import 'package:flutter/material.dart';
import 'package:zego_express_engine/zego_express_engine.dart';
class CallPage extends StatefulWidget {
const CallPage({
super.key,
required this.userId,
required this.userName,
required this.roomId,
});
final String userId;
final String userName;
final String roomId;
State<CallPage> createState() => _CallPageState();
}
class _CallPageState extends State<CallPage> {
Widget? localView;
int? localViewID;
Widget? remoteView;
int? remoteViewID;
void initState() {
try {
startListen();
loginRoom();
} catch (e) {
log(e.toString());
}
super.initState();
}
void dispose() {
try {
stopListen();
logoutRoom();
} catch (e) {
log(e.toString());
}
super.dispose();
}
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text("Call Page")),
body: Stack(
fit: StackFit.expand,
children: [
Positioned.fill(child: localView ?? ColoredBox(color: Colors.black)),
Positioned(
top: MediaQuery.sizeOf(context).height / 20,
right: MediaQuery.sizeOf(context).width / 20,
width: MediaQuery.sizeOf(context).width / 4,
child: AspectRatio(
aspectRatio: 9 / 16,
child:
remoteView ??
Container(
color: Colors.black,
child: Icon(Icons.not_interested, color: Colors.red),
),
),
),
Positioned(
bottom: MediaQuery.of(context).size.height / 20,
left: 0,
right: 0,
child: Center(
child: IconButton.filled(
style: IconButton.styleFrom(backgroundColor: Colors.red),
onPressed: () => Navigator.pop(context),
icon: Icon(Icons.call_end, size: 32),
),
),
),
],
),
);
}
Future<void> loginRoom() async {
final user = ZegoUser(widget.userId, widget.userName);
ZegoRoomConfig roomConfig =
ZegoRoomConfig.defaultConfig()..isUserStatusNotify = true;
final loginRoomResult = await ZegoExpressEngine.instance.loginRoom(
widget.roomId,
user,
config: roomConfig,
);
log(
'loginRoom: errorCode:${loginRoomResult.errorCode}, extendedData:${loginRoomResult.extendedData}',
);
if (loginRoomResult.errorCode != 0) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Call Failed')));
return;
}
startPreview();
publishStream();
}
Future<void> logoutRoom() async {
stopPreview();
unpublishStream();
await ZegoExpressEngine.instance.logoutRoom(widget.roomId);
}
Future<void> startPreview() async {
final canvasViewWidget = await ZegoExpressEngine.instance.createCanvasView((
viewID,
) {
localViewID = viewID;
ZegoExpressEngine.instance.startPreview(
canvas: ZegoCanvas(viewID, viewMode: ZegoViewMode.AspectFill),
);
});
localView = canvasViewWidget;
setState(() {});
}
Future<void> stopPreview() async {
ZegoExpressEngine.instance.stopPreview();
if (localViewID == null) return;
await ZegoExpressEngine.instance.destroyCanvasView(localViewID!);
if (!mounted) return;
localView = null;
localViewID = null;
setState(() {});
}
Future<void> publishStream() async {
final streamID = '${widget.roomId}_${widget.userId}_call';
await ZegoExpressEngine.instance.startPublishingStream(streamID);
}
Future<void> unpublishStream() async {
return ZegoExpressEngine.instance.stopPublishingStream();
}
void startListen() {
ZegoExpressEngine.onRoomUserUpdate = (
roomID,
updateType,
List<ZegoUser> users,
) {
log(
'onRoomUserUpdate: roomID: $roomID, updateType: ${updateType.name}, userList: ${users.map((e) => e.userID)}',
);
};
ZegoExpressEngine.onRoomStreamUpdate = (
roomID,
updateType,
List<ZegoStream> streams,
extendedData,
) {
log(
'onRoomStreamUpdate: roomID: $roomID, updateType: ${updateType.name}, streams: ${streams.map((e) => e.user.userName)}, extendedData: $extendedData',
);
if (updateType == ZegoUpdateType.Add) {
for (final stream in streams) {
startStream(stream.streamID);
}
} else {
for (final stream in streams) {
stopStream(stream.streamID);
}
}
};
ZegoExpressEngine.onRoomStateUpdate = (
roomID,
state,
errorCode,
extendedData,
) {
log(
'onRoomStateUpdate: roomID: $roomID, state: ${state.name}, errorCode: $errorCode, extendedData: $extendedData',
);
};
ZegoExpressEngine.onPublisherStateUpdate = (
streamID,
state,
errorCode,
extendedData,
) {
log(
'onPublisherStateUpdate: streamID: $streamID, state: ${state.name}, errorCode: $errorCode, extendedData: $extendedData',
);
};
}
void stopListen() {
ZegoExpressEngine.onRoomUserUpdate = null;
ZegoExpressEngine.onRoomStreamUpdate = null;
ZegoExpressEngine.onRoomStateUpdate = null;
ZegoExpressEngine.onPublisherStateUpdate = null;
}
Future<void> startStream(String streamID) async {
final canvasViewWidget = await ZegoExpressEngine.instance.createCanvasView((
viewID,
) {
remoteViewID = viewID;
ZegoExpressEngine.instance.startPlayingStream(
streamID,
canvas: ZegoCanvas(viewID, viewMode: ZegoViewMode.AspectFill),
);
});
remoteView = canvasViewWidget;
setState(() {});
}
Future<void> stopStream(String streamID) async {
await ZegoExpressEngine.instance.stopPlayingStream(streamID);
if (remoteViewID == null) return;
await ZegoExpressEngine.instance.destroyCanvasView(remoteViewID!);
if (!mounted) return;
remoteView = null;
remoteViewID = null;
setState(() {});
}
}
Permission
On some Android version, permission not automatically allowed and must be set request permission manually. So we must implement code for permission using permission_handler package. Request permission will be execute when open Call Page.
import 'dart:developer';
import 'package:permission_handler/permission_handler.dart';
Future<void> requestPermission() async {
try {
PermissionStatus microphoneStatus = await Permission.microphone.request();
if (microphoneStatus != PermissionStatus.granted) {
log('Error: Microphone permission not granted!!!');
return;
}
} on Exception catch (error) {
log("[ERROR], request microphone permission exception, $error");
}
try {
PermissionStatus cameraStatus = await Permission.camera.request();
if (cameraStatus != PermissionStatus.granted) {
log('Error: Camera permission not granted!!!');
return;
}
} on Exception catch (error) {
log("[ERROR], request camera permission exception, $error");
}
}
import 'package:flutter_zegocloud_vcall/utils/permission.dart';
...
routes: {
'/': (context) => LoginPage(),
'/call': (context) {
final data = ModalRoute.settingsOf(context)?.arguments as Map?;
if (data == null) {
return Scaffold(appBar: AppBar(title: Text('Invalid Argument')));
}
requestPermission();
return CallPage(
userId: data['userId'],
userName: data['userName'],
roomId: data['roomId'],
);
},
},
...
Flow
Sequence | ||||
---|---|---|---|---|
User open App | ||||
↓ | ||||
Create Engine | ||||
↓ | ||||
Redirect Login Page | ||||
↓ | ||||
Go to Call Page | → | requestPermission | ||
↓ | ||||
startListen loginRoom | → → | startStream startPreview & publishStream | ||
↓ | ||||
Waiting for User Remote Connected | ||||
↓ | ||||
Call Activity | ||||
↓ | ||||
End Call Button or Back Button | ||||
↓ | ||||
stopListen logoutRoom | → → | stopStream stopPreview & unpublishStream | ||
↓ | ||||
Back to Login Page |
Final Result
Here are some views of the final results. Test using OS Android 10 and 15.
Request Permission | User Remote Not Connected | User 1 (Android 15) | User 2 (Android 10) |
---|---|---|---|
![]() | ![]() | ![]() | ![]() |
Next Step
Make more customize and feature for example you can add call invitation.