Unleashing Voice Calling Power: Flutter + Appwrite + WebRTC | Free & Provider-Free Implementation Episode [20]
Key Topics covered:
- Implement voice calls
- Create call room
- Join room
- WebRTC Knowledge
Description:
In this tutorial, we will guide you through the process of integrating WebRTC into your Flutter app, allowing you to establish high-quality voice calls. The best part? We'll achieve all of this without relying on any paid service providers. Instead, we'll leverage the power of Flutter WebRTC and Appwrite, a powerful open-source backend platform. Throughout this tutorial, we'll dive into the details of setting up WebRTC in Flutter and explore the various functionalities it offers for seamless voice communication. By the end of the video, you'll have a solid understanding of how to implement voice calls using Flutter and WebRTC, all while utilizing Appwrite as your backend solution.
Add the following flutter_background_service dependency in your pubspec.yaml. This is the dependency that we are using to handle Voice Calls in flutter
dependencies:
.........
flutter_webrtc: ^0.9.35
flutter_ringtone_player: ^3.2.0
Code Snippet(Android) configurations:
- android/app/build.gradle
- android/app/src/main/AndroidManifest.xml
minSdkVersion 21
<uses-permission android:name="android.permission.READ_CONTACTS" />
<uses-permission android:name="android.permission.WRITE_CONTACTS" />
<uses-feature android:name="android.hardware.camera" />
<uses-feature android:name="android.hardware.camera.autofocus" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
Code Snippet(iOS) configurations:
- ios/Podfile
target.build_configurations.each do |config|
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '12.0'
config.build_settings["ONLY_ACTIVE_ARCH"] = "YES"
config.build_settings['BUILD_LIBRARY_FOR_DISTRIBUTION'] = 'NO'
end
Code Snippet(strings.dart):
- Add the following code below in lib/constant/strings.dart. Create collection on Appwrite dashboard
static const collectionRoomId = "64b20a417c0155087ced";
static const collectionCallerId = "64b20afc1058bd56a1d2";
static const collectionCalleeId = "64b20b86a114c2d669fb";
Code Snippet(strings.dart):
- Modify the following file lib/core/providers/AppwriteClientProvider.dart
- Modify the following file lib/core/providers/RealtimeProvider.dart
We must have one Appwrite Client through the application. We need to update everywhere we are using provider client replace ref.read(appwriteClientProvider) with the code below
- lib/core/providers/AppwriteClientProvider.dart
- lib/core/providers/DatabaseProvider.dart
- lib/core/providers/StorageProvider.dart
clientService.getClient()
Code Snippet(RealtimeNotifierProvider.dart):
- Modify the following file lib/core/providers/RealtimeNotifierProvider.dart
Let's add voice calls listeners channels
const channels = [........
'databases.${Strings.databaseId}.collections.${Strings.collectionCalleeId}.documents',
'databases.${Strings.databaseId}.collections.${Strings.collectionCallerId}.documents',
'databases.${Strings.databaseId}.collections.${Strings.collectionRoomId}.documents',
]
Code Snippet(RoomAppwrite.dart):
- Add the following file lib/model/RoomAppwrite.dart
We create a room before we create an offer and save the caller offer SDP and later on the callee answer sdp
import 'package:json_annotation/json_annotation.dart';
part 'RoomAppwrite.g.dart';
@JsonSerializable()
class RoomAppwrite {
String? roomId;
bool? groupCall;
String? callType;
String? rtcSessionDescription;
String? callerUserId;
String? calleeUserId;
String? status;
RoomAppwrite({this.roomId,this.groupCall, this.callType, this.rtcSessionDescription,this.callerUserId,this.calleeUserId,this.status});
factory RoomAppwrite.fromJson(Map<String, dynamic> json) =>
_$RoomAppwriteFromJson(json);
Map<String, dynamic> toJson() => _$RoomAppwriteToJson(this);
}
Code Snippet(RTCSessionDescriptionModel.dart):
- Add the following file lib/model/RTCSessionDescriptionModel.dart
Create an RTCSessionDescriptionModel that we are going to encode and decode to json
import 'package:json_annotation/json_annotation.dart';
part 'RTCSessionDescriptionModel.g.dart';
@JsonSerializable()
class RTCSessionDescriptionModel {
String? type;
String? description;
RTCSessionDescriptionModel({this.type,this.description});
factory RTCSessionDescriptionModel.fromJson(Map<String, dynamic> json) =>
_$RTCSessionDescriptionModelFromJson(json);
Map<String, dynamic> toJson() => _$RTCSessionDescriptionModelToJson(this);
}
Code Snippet(CandidateModel.dart):
- Add the following file lib/model/CandidateModel.dart
Create a CandidateModel. The callee and caller candidates must be saved and used by each other to establish communication
import 'package:json_annotation/json_annotation.dart';
part 'CandidateModel.g.dart';
@JsonSerializable()
class CandidateModel {
String? id;
String? candidate;
int? sdpMLineIndex;
String? sdpMid;
String? roomId;
CandidateModel({this.id,this.candidate, this.sdpMLineIndex, this.sdpMid,this.roomId});
factory CandidateModel.fromJson(Map<String, dynamic> json) =>
_$CandidateModelFromJson(json);
Map<String, dynamic> toJson() => _$CandidateModelToJson(this);
}
Don't forget to run the following command in your terminal to generate new parts flutter packages pub run build_runner build
Code Snippet(main.dart):
- Modify the following file lib/main.dart
Remove below code. We don't need it.
notificationChannelId: 'my_foreground',
initialNotificationTitle: 'AWESOME SERVICE',
initialNotificationContent: 'Initializing',
Code Snippet(UserRepositoryProvider.dart):
- Add the following file lib/core/providers/UserRepositoryProvider.dart
Add code to get user by id
import 'package:appwrite/appwrite.dart';
import 'package:appwrite/models.dart';
import 'package:chat_with_bisky/constant/strings.dart';
import 'package:chat_with_bisky/core/providers/DatabaseProvider.dart';
import 'package:chat_with_bisky/model/UserAppwrite.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
final userRepositoryProvider =
Provider((ref) => UserDataRepositoryProvider(ref));
class UserDataRepositoryProvider {
final Ref _ref;
Databases get _db => _ref.read(databaseProvider);
UserDataRepositoryProvider(this._ref);
Future<UserAppwrite?> getUser(String id) async {
try {
Document document = await _db.getDocument(
databaseId: Strings.databaseId,
collectionId: Strings.collectionUsersId,
documentId: id);
return UserAppwrite.fromJson(document.data);
} on AppwriteException catch (e) {
print('ERROR getUser $e');
} catch (e) {
print('ERROR getUser $e');
}
return null;
}
}
Code Snippet(RoomRepositoryProvider.dart):
- Add the following file lib/core/providers/RoomRepositoryProvider.dart
Add code create room, update sdps, create callee and caller candidates
import 'dart:convert';
import 'package:appwrite/appwrite.dart';
import 'package:appwrite/models.dart';
import 'package:chat_with_bisky/constant/strings.dart';
import 'package:chat_with_bisky/core/providers/DatabaseProvider.dart';
import 'package:chat_with_bisky/model/CandidateModel.dart';
import 'package:chat_with_bisky/model/RTCSessionDescriptionModel.dart';
import 'package:chat_with_bisky/model/RoomAppwrite.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:realm/realm.dart';
final roomRepositoryProvider = Provider((ref) => RoomRepositoryProvider(ref));
class RoomRepositoryProvider {
final Ref _ref;
Databases get _db => _ref.read(databaseProvider);
RoomRepositoryProvider(this._ref);
Future<RoomAppwrite?> getRoom(String roomId) async {
try {
Document document = await _db.getDocument(
databaseId: Strings.databaseId,
collectionId: Strings.collectionRoomId,
documentId: roomId);
return RoomAppwrite.fromJson(document.data);
} on AppwriteException catch (e) {
print('ERROR getRoom $e');
} catch (e) {
print('ERROR getRoom $e');
}
return null;
}
Future<CandidateModel?> addCallerCandidates(
RoomAppwrite roomAppwrite, RTCIceCandidate rtcIceCandidate) async {
final model = CandidateModel(
id: ObjectId().hexString,
candidate: rtcIceCandidate.candidate,
sdpMid: rtcIceCandidate.sdpMid,
roomId: roomAppwrite.roomId,
sdpMLineIndex: rtcIceCandidate.sdpMLineIndex);
try {
print('addCallerCandidates>>() ${model.toJson()}');
Document document = await _db.createDocument(
databaseId: Strings.databaseId,
collectionId: Strings.collectionCallerId,
documentId: ObjectId().hexString,
data: model.toJson());
print('addCallerCandidates Created CallerId ${document.$id}');
return CandidateModel.fromJson(document.data);
} catch (e) {
print('addCallerCandidates ERROR RoomRepositoryProvider $e');
}
return null;
}
Future<CandidateModel?> addCalleeCandidates(
RoomAppwrite roomAppwrite, RTCIceCandidate rtcIceCandidate) async {
final model = CandidateModel(
id: ObjectId().hexString,
roomId: roomAppwrite.roomId,
candidate: rtcIceCandidate.candidate,
sdpMid: rtcIceCandidate.sdpMid,
sdpMLineIndex: rtcIceCandidate.sdpMLineIndex);
try {
Document document = await _db.createDocument(
databaseId: Strings.databaseId,
collectionId: Strings.collectionCalleeId,
documentId: ObjectId().hexString,
data: model.toJson());
return CandidateModel.fromJson(document.data);
} on AppwriteException catch (e) {
print('addCalleeCandidates ERROR createCallerUserData message= ${e.message} code=${e.code} response=${e.response} type=${e.type}');
}catch (e) {
print('addCalleeCandidates ERROR createCallerUserData $e');
}
return null;
}
Future<RoomAppwrite> addRtcSessionDescription(
RoomAppwrite roomAppwrite, RTCSessionDescription description) async {
List<RTCSessionDescriptionModel> list = [];
final model = RTCSessionDescriptionModel(
type: description.type, description: description.sdp);
RoomAppwrite? room = await getRoom(roomAppwrite.roomId!);
if (room != null) {
if (room.rtcSessionDescription == null) {
list.add(model);
} else {
List<dynamic> jsonData = json.decode(room.rtcSessionDescription!);
list = jsonData
.map((item) => RTCSessionDescriptionModel.fromJson(item))
.toList();
bool exist = false;
for (RTCSessionDescriptionModel md in list) {
if (md.type == model.type) {
exist = true;
md = model;
}
}
if (exist == false) {
list.add(model);
}
}
try {
Document document = await _db.updateDocument(
databaseId: Strings.databaseId,
collectionId: Strings.collectionRoomId,
documentId: roomAppwrite.roomId!,
data: {
'rtcSessionDescription': json.encode(list),
'roomId': roomAppwrite.roomId!
});
return RoomAppwrite.fromJson(document.data);
} catch (e) {
print('addRtcSessionDescription ERROR createCallerUserData $e');
}
}
return roomAppwrite;
}
Future<void> deleteRoom(String id) async {
try {
Document document = await _db.deleteDocument(
databaseId: Strings.databaseId,
collectionId: Strings.collectionRoomId,
documentId: id);
deleteCalleeCandidates(id);
deleteCallerCandidates(id);
} on AppwriteException catch (e) {
print('ERROR deleteRoom $e');
} catch (e) {
print('ERROR deleteRoom $e');
}
}
deleteCalleeCandidates(String roomId) async {
try{
DocumentList documentList = await _db.listDocuments(databaseId: Strings.databaseId,
collectionId: Strings.collectionCalleeId,
queries: [
Query.equal("roomId", [roomId]),
Query.limit(100)
]);
if(documentList.total > 0){
for(Document document in documentList.documents){
await _db.deleteDocument(
databaseId: Strings.databaseId,
collectionId: Strings.collectionCalleeId,
documentId: document.$id);
}
}
}catch (e){
print('delete callee candidates Exception $e');
}
}
deleteCallerCandidates(String roomId) async {
try{
DocumentList documentList = await _db.listDocuments(databaseId: Strings.databaseId,
collectionId: Strings.collectionCallerId,
queries: [
Query.equal("roomId", [roomId]),
Query.limit(100)
]);
if(documentList.total > 0){
for(Document document in documentList.documents){
await _db.deleteDocument(
databaseId: Strings.databaseId,
collectionId: Strings.collectionCallerId,
documentId: document.$id);
}
}
}catch (e){
print('delete caller candidates Exception $e');
}
}
Future<RoomAppwrite?> createNewRoom(RoomAppwrite callDataAppwrite) async {
try {
Document document = await _db.createDocument(
databaseId: Strings.databaseId,
collectionId: Strings.collectionRoomId,
documentId: callDataAppwrite.roomId!,
data: callDataAppwrite.toJson());
return RoomAppwrite.fromJson(document.data);
} catch (e) {
print('ERROR createNewRoom $e');
}
return null;
}
}
Code Snippet(DashboardPage.dart):
- Modify the following file lib/pages/dashboard/DashboardPage.dart
We need to listen for incoming calls in dashboard page
void _listenIncomingCalls() {
LocalStorageService.deleteKey(LocalStorageService.callInProgress);
ref.listen<RealtimeNotifier?>(
realtimeNotifierProvider.select((value) => value.asData?.value),
(_, next) async {
if (next?.document.$collectionId == Strings.collectionRoomId) {
final roomState = RoomAppwrite.fromJson(next!.document.data);
String username = await LocalStorageService.getString(LocalStorageService.userId) ?? "";
if(username == roomState.calleeUserId && roomState.rtcSessionDescription != null && next.type != RealtimeNotifier.delete){
final inProgress = await LocalStorageService.getString(LocalStorageService.callInProgress);
if(inProgress !=null){
// todo update room with state repository
return;
}
final caller = await ref.read(userRepositoryProvider).getUser(roomState.callerUserId??"");
if(caller != null){
ref.invalidate(voiceCallsWebRtcProvider);
Navigator.of(context)
.push(MaterialPageRoute(builder: (context) =>
VoiceCallingPage(
user: caller,
callStatus: CallStatus.ringing,
roomId: roomState.roomId ?? "",
)));
}
}
}
});
}
Call the _listenIncomingCalls() in build method. _DashboardPageState must extends ConsumerState and also DashboardPage extends ConsumerStatefulWidget
class DashboardPage extends ConsumerStatefulWidget {
@override
ConsumerState<DashboardPage> createState() {
return _DashboardPageState();
}
}
class _DashboardPageState extends ConsumerState<DashboardPage> {
Code Snippet(MessageScreen.dart):
- Modify the following file lib/pages/dashboard/chat/MessageScreen.dart
Let's modify the AppBar to have call icons.
AppBar(
elevation: 0,
automaticallyImplyLeading: false,
backgroundColor: Colors.white,
flexibleSpace: SafeArea(
child: Container(
padding: const EdgeInsets.only(right: 16),
child: Row(
children: <Widget>[
IconButton(
onPressed: () {
Navigator.pop(context);
},
icon: const Icon(
Icons.arrow_back,
color: Colors.black,
),
),
const SizedBox(
width: 2,
),
FriendImage(friendUserId ?? ""),
const SizedBox(
width: 12,
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
displayName,
style: const TextStyle(
fontSize: 16, fontWeight: FontWeight.w600),
),
const SizedBox(
height: 6,
),
// Expanded(
// child: Text(
// "",
// style: TextStyle(
// color: Colors.grey.shade600, fontSize: 13),
// ),
// ),
],
),
),
GestureDetector(
onTap: () {
ref.invalidate(voiceCallsWebRtcProvider);
Navigator.of(context).push(MaterialPageRoute(
builder: (context) => VoiceCallingPage(
user: widget.friendUser,
)));
},
child: const Icon(
Icons.phone,
size: 27,
color: Colors.green,
)),
const SizedBox(
width: 16,
),
GestureDetector(
onTap: () {},
child: const Icon(
Icons.video_call,
size: 27,
color: Colors.green,
))
],
),
),
),
),
// replace return ChatMessageItem( with the code below
return ChatMessageItem(
message: documentSnapshot,
displayName: displayName,
myMessage: documentSnapshot.senderUserId == myUserId,
);
}
Code Snippet(ChatListScreen.dart):
- Modify the following file l lib/pages/dashboard/chat/list/ChatListScreen.dart
Modify MessageRoute to have all required added parameters.
AutoRouter.of(context).push(MessageRoute(displayName: friend.displayName ?? "",
myUserId:userId,
friendUserId:friend.senderUserId ?? "",friendUser: UserAppwrite(userId: friend.senderUserId,
name: friend.displayName)));
Code Snippet(LocalStorageService.dart):
- Modify the following file lib/service/LocalStorageService.dart
Add below code
static String callInProgress = "CALL_IN_PROGRESS";
Code Snippet(countup.dart):
- Add the following file lib/widget/countup.dart
ACreate a widget for count up timer
import 'package:flutter/widgets.dart';
class Countup extends StatefulWidget {
final double begin;
final double end;
final int precision;
final Curve curve;
final Duration duration;
final TextStyle? style;
final TextAlign? textAlign;
final TextDirection? textDirection;
final Locale? locale;
final bool? softWrap;
final TextOverflow? overflow;
final double? textScaleFactor;
final int? maxLines;
final String? semanticsLabel;
final String prefix;
final String suffix;
Countup({
Key? key,
this.begin = 0,
this.end = 86400,
this.precision = 0,
this.curve = Curves.linear,
this.duration = const Duration(seconds: 86400),
this.style,
this.textAlign,
this.textDirection,
this.locale,
this.softWrap,
this.overflow,
this.textScaleFactor,
this.maxLines,
this.semanticsLabel,
this.prefix = '',
this.suffix = '',
}) : super(key: key);
@override
_CountupState createState() => _CountupState();
}
class _CountupState extends State<Countup> with TickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
double? _latestBegin;
double? _latestEnd;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
void initState() {
super.initState();
_controller = AnimationController(duration: widget.duration, vsync: this);
_latestBegin = widget.begin;
_latestEnd = widget.end;
}
@override
Widget build(BuildContext context) {
CurvedAnimation curvedAnimation =
CurvedAnimation(parent: _controller, curve: widget.curve);
_animation = Tween<double>(begin: widget.begin, end: widget.end)
.animate(curvedAnimation);
if (widget.begin != _latestBegin || widget.end != _latestEnd) {
_controller.reset();
}
_latestBegin = widget.begin;
_latestEnd = widget.end;
_controller.forward();
return _CountupAnimatedText(
key: UniqueKey(),
animation: _animation,
precision: widget.precision,
style: widget.style,
textAlign: widget.textAlign,
textDirection: widget.textDirection,
locale: widget.locale,
softWrap: widget.softWrap,
overflow: widget.overflow,
textScaleFactor: widget.textScaleFactor,
maxLines: widget.maxLines,
semanticsLabel: widget.semanticsLabel,
prefix: widget.prefix,
suffix: widget.suffix,
);
}
}
class _CountupAnimatedText extends AnimatedWidget {
final RegExp reg = new RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))');
final Animation<double> animation;
final int precision;
final TextStyle? style;
final TextAlign? textAlign;
final TextDirection? textDirection;
final Locale? locale;
final bool? softWrap;
final TextOverflow? overflow;
final double? textScaleFactor;
final int? maxLines;
final String? semanticsLabel;
final String? prefix;
final String? suffix;
_CountupAnimatedText({
Key? key,
required this.animation,
required this.precision,
this.style,
this.textAlign,
this.textDirection,
this.locale,
this.softWrap,
this.overflow,
this.textScaleFactor,
this.maxLines,
this.semanticsLabel,
this.prefix,
this.suffix,
}) : super(key: key, listenable: animation);
String _printDuration(Duration duration) {
String twoDigits(int n) => n.toString().padLeft(2, "0");
String twoDigitMinutes = twoDigits(duration.inMinutes.remainder(60));
String twoDigitSeconds = twoDigits(duration.inSeconds.remainder(60));
if(twoDigits(duration.inHours) == "00"){
return "$twoDigitMinutes:$twoDigitSeconds";
}else{
return "${twoDigits(duration.inHours)}:$twoDigitMinutes:$twoDigitSeconds";
}
}
@override
Widget build(BuildContext context) => Text(_printDuration(Duration(seconds: animation.value.toInt())),
style: style,
textAlign: textAlign,
textDirection: textDirection,
locale: locale,
softWrap: softWrap,
overflow: overflow,
textScaleFactor: textScaleFactor,
maxLines: maxLines,
semanticsLabel: semanticsLabel,
);
}
Code Snippet(VoiceCallingPage.dart):
- Add the following file lib/pages/dashboard/chat/voice_calls/VoiceCallingPage.dart
Create VoiceCallingPage for calls
import 'dart:ui';
import 'package:chat_with_bisky/model/UserAppwrite.dart';
import 'package:chat_with_bisky/pages/dashboard/chat/voice_calls/VoiceCallsWebRTCHandler.dart';
import 'package:chat_with_bisky/service/LocalStorageService.dart';
import 'package:chat_with_bisky/widget/countup.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_ringtone_player/flutter_ringtone_player.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
enum CallStatus { calling, accepted, ringing, progress, connecting,disconnected }
class VoiceCallingPage extends ConsumerStatefulWidget {
final UserAppwrite user;
CallStatus? callStatus;
final String? roomId;
VoiceCallingPage(
{Key? key, required this.user, this.callStatus, this.roomId})
: super(key: key);
@override
_VoiceCallingPageState createState() => _VoiceCallingPageState();
}
class _VoiceCallingPageState extends ConsumerState<VoiceCallingPage> {
late CallStatus callStatus;
VoiceCallsWebRTCHandler? webrtcLogic;
String? roomId;
bool _isCaller = false;
initializeWebRTC() async {
await Future.delayed(const Duration(milliseconds: 500));
LocalStorageService.putString(LocalStorageService.callInProgress, 'YES');
webrtcLogic!.onLocalStream = ((stream) {
});
webrtcLogic?.onAddRemoteStream = ((stream) {
});
webrtcLogic?.onRTCPeerConnectionStateChange = ((state) {
switch (state) {
case RTCPeerConnectionState.RTCPeerConnectionStateClosed:
case RTCPeerConnectionState.RTCPeerConnectionStateFailed:
case RTCPeerConnectionState.RTCPeerConnectionStateDisconnected:
if(mounted){
setState(() {
callStatus = CallStatus.disconnected;
});
}
break;
case RTCPeerConnectionState.RTCPeerConnectionStateNew:
break;
case RTCPeerConnectionState.RTCPeerConnectionStateConnecting:
setState(() {
callStatus = CallStatus.connecting;
});
break;
case RTCPeerConnectionState.RTCPeerConnectionStateConnected:
if (_isCaller == true) {
setState(() {
callStatus = CallStatus.progress;
});
} else {
setState(() {
callStatus = CallStatus.accepted;
});
}
break;
}
});
webrtcLogic?.onRemoveRemoteStream = ((stream) {
});
webrtcLogic?.onCallHangUpTimeout = ((stream) {
if (mounted) {
Navigator.of(context).pop();
}
});
webrtcLogic?.remoteCallHangUp = ((stream) {
if (mounted) {
Navigator.of(context).pop();
}
});
if (callStatus == CallStatus.calling) {
final userId =
await LocalStorageService.getString(LocalStorageService.userId) ??
"";
roomId = await webrtcLogic?.createRoom(userId, widget.user.userId ?? "");
print("roomID: $roomId");
setState(() {
_isCaller = true;
});
}else if (callStatus == CallStatus.ringing){
if(!_isCaller) {
webrtcLogic?.roomId = roomId;
FlutterRingtonePlayer.playRingtone();
webrtcLogic?.callingCountDownTimeout();
}
}
if (kDebugMode) {
print("connected successfully");
}
}
disconnectCall() async {
await webrtcLogic?.hangUp();
Navigator.of(context).pop();
}
@override
void initState() {
callStatus = widget.callStatus ?? CallStatus.calling;
initializeWebRTC();
super.initState();
}
@override
void dispose() {
webrtcLogic?.hangUp();
if(!_isCaller){
FlutterRingtonePlayer.stop();
}
super.dispose();
}
Widget getBody() {
switch (callStatus) {
case CallStatus.calling:
return Center(
child: Column(
children: [
const SizedBox(
height: 100,
),
Column(
children: [
const SizedBox(
width: 150,
height: 150,
),
const SizedBox(
height: 16,
),
Text(
widget.user.name ?? "",
style: const TextStyle(
color: Colors.black,
fontSize: 26,
fontWeight: FontWeight.bold),
),
const SizedBox(
height: 8,
),
const Text(
"Calling...",
style: TextStyle(
color: Colors.white,
shadows: [
BoxShadow(color: Colors.black, blurRadius: 3)
],
fontSize: 16),
),
GestureDetector(
onTap: () {
disconnectCall();
},
child: Column(
children: [
Container(
width: 70,
height: 70,
decoration: const BoxDecoration(
color: Colors.redAccent, shape: BoxShape.circle),
child: const Icon(Icons.phone_disabled,
color: Colors.white),
),
const SizedBox(
height: 8,
),
const Text(
"Decline",
style: TextStyle(color: Colors.white),
)
],
),
),
],
),
],
),
);
case CallStatus.accepted:
return Center(
child: Column(
children: [
const SizedBox(
height: 100,
),
Column(
children: [
Container(
width: 150,
height: 150,
),
const SizedBox(
height: 16,
),
Text(
widget.user.name ?? "",
style: const TextStyle(
color: Colors.black,
fontSize: 26,
fontWeight: FontWeight.bold),
),
const SizedBox(
height: 8,
),
Countup(
style: const TextStyle(
color: Colors.white,
shadows: [
BoxShadow(color: Colors.black, blurRadius: 3)
],
fontSize: 16),
),
GestureDetector(
onTap: () {
disconnectCall();
},
child: Column(
children: [
Container(
width: 70,
height: 70,
decoration: const BoxDecoration(
color: Colors.redAccent, shape: BoxShape.circle),
child: const Icon(Icons.phone_disabled,
color: Colors.white),
),
const SizedBox(
height: 8,
),
const Text(
"End",
style: TextStyle(color: Colors.white),
)
],
),
),
],
),
],
),
);
case CallStatus.ringing:
return Column(
children: [
const Spacer(),
Column(
children: [
const SizedBox(
width: 150,
height: 150,
),
const SizedBox(
height: 16,
),
Text(
widget.user.name ?? "",
style: const TextStyle(
color: Colors.black,
fontSize: 26,
fontWeight: FontWeight.bold),
),
const SizedBox(
height: 8,
),
const Text(
"Ringing...",
style: TextStyle(
color: Colors.white,
shadows: [BoxShadow(color: Colors.black, blurRadius: 3)],
fontSize: 16),
)
],
),
const SizedBox(
height: 60,
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
GestureDetector(
onTap: () {
FlutterRingtonePlayer.stop();
webrtcLogic!.callingCountDownTimer?.cancel();
roomId = widget.roomId;
webrtcLogic?.joinRoom(roomId!);
setState(() {
callStatus = CallStatus.accepted;
});
},
child: Column(
children: [
Container(
width: 70,
height: 70,
decoration: const BoxDecoration(
color: Colors.green, shape: BoxShape.circle),
child: const Icon(Icons.phone, color: Colors.white),
),
const SizedBox(
height: 8,
),
const Text(
"Accept",
style: TextStyle(color: Colors.white),
)
],
),
),
GestureDetector(
onTap: () {
disconnectCall();
},
child: Column(
children: [
Container(
width: 70,
height: 70,
decoration: const BoxDecoration(
color: Colors.redAccent, shape: BoxShape.circle),
child: const Icon(Icons.phone_disabled,
color: Colors.white),
),
const SizedBox(
height: 8,
),
const Text(
"Decline",
style: TextStyle(color: Colors.white),
)
],
),
),
],
),
const SizedBox(
height: 60,
),
const Text(
"Decline & Send Message",
style: TextStyle(color: Colors.white60, fontSize: 14),
),
const SizedBox(
height: 10,
),
const SizedBox(
height: 80,
),
],
);
case CallStatus.connecting:
case CallStatus.progress:
case CallStatus.disconnected:
return Center(
child: Column(
children: [
const SizedBox(
height: 100,
),
Column(
children: [
const SizedBox(
width: 150,
height: 150,
),
const SizedBox(
height: 16,
),
Text(
widget.user.name ?? "",
style: const TextStyle(
color: Colors.black,
fontSize: 26,
fontWeight: FontWeight.bold),
),
if (CallStatus.connecting == callStatus)
const Text(
"Connecting...",
style: TextStyle(
color: Colors.black,
fontSize: 14,
fontWeight: FontWeight.bold),
),
if (CallStatus.disconnected == callStatus)
const Text(
"You are disconnected. Please tap End Call Button...",
style: TextStyle(
color: Colors.black,
fontSize: 14,
fontWeight: FontWeight.bold),
),
const SizedBox(
height: 8,
),
Countup(
style: const TextStyle(
color: Colors.white,
shadows: [
BoxShadow(color: Colors.black, blurRadius: 3)
],
fontSize: 16),
),
GestureDetector(
onTap: () {
disconnectCall();
},
child: Column(
children: [
Container(
width: 70,
height: 70,
decoration: const BoxDecoration(
color: Colors.redAccent, shape: BoxShape.circle),
child: const Icon(Icons.phone_disabled,
color: Colors.white),
),
const SizedBox(
height: 8,
),
const Text(
"End",
style: TextStyle(color: Colors.white),
)
],
),
),
],
),
],
),
);
}
}
@override
Widget build(BuildContext context) {
webrtcLogic = ref.read(voiceCallsWebRtcProvider);
return Scaffold(
body: Stack(
children: [
SizedBox(
width: MediaQuery.of(context).size.width,
height: MediaQuery.of(context).size.height,
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 8.0, sigmaY: 8.0),
child: Container(
decoration: BoxDecoration(color: Colors.white.withOpacity(0.0)),
),
),
),
getBody(),
],
),
);
}
@override
void setState(VoidCallback fn) {
if(mounted){
super.setState(fn);
}
}
}
Code Snippet(countup.dart):
- Add the following file lib/widget/countup.dart
ACreate a widget for count up timer
static String callInProgress = "CALL_IN_PROGRESS";
Code Snippet(VoiceCallsWebRTCHandler.dart):
- Add the following file lib/pages/dashboard/chat/voice_calls/VoiceCallsWebRTCHandler.dart
A handler that creates room and handle call connections. It updates Caller and Callee candidates and listens to their activity on call
import 'dart:async';
import 'dart:convert';
import 'package:chat_with_bisky/constant/strings.dart';
import 'package:chat_with_bisky/core/providers/RealtimeNotifierProvider.dart';
import 'package:chat_with_bisky/core/providers/RoomRepositoryProvider.dart';
import 'package:chat_with_bisky/model/CandidateModel.dart';
import 'package:chat_with_bisky/model/RTCSessionDescriptionModel.dart';
import 'package:chat_with_bisky/model/RealtimeNotifier.dart';
import 'package:chat_with_bisky/model/RoomAppwrite.dart';
import 'package:chat_with_bisky/service/LocalStorageService.dart';
import 'package:flutter/material.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:realm/realm.dart';
typedef StreamStateCallback = void Function(MediaStream stream);
final voiceCallsWebRtcProvider = Provider((ref) => VoiceCallsWebRTCHandler(ref));
class VoiceCallsWebRTCHandler {
final Map<String, dynamic> configuration = {
'iceServers': [
{'url': 'stun:stun.l.google.com:19302'},
{ 'url': 'stun:stun1.l.google.com:19302' },
{ 'url': 'stun:stun2.l.google.com:19302' },
{ 'url': 'stun:stun3.l.google.com:19302' },
{
'url': 'turn:144.91.113.238:3478?transport=udp',
'username': 'codewithbisky',
'credential': 'webrtc'
},
]
};
final Map<String, dynamic> _voiceConstraints = {
'mandatory': {
'OfferToReceiveAudio': true,
'OfferToReceiveVideo': false,
},
'optional': [],
};
RTCPeerConnection? peerConnection;
MediaStream? localStream;
MediaStream? remoteStream;
String? roomId;
String? currentRoomText;
String? callerType = 'voice';
StreamStateCallback? onAddRemoteStream;
StreamStateCallback? onRemoveRemoteStream;
StreamStateCallback? onLocalStream;
ValueChanged<RTCPeerConnectionState>? onRTCPeerConnectionStateChange;
ValueChanged<bool>? onCallHangUpTimeout;
ValueChanged<bool>? remoteCallHangUp;
bool calleeAnswer = false;
bool groupCall = false;
bool addIntoList = true;
bool isCaller = true;
final List<RTCIceCandidate> _listLocalCandidates = [];
final Ref _ref;
List<MediaStream>? _remoteStreams;
Timer? callingCountDownTimer;
RoomRepositoryProvider get _roomRepository =>
_ref.read(roomRepositoryProvider);
VoiceCallsWebRTCHandler(this._ref);
Future<MediaStream> createStream() async {
var stream = await navigator.mediaDevices
.getUserMedia({'video': false, 'audio': true});
onLocalStream!(stream);
return stream;
}
Future<RTCPeerConnection> _createPeerConnection() async {
localStream = await createStream();
RTCPeerConnection pc = await createPeerConnection(configuration);
localStream?.getTracks().forEach((track) {
pc.addTrack(track, localStream!);
});
return pc;
}
Future<String> createRoom(String callerUserId, String calleeUserId) async {
final id = ObjectId().hexString;
RoomAppwrite room = RoomAppwrite(
roomId: id,
callType: callerType,
groupCall: groupCall,
callerUserId: callerUserId,
calleeUserId: calleeUserId);
this.roomId = id;
peerConnection = await _createPeerConnection();
registerPeerConnectionListeners();
await _roomRepository.createNewRoom(room);
peerConnection?.onIceCandidate = (RTCIceCandidate candidate) async {
if (addIntoList == true) {
_listLocalCandidates.add(candidate);
} else {
await _roomRepository.addCallerCandidates(room, candidate);
}
};
RTCSessionDescription offer =
await peerConnection!.createOffer(_voiceConstraints);
await peerConnection!.setLocalDescription(offer);
await _roomRepository.addRtcSessionDescription(room, offer);
var roomId = id;
_syncChangesCaller(roomId, room);
callingCountDownTimeout();
return roomId;
}
Future<void> joinRoom(String roomId) async {
this.roomId = roomId;
RoomAppwrite? roomAppwrite = await _roomRepository.getRoom(roomId);
if (roomAppwrite != null) {
peerConnection = await _createPeerConnection();
registerPeerConnectionListeners();
// Code for collecting ICE candidates below
peerConnection!.onIceCandidate = (RTCIceCandidate candidate) async {
if (candidate.candidate == null) {
return;
}
_listLocalCandidates.add(candidate);
await _roomRepository.addCalleeCandidates(roomAppwrite, candidate);
};
final rtcSessionDescription = roomAppwrite.rtcSessionDescription;
if (rtcSessionDescription != null) {
List<dynamic> jsonData = json.decode(rtcSessionDescription);
List<RTCSessionDescriptionModel> listRTCDescriptions = jsonData
.map((item) => RTCSessionDescriptionModel.fromJson(item))
.toList();
RTCSessionDescriptionModel? rtcSessionDescriptionOffer = listRTCDescriptions.where((element) =>
element.type == 'offer').firstOrNull;
if(rtcSessionDescriptionOffer != null){
var offer = RTCSessionDescription(
rtcSessionDescriptionOffer.description,
rtcSessionDescriptionOffer.type,
);
await peerConnection?.setRemoteDescription(offer);
}
var answer = await peerConnection!.createAnswer(_voiceConstraints);
await peerConnection!.setLocalDescription(answer);
await _roomRepository.addRtcSessionDescription(roomAppwrite, answer);
_syncChangesCallee(roomId);
}
}
}
Future<void> hangUp() async {
LocalStorageService.deleteKey(LocalStorageService.callInProgress);
remoteStream?.getTracks().forEach((track) => track.stop());
peerConnection?.close();
if (roomId != null) {
await _roomRepository.deleteRoom(roomId ?? "");
}
localStream?.dispose();
remoteStream?.dispose();
}
void registerPeerConnectionListeners() {
peerConnection?.onIceGatheringState = (RTCIceGatheringState state) {
};
peerConnection?.onConnectionState = (RTCPeerConnectionState state) {
if (onRTCPeerConnectionStateChange != null) {
onRTCPeerConnectionStateChange!(state);
}
};
peerConnection?.onSignalingState = (RTCSignalingState state) {
};
peerConnection?.onIceGatheringState = (RTCIceGatheringState state) {
};
peerConnection?.onAddStream = (MediaStream stream) {
if (onAddRemoteStream != null) onAddRemoteStream!(stream);
};
peerConnection?.onRemoveStream = (stream) {
if (onRemoveRemoteStream != null) {
onRemoveRemoteStream!(stream);
}
_remoteStreams?.removeWhere((MediaStream it) {
return (it.id == stream.id);
});
};
}
void _syncChangesCaller(String roomId, RoomAppwrite room) async {
_ref.listen<RealtimeNotifier?>(
realtimeNotifierProvider.select((value) => value.asData?.value),
(_, next) async {
if (next?.document.$collectionId == Strings.collectionRoomId) {
final roomState = RoomAppwrite.fromJson(next!.document.data);
if (roomState.roomId == roomId) {
String userId = await LocalStorageService.getString(LocalStorageService.userId) ?? "";
if(roomState.roomId == roomId && (userId == roomState.calleeUserId || userId == roomState.callerUserId) && next.type == RealtimeNotifier.delete){
remoteCallHangUp!(true);
return;
}
final rtcSessionDescription = roomState.rtcSessionDescription;
if (rtcSessionDescription != null) {
List<dynamic> jsonData = json.decode(rtcSessionDescription);
List<RTCSessionDescriptionModel> listRTCDescriptions = jsonData
.map((item) => RTCSessionDescriptionModel.fromJson(item))
.toList();
if (roomState.status == 'busy') {
return;
}
if (calleeAnswer == false) {
RTCSessionDescriptionModel? rtcSessionDescriptionAnswer = listRTCDescriptions.where((element) => element.type == 'answer').firstOrNull;
if(rtcSessionDescriptionAnswer != null){
calleeAnswer = true;
var answer = RTCSessionDescription(
rtcSessionDescriptionAnswer.description,
rtcSessionDescriptionAnswer.type,
);
await peerConnection?.setRemoteDescription(answer);
callingCountDownTimer?.cancel();
if (_listLocalCandidates.isNotEmpty) {
for (RTCIceCandidate candidate in _listLocalCandidates) {
await _roomRepository.addCallerCandidates(
room, candidate);
}
}
addIntoList = false;
}
}
}
}
} else if (next?.document.$collectionId == Strings.collectionCalleeId) {
// Listen for remote Ice candidates below
final roomState = CandidateModel.fromJson(next!.document.data);
if (roomState.roomId == roomId &&
next.type == RealtimeNotifier.create) {
peerConnection!.addCandidate(
RTCIceCandidate(
roomState.candidate,
roomState.sdpMid,
roomState.sdpMLineIndex,
),
);
}
}
});
}
void _syncChangesCallee(String roomId) async {
_ref.listen<RealtimeNotifier?>(
realtimeNotifierProvider.select((value) => value.asData?.value),
(_, next) async {
if (next?.document.$collectionId == Strings.collectionCallerId) {
final roomState = CandidateModel.fromJson(next!.document.data);
if (roomState.roomId == roomId &&
next.type == RealtimeNotifier.create) {
peerConnection!.addCandidate(
RTCIceCandidate(
roomState.candidate,
roomState.sdpMid,
roomState.sdpMLineIndex,
),
);
}
}else if (next?.document.$collectionId == Strings.collectionRoomId && next?.type != RealtimeNotifier.delete) {
final roomState = RoomAppwrite.fromJson(next!.document.data);
String userId = await LocalStorageService.getString(LocalStorageService.userId) ?? "";
if(roomState.roomId == roomId && (userId == roomState.calleeUserId || userId == roomState.callerUserId) && next.type == RealtimeNotifier.delete){
remoteCallHangUp!(true);
}
}
});
}
callingCountDownTimeout() {
callingCountDownTimer = Timer(const Duration(minutes: 1), () {
onCallHangUpTimeout!(true);
});
}
}
Conclusion:
We managed to implement voice calls with flutter WebRTC. The users can call each other communicate for free without any paid service provider. In the next tutorial, we are going to implement Firebase Cloud Messaging Integration in Flutter. Don't forget to share and join our Discord Channel. May you please subscribe to our YouTube Channel.