Flutter · Android + iOS · One-to-one calling

MyHub Live SDK Integration Guide

Project documentation for integrating the MyHub one-to-one audio and video calling SDK into a Flutter application using the tested CallTestScreen.

SDK packagemyhub_live_sdk
Example projectmyhub_live_sdk_example
Repository accessAvailable on Bitbucket

1. Project and Bitbucket access

MyHub TechnologiesFlutterAndroidiOS

The SDK package is myhub_live_sdk. The tested Flutter example project is myhub_live_sdk_example.

Bitbucket: The SDK and example project are available on Bitbucket. Check the email shared by MyHub Technologies for the example-project repository URL, access invitation, and branch details.

This guide is for Flutter integration only. Customer projects only need to add the MyHub Live SDK dependency, configure platform permissions, and use the SDK APIs shown below.

2. Add the SDK dependency

Add the package to the main Flutter application's pubspec.yaml.

dependencies:
  flutter:
    sdk: flutter

  myhub_live_sdk:
    git:
      url: git@bitbucket.org:bilwamindia/myhub_live_sdk.git
      ref: main

  # Required because CallTestScreen imports permission_handler directly.
  permission_handler: 11.4.0
Add only myhub_live_sdk to the main project. The example screen also declares permission_handler because it requests camera and microphone permissions directly.

Local SDK testing

dependencies:
  myhub_live_sdk:
    path: ../myhub_live_sdk

  permission_handler: 11.4.0

Install dependencies

flutter pub get

3. Android setup

Add the required permissions to the main application's android/app/src/main/AndroidManifest.xml, above the <application> element.

<manifest xmlns:android="http://schemas.android.com/apk/res/android">

    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.RECORD_AUDIO" />
    <uses-permission android:name="android.permission.CAMERA" />
    <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />

    <application
        ...>
        ...
    </application>

</manifest>
No manual native SDK setup: Do not copy Kotlin, Swift, Objective-C++, or C++ files. Flutter plugin registration and required native dependencies are handled by myhub_live_sdk.

4. iOS permissions and CocoaPods

The main Flutter application owns the iOS Runner target, so its Podfile and Info.plist must contain the media permission configuration.

Final ios/Podfile

platform :ios, '13.0'

ENV['COCOAPODS_DISABLE_STATS'] = 'true'

project 'Runner', {
  'Debug' => :debug,
  'Profile' => :release,
  'Release' => :release,
}

def flutter_root
  generated_xcode_build_settings_path = File.expand_path(
    File.join('..', 'Flutter', 'Generated.xcconfig'),
    __FILE__
  )

  unless File.exist?(generated_xcode_build_settings_path)
    raise "#{generated_xcode_build_settings_path} must exist. Run flutter pub get first."
  end

  File.foreach(generated_xcode_build_settings_path) do |line|
    matches = line.match(/FLUTTER_ROOT\=(.*)/)
    return matches[1].strip if matches
  end

  raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}"
end

require File.expand_path(
  File.join('packages', 'flutter_tools', 'bin', 'podhelper'),
  flutter_root
)

flutter_ios_podfile_setup

target 'Runner' do
  use_frameworks!

  flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))

  target 'RunnerTests' do
    inherit! :search_paths
  end
end

post_install do |installer|
  installer.pods_project.targets.each do |target|
    flutter_additional_ios_build_settings(target)

    target.build_configurations.each do |config|
      config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= [
        '$(inherited)',
        'PERMISSION_CAMERA=1',
        'PERMISSION_MICROPHONE=1',
      ]
    end
  end
end

ios/Runner/Info.plist

Add these entries inside the main <dict>:

<key>NSCameraUsageDescription</key>
<string>Camera access is required for video calls.</string>

<key>NSMicrophoneUsageDescription</key>
<string>Microphone access is required for audio and video calls.</string>
No additional SDK pod is required. Keep the Podfile changes limited to the iOS platform version and camera/microphone permission definitions shown below.

5. Call flow and callId handling

  1. Create CallSDK and register CallEventListenerNew.
  2. Connect the current user with connect(userId: ...).
  3. Start an outgoing audio or video call using startCall.
  4. The signaling server returns call_ringing. The SDK calls onCallRinging(callId) on the caller.
  5. The receiver gets the same ID in onIncomingCall(callId, fromUserId, isVideo).
  6. Both users pass that exact callId to the SDK's roomId parameter.
  7. The caller starts media after onCallAccepted(); the receiver starts media while accepting.
No separate room value is required. Do not hardcode a room name and do not ask the user to enter one. Pass the received callId to the SDK's roomId parameter.

WebSocket ringing event

{
  "type": "call_ringing",
  "data": {
    "callId": "call_1784805405951",
    "status": "ringing",
    "toUserId": "user_123"
  },
  "timestamp": 1784805406
}

Flutter handling

@override
void onCallRinging(String callId) {
  _callId = callId;
  _isIncomingCall = false;

  _setCallState(
    _CallUiState.ringing,
    'Ringing ${_targetUserId ?? _toUserIdController.text.trim()}...',
  );
}

@override
void onIncomingCall(
  String callId,
  String fromUserId,
  bool isVideo,
) {
  _callId = callId;
  _fromUserId = fromUserId;
  _isVideo = isVideo;
  _isIncomingCall = true;
}

// Caller joins after onCallAccepted.
final streams = await _callSdk.startMedia(
  roomId: _callId!,
  userId: currentUserId,
  isVideo: _isVideo,
);

// Receiver joins when accepting.
final streams = await _callSdk.acceptCall(
  roomId: _callId!,
  userId: currentUserId,
  fromUserId: _fromUserId!,
  isVideo: _isVideo,
);

Use isVideo: true for video calls and isVideo: false for audio-only calls. The receiver must use the value received in onIncomingCall.

6. SDK methods used by CallTestScreen

MethodPurpose
connectRegisters the current user with MyHub signaling.
startCallStarts an outgoing audio or video call.
acceptCallAccepts an incoming call and starts the SDK media session.
rejectCallRejects the incoming call.
endCallEnds an outgoing, ringing, connecting, or connected call.
startMediaStarts caller media after the call is accepted.
muteAudioMutes or unmutes the local microphone.
muteVideoEnables or disables the local camera.
switchCameraSwitches between available cameras.

7. Listener callbacks

CallbackScreen behavior
onCallRinging(callId)Server confirmed ringing. Store the call ID and pass it to the SDK when media starts.
onIncomingCall(callId, fromUserId, isVideo)Incoming call details for the receiver.
onCallAccepted()Caller starts media using the stored callId.
onCallRejected()Clear call state and show rejected status.
onCallEnded()Clear media and call state.
onUserOffline()Target user is not connected.
onLocalAudioMutedUpdates the local microphone UI.
onLocalVideoMutedUpdates the local camera UI.
onRemoteAudioMutedUpdates remote microphone status.
onRemoteVideoMutedShows remote profile UI when camera is off.
Status UI: The screen shows OFFLINE, READY, CALLING, RINGING, INCOMING, CONNECTING, CONNECTED, REJECTED, ENDED, USER OFFLINE, PERMISSION, and ERROR states.

8. Complete CallTestScreen

Create lib/call_test_screen.dart and use the following tested screen. It supports incoming/outgoing audio and video calls, caller cancellation while ringing, media permissions, mute controls, camera switching, remote mute states, and call status UI.

Open complete call_test_screen.dart
import 'package:flutter/material.dart';
import 'package:myhub_live_sdk/call_sdk.dart';
import 'package:permission_handler/permission_handler.dart';

enum _CallUiState {
  notConnected,
  ready,
  calling,
  ringing,
  incoming,
  connecting,
  connected,
  rejected,
  ended,
  offline,
  permissionDenied,
  error,
}

class CallTestScreen extends StatefulWidget {
  const CallTestScreen({super.key});

  @override
  State<CallTestScreen> createState() => _CallTestScreenState();
}

class _CallTestScreenState extends State<CallTestScreen>
    implements CallEventListenerNew {
  late final CallSDK _callSdk;

  final TextEditingController _userIdController =
      TextEditingController(text: 'user_123');
  final TextEditingController _toUserIdController =
      TextEditingController(text: 'user_439');

  String? _callId;
  String? _fromUserId;
  String? _currentUserId;
  String? _targetUserId;
  MediaStreams? _mediaStreams;

  bool _connectedToSignaling = false;
  bool _isIncomingCall = false;
  bool _isVideo = true;
  bool _audioMuted = false;
  bool _videoMuted = false;
  bool _remoteAudioMuted = false;
  bool _remoteVideoMuted = false;

  _CallUiState _callState = _CallUiState.notConnected;
  String _statusMessage = 'Connect to signaling to start calling';

  @override
  void initState() {
    super.initState();
    _callSdk = CallSDK();
    _callSdk.setListener(this);
  }

  @override
  void dispose() {
    _userIdController.dispose();
    _toUserIdController.dispose();
    super.dispose();
  }

  void _setCallState(_CallUiState state, String message) {
    if (!mounted) return;
    setState(() {
      _callState = state;
      _statusMessage = message;
    });
  }

  void _showMessage(String message) {
    if (!mounted) return;
    ScaffoldMessenger.of(context)
      ..clearSnackBars()
      ..showSnackBar(SnackBar(content: Text(message)));
  }

  Future<bool> _ensureMediaPermissions() async {
    final microphone = await Permission.microphone.request();
    if (!microphone.isGranted) return false;

    if (!_isVideo) return true;

    final camera = await Permission.camera.request();
    return camera.isGranted;
  }

  Future<void> _connect() async {
    final value = _userIdController.text.trim();
    if (value.isEmpty) {
      _showMessage('Your User ID is required');
      return;
    }

    try {
      await _callSdk.connect(userId: value);
      _currentUserId = value;
      _connectedToSignaling = true;
      _setCallState(_CallUiState.ready, 'Connected as $value');
    } catch (error) {
      _setCallState(_CallUiState.error, 'Unable to connect');
      _showMessage(error.toString());
    }
  }

  Future<void> _startCall() async {
    if (!_connectedToSignaling) {
      _showMessage('Connect first');
      return;
    }

    final target = _toUserIdController.text.trim();
    if (target.isEmpty) {
      _showMessage('To User ID is required');
      return;
    }

    _targetUserId = target;
    _fromUserId = target;
    _callId = null;
    _isIncomingCall = false;

    _setCallState(
      _CallUiState.calling,
      'Calling $target...',
    );

    try {
      await _callSdk.startCall(
        toUserId: target,
        isVideo: _isVideo,
      );
    } catch (error) {
      _setCallState(_CallUiState.error, 'Unable to start call');
      _showMessage(error.toString());
    }
  }

  Future<void> _acceptIncomingCall() async {
    final callId = _callId;
    final fromUserId = _fromUserId;
    final currentUserId = _currentUserId ?? _userIdController.text.trim();

    if (!_isIncomingCall || callId == null || fromUserId == null) {
      _showMessage('No incoming call to accept');
      return;
    }

    final granted = await _ensureMediaPermissions();
    if (!granted) {
      _setCallState(
        _CallUiState.permissionDenied,
        'Camera or microphone permission denied',
      );
      return;
    }

    _setCallState(_CallUiState.connecting, 'Connecting call media...');

    try {
      final streams = await _callSdk.acceptCall(
        roomId: callId,
        userId: currentUserId,
        fromUserId: fromUserId,
        isVideo: _isVideo,
      );

      _mediaStreams = streams;
      _isIncomingCall = false;
      _audioMuted = false;
      _videoMuted = false;

      _callSdk.muteAudio(false);
      if (_isVideo) {
        _callSdk.muteVideo(false);
      }

      _setCallState(
        _CallUiState.connected,
        '${_isVideo ? 'Video' : 'Audio'} call connected',
      );
    } catch (error) {
      _setCallState(_CallUiState.error, 'Unable to accept call');
      _showMessage(error.toString());
    }
  }

  Future<void> _startMediaAsCaller() async {
    final callId = _callId;
    final currentUserId = _currentUserId ?? _userIdController.text.trim();

    if (callId == null || callId.isEmpty) {
      _setCallState(
        _CallUiState.error,
        'Call accepted, but callId was not received',
      );
      return;
    }

    final granted = await _ensureMediaPermissions();
    if (!granted) {
      _setCallState(
        _CallUiState.permissionDenied,
        'Camera or microphone permission denied',
      );
      return;
    }

    _setCallState(_CallUiState.connecting, 'Connecting call media...');

    try {
      final streams = await _callSdk.startMedia(
        roomId: callId,
        userId: currentUserId,
        isVideo: _isVideo,
      );

      _mediaStreams = streams;
      _audioMuted = false;
      _videoMuted = false;

      _callSdk.muteAudio(false);
      if (_isVideo) {
        _callSdk.muteVideo(false);
      }

      _setCallState(
        _CallUiState.connected,
        '${_isVideo ? 'Video' : 'Audio'} call connected',
      );
    } catch (error) {
      _setCallState(_CallUiState.error, 'Unable to start call media');
      _showMessage(error.toString());
    }
  }

  Future<void> _rejectIncomingCall() async {
    if (!_isIncomingCall || _callId == null) {
      _showMessage('No incoming call to reject');
      return;
    }

    try {
      await _callSdk.rejectCall();
      _clearCallSession();
      _setCallState(_CallUiState.rejected, 'Incoming call rejected');
    } catch (error) {
      _setCallState(_CallUiState.error, 'Unable to reject call');
      _showMessage(error.toString());
    }
  }

  Future<void> _endCurrentCall() async {
    final callId = _callId ?? '';
    final remoteUserId = _fromUserId ?? _targetUserId ?? '';

    try {
      await _callSdk.endCall(callId, remoteUserId);
    } catch (error) {
      _showMessage(error.toString());
    }

    _clearCallSession();
    _setCallState(_CallUiState.ended, 'Call ended');
  }

  void _clearCallSession() {
    _callId = null;
    _fromUserId = null;
    _targetUserId = null;
    _mediaStreams = null;
    _isIncomingCall = false;
    _audioMuted = false;
    _videoMuted = false;
    _remoteAudioMuted = false;
    _remoteVideoMuted = false;
  }

  String get _statusLabel {
    switch (_callState) {
      case _CallUiState.notConnected:
        return 'OFFLINE';
      case _CallUiState.ready:
        return 'READY';
      case _CallUiState.calling:
        return 'CALLING';
      case _CallUiState.ringing:
        return 'RINGING';
      case _CallUiState.incoming:
        return 'INCOMING';
      case _CallUiState.connecting:
        return 'CONNECTING';
      case _CallUiState.connected:
        return 'CONNECTED';
      case _CallUiState.rejected:
        return 'REJECTED';
      case _CallUiState.ended:
        return 'ENDED';
      case _CallUiState.offline:
        return 'USER OFFLINE';
      case _CallUiState.permissionDenied:
        return 'PERMISSION';
      case _CallUiState.error:
        return 'ERROR';
    }
  }

  Color get _statusColor {
    switch (_callState) {
      case _CallUiState.connected:
      case _CallUiState.ready:
        return const Color(0xFF22C55E);
      case _CallUiState.calling:
      case _CallUiState.ringing:
      case _CallUiState.connecting:
        return const Color(0xFFF59E0B);
      case _CallUiState.incoming:
        return const Color(0xFF38BDF8);
      case _CallUiState.rejected:
      case _CallUiState.ended:
      case _CallUiState.offline:
      case _CallUiState.permissionDenied:
      case _CallUiState.error:
        return const Color(0xFFEF4444);
      case _CallUiState.notConnected:
        return const Color(0xFF818CF8);
    }
  }

  IconData get _statusIcon {
    switch (_callState) {
      case _CallUiState.calling:
      case _CallUiState.ringing:
        return Icons.notifications_active_rounded;
      case _CallUiState.incoming:
        return Icons.call_received_rounded;
      case _CallUiState.connecting:
        return Icons.sync_rounded;
      case _CallUiState.connected:
        return Icons.call_rounded;
      case _CallUiState.rejected:
      case _CallUiState.ended:
        return Icons.call_end_rounded;
      case _CallUiState.offline:
        return Icons.person_off_rounded;
      case _CallUiState.permissionDenied:
        return Icons.no_photography_rounded;
      case _CallUiState.error:
        return Icons.error_outline_rounded;
      case _CallUiState.ready:
        return Icons.check_circle_outline_rounded;
      case _CallUiState.notConnected:
        return Icons.link_off_rounded;
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: const Color(0xFF05070D),
      resizeToAvoidBottomInset: true,
      body: SafeArea(
        child: Column(
          children: [
            _buildHeader(),
            Expanded(
              child: SingleChildScrollView(
                padding: const EdgeInsets.fromLTRB(16, 14, 16, 20),
                child: Column(
                  children: [
                    _buildStatusPanel(),
                    const SizedBox(height: 14),
                    _buildConnectionCard(),
                    const SizedBox(height: 14),
                    SizedBox(
                      height: MediaQuery.of(context).size.height * 0.52,
                      child: _buildMediaSection(),
                    ),
                  ],
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }

  Widget _buildHeader() {
    return Container(
      padding: const EdgeInsets.fromLTRB(20, 18, 20, 16),
      decoration: const BoxDecoration(
        gradient: LinearGradient(
          colors: [Color(0xFF101827), Color(0xFF080B13)],
          begin: Alignment.topLeft,
          end: Alignment.bottomRight,
        ),
        border: Border(bottom: BorderSide(color: Color(0xFF1E293B))),
      ),
      child: Row(
        children: [
          Container(
            width: 46,
            height: 46,
            decoration: BoxDecoration(
              gradient: const LinearGradient(
                colors: [Color(0xFF6366F1), Color(0xFF8B5CF6)],
              ),
              borderRadius: BorderRadius.circular(15),
            ),
            child: Icon(
              _isVideo ? Icons.videocam_rounded : Icons.call_rounded,
              color: Colors.white,
            ),
          ),
          const SizedBox(width: 14),
          const Expanded(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(
                  'MyHub Call SDK',
                  style: TextStyle(
                    color: Colors.white,
                    fontSize: 21,
                    fontWeight: FontWeight.w700,
                  ),
                ),
                SizedBox(height: 3),
                Text(
                  'One-to-one audio and video calling',
                  style: TextStyle(color: Color(0xFF94A3B8), fontSize: 12),
                ),
              ],
            ),
          ),
          _buildStatusBadge(),
        ],
      ),
    );
  }

  Widget _buildStatusBadge() {
    final color = _statusColor;
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
      decoration: BoxDecoration(
        color: color.withOpacity(0.12),
        borderRadius: BorderRadius.circular(50),
        border: Border.all(color: color.withOpacity(0.38)),
      ),
      child: Row(
        mainAxisSize: MainAxisSize.min,
        children: [
          Container(
            width: 7,
            height: 7,
            decoration: BoxDecoration(color: color, shape: BoxShape.circle),
          ),
          const SizedBox(width: 7),
          Text(
            _statusLabel,
            style: TextStyle(
              color: color,
              fontSize: 10,
              fontWeight: FontWeight.w700,
            ),
          ),
        ],
      ),
    );
  }

  Widget _buildStatusPanel() {
    final color = _statusColor;
    return AnimatedContainer(
      duration: const Duration(milliseconds: 220),
      width: double.infinity,
      padding: const EdgeInsets.all(16),
      decoration: BoxDecoration(
        color: color.withOpacity(0.09),
        borderRadius: BorderRadius.circular(18),
        border: Border.all(color: color.withOpacity(0.32)),
      ),
      child: Row(
        children: [
          Container(
            width: 44,
            height: 44,
            decoration: BoxDecoration(
              color: color.withOpacity(0.16),
              shape: BoxShape.circle,
            ),
            child: Icon(_statusIcon, color: color),
          ),
          const SizedBox(width: 13),
          Expanded(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(
                  _statusMessage,
                  style: const TextStyle(
                    color: Colors.white,
                    fontSize: 14,
                    fontWeight: FontWeight.w700,
                  ),
                ),
                if (_callId != null) ...[
                  const SizedBox(height: 4),
                  Text(
                    'Call ID: $_callId',
                    maxLines: 1,
                    overflow: TextOverflow.ellipsis,
                    style: const TextStyle(
                      color: Color(0xFF94A3B8),
                      fontSize: 11,
                    ),
                  ),
                ],
              ],
            ),
          ),
        ],
      ),
    );
  }

  Widget _buildConnectionCard() {
    final canAccept = _isIncomingCall && _callId != null;

    final canCancelOutgoing =
        !_isIncomingCall &&
        (_callState == _CallUiState.calling ||
            _callState == _CallUiState.ringing ||
            _callState == _CallUiState.connecting);

    return Container(
      padding: const EdgeInsets.all(16),
      decoration: BoxDecoration(
        color: const Color(0xFF0F141F),
        borderRadius: BorderRadius.circular(22),
        border: Border.all(color: const Color(0xFF1E293B)),
      ),
      child: Column(
        children: [
          Row(
            children: [
              const Expanded(
                child: Text(
                  'Connection Setup',
                  style: TextStyle(
                    color: Colors.white,
                    fontSize: 16,
                    fontWeight: FontWeight.w700,
                  ),
                ),
              ),
              _buildCallTypeSelector(),
            ],
          ),
          const SizedBox(height: 16),
          Row(
            children: [
              Expanded(
                child: _buildIdField(
                  controller: _userIdController,
                  label: 'Your User ID',
                  icon: Icons.person_outline_rounded,
                ),
              ),
              const SizedBox(width: 10),
              Expanded(
                child: _buildIdField(
                  controller: _toUserIdController,
                  label: 'To User ID',
                  icon: Icons.person_add_alt_1_outlined,
                ),
              ),
            ],
          ),
          const SizedBox(height: 13),
          Row(
            children: [
              Expanded(
                child: _buildActionButton(
                  title: 'Connect',
                  icon: Icons.link_rounded,
                  color: const Color(0xFF334155),
                  onPressed: _connect,
                ),
              ),
              const SizedBox(width: 10),
              Expanded(
                flex: 2,
                child: _buildActionButton(
                  title: _isVideo ? 'Start Video Call' : 'Start Audio Call',
                  icon: _isVideo ? Icons.video_call_rounded : Icons.call_rounded,
                  color: const Color(0xFF6366F1),
                  onPressed: _startCall,
                ),
              ),
            ],
          ),
          const SizedBox(height: 10),
          Row(
            children: [
              Expanded(
                child: _buildActionButton(
                  title: 'Accept',
                  icon: Icons.call_rounded,
                  color: const Color(0xFF16A34A),
                  onPressed: canAccept ? _acceptIncomingCall : null,
                ),
              ),
              const SizedBox(width: 10),
              Expanded(
                child: _buildActionButton(
                  title: 'Reject',
                  icon: Icons.call_end_rounded,
                  color: const Color(0xFFDC2626),
                  onPressed: canAccept ? _rejectIncomingCall : null,
                ),
              ),
            ],
          ),

          if (canCancelOutgoing) ...[
            const SizedBox(height: 10),
            SizedBox(
              width: double.infinity,
              child: _buildActionButton(
                title: 'End Outgoing Call',
                icon: Icons.call_end_rounded,
                color: const Color(0xFFDC2626),
                onPressed: _endCurrentCall,
              ),
            ),
          ],
        ],
      ),
    );
  }

  Widget _buildCallTypeSelector() {
    return InkWell(
      onTap: _mediaStreams != null || _isIncomingCall
          ? null
          : () => setState(() => _isVideo = !_isVideo),
      borderRadius: BorderRadius.circular(12),
      child: Container(
        padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
        decoration: BoxDecoration(
          color: (_isVideo ? const Color(0xFF6366F1) : const Color(0xFF0EA5E9))
              .withOpacity(0.14),
          borderRadius: BorderRadius.circular(12),
          border: Border.all(
            color:
                (_isVideo ? const Color(0xFF6366F1) : const Color(0xFF0EA5E9))
                    .withOpacity(0.4),
          ),
        ),
        child: Row(
          mainAxisSize: MainAxisSize.min,
          children: [
            Icon(
              _isVideo ? Icons.videocam_rounded : Icons.call_rounded,
              color: _isVideo
                  ? const Color(0xFFA5B4FC)
                  : const Color(0xFF7DD3FC),
              size: 17,
            ),
            const SizedBox(width: 7),
            Text(
              _isVideo ? 'Video' : 'Audio',
              style: const TextStyle(
                color: Colors.white,
                fontSize: 12,
                fontWeight: FontWeight.w600,
              ),
            ),
          ],
        ),
      ),
    );
  }

  Widget _buildIdField({
    required TextEditingController controller,
    required String label,
    required IconData icon,
  }) {
    return TextField(
      controller: controller,
      style: const TextStyle(color: Colors.white, fontSize: 13),
      decoration: InputDecoration(
        labelText: label,
        labelStyle: const TextStyle(color: Color(0xFF64748B), fontSize: 11),
        prefixIcon: Icon(icon, color: const Color(0xFF64748B), size: 19),
        filled: true,
        fillColor: const Color(0xFF080B12),
        enabledBorder: OutlineInputBorder(
          borderRadius: BorderRadius.circular(14),
          borderSide: const BorderSide(color: Color(0xFF1E293B)),
        ),
        focusedBorder: OutlineInputBorder(
          borderRadius: BorderRadius.circular(14),
          borderSide: const BorderSide(color: Color(0xFF6366F1)),
        ),
      ),
    );
  }

  Widget _buildActionButton({
    required String title,
    required IconData icon,
    required Color color,
    required VoidCallback? onPressed,
  }) {
    return SizedBox(
      height: 48,
      child: ElevatedButton.icon(
        onPressed: onPressed,
        icon: Icon(icon, size: 18),
        label: Text(title, style: const TextStyle(fontSize: 12)),
        style: ElevatedButton.styleFrom(
          elevation: 0,
          backgroundColor: color,
          disabledBackgroundColor: const Color(0xFF1E293B).withOpacity(0.45),
          foregroundColor: Colors.white,
          shape: RoundedRectangleBorder(
            borderRadius: BorderRadius.circular(14),
          ),
        ),
      ),
    );
  }

  Widget _buildMediaSection() {
    return Container(
      width: double.infinity,
      decoration: BoxDecoration(
        color: const Color(0xFF080B12),
        borderRadius: BorderRadius.circular(24),
        border: Border.all(color: const Color(0xFF1E293B)),
      ),
      child: ClipRRect(
        borderRadius: BorderRadius.circular(23),
        child: _mediaStreams == null ? _buildEmptyMedia() : _buildActiveMedia(),
      ),
    );
  }

  Widget _buildEmptyMedia() {
    return Container(
      decoration: const BoxDecoration(
        gradient: RadialGradient(
          center: Alignment.topCenter,
          radius: 1.1,
          colors: [Color(0xFF171A2A), Color(0xFF080B12)],
        ),
      ),
      child: Center(
        child: Padding(
          padding: const EdgeInsets.all(24),
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              Icon(_statusIcon, color: _statusColor, size: 62),
              const SizedBox(height: 18),
              Text(
                _statusMessage,
                textAlign: TextAlign.center,
                style: const TextStyle(
                  color: Colors.white,
                  fontSize: 17,
                  fontWeight: FontWeight.w700,
                ),
              ),
              if (_callState == _CallUiState.incoming) ...[
                const SizedBox(height: 8),
                Text(
                  '${_isVideo ? 'Video' : 'Audio'} call from ${_fromUserId ?? ''}',
                  style: const TextStyle(color: Color(0xFF94A3B8)),
                ),
              ],
            ],
          ),
        ),
      ),
    );
  }

  Widget _buildActiveMedia() {
    return Stack(
      children: [
        Positioned.fill(child: _buildRemoteMedia()),
        Positioned(
          top: 16,
          left: 16,
          child: Container(
            padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
            decoration: BoxDecoration(
              color: Colors.black.withOpacity(0.55),
              borderRadius: BorderRadius.circular(50),
            ),
            child: Row(
              mainAxisSize: MainAxisSize.min,
              children: [
                const CircleAvatar(
                  radius: 3.5,
                  backgroundColor: Color(0xFF22C55E),
                ),
                const SizedBox(width: 7),
                Text(
                  _isVideo ? 'VIDEO CALL' : 'AUDIO CALL',
                  style: const TextStyle(
                    color: Colors.white,
                    fontSize: 10,
                    fontWeight: FontWeight.w700,
                  ),
                ),
              ],
            ),
          ),
        ),
        Positioned(top: 58, left: 16, child: _buildRemoteMicStatus()),
        if (_isVideo)
          Positioned(
            right: 14,
            top: 14,
            width: 110,
            height: 150,
            child: Container(
              decoration: BoxDecoration(
                color: const Color(0xFF111827),
                borderRadius: BorderRadius.circular(18),
                border: Border.all(color: Colors.white.withOpacity(0.32)),
              ),
              child: ClipRRect(
                borderRadius: BorderRadius.circular(17),
                child: _videoMuted
                    ? const Center(
                        child: Icon(
                          Icons.videocam_off_rounded,
                          color: Color(0xFF94A3B8),
                        ),
                      )
                    : ValueListenableBuilder<Widget>(
                        valueListenable: _mediaStreams!.localVideo,
                        builder: (_, widget, __) => widget,
                      ),
              ),
            ),
          ),
        Positioned(
          left: 12,
          right: 12,
          bottom: 18,
          child: Center(child: _buildCallControls()),
        ),
      ],
    );
  }

  Widget _buildRemoteMedia() {
    if (!_isVideo || _remoteVideoMuted) {
      return Container(
        color: const Color(0xFF0B0F19),
        child: Center(
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              Container(
                width: 125,
                height: 125,
                decoration: BoxDecoration(
                  shape: BoxShape.circle,
                  color: const Color(0xFF1E293B),
                  border: Border.all(color: const Color(0xFF334155), width: 3),
                ),
                child: const Icon(
                  Icons.person_rounded,
                  color: Color(0xFF94A3B8),
                  size: 72,
                ),
              ),
              const SizedBox(height: 18),
              Text(
                !_isVideo ? 'Audio call' : 'Camera is off',
                style: const TextStyle(
                  color: Colors.white,
                  fontSize: 17,
                  fontWeight: FontWeight.w600,
                ),
              ),
            ],
          ),
        ),
      );
    }

    return ValueListenableBuilder<Widget>(
      valueListenable: _mediaStreams!.remoteVideo,
      builder: (_, widget, __) => widget,
    );
  }

  Widget _buildRemoteMicStatus() {
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
      decoration: BoxDecoration(
        color: _remoteAudioMuted
            ? const Color(0xFFEF4444).withOpacity(0.88)
            : Colors.black.withOpacity(0.55),
        borderRadius: BorderRadius.circular(50),
      ),
      child: Row(
        mainAxisSize: MainAxisSize.min,
        children: [
          Icon(
            _remoteAudioMuted ? Icons.mic_off_rounded : Icons.mic_rounded,
            color: Colors.white,
            size: 15,
          ),
          const SizedBox(width: 6),
          Text(
            _remoteAudioMuted ? 'Remote muted' : 'Remote mic on',
            style: const TextStyle(color: Colors.white, fontSize: 10),
          ),
        ],
      ),
    );
  }

  Widget _buildCallControls() {
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
      decoration: BoxDecoration(
        color: const Color(0xFF0F172A).withOpacity(0.88),
        borderRadius: BorderRadius.circular(50),
      ),
      child: Row(
        mainAxisSize: MainAxisSize.min,
        children: [
          if (_isVideo) ...[
            _circleButton(
              icon: Icons.cameraswitch_rounded,
              onTap: () => _callSdk.switchCamera(),
            ),
            const SizedBox(width: 10),
          ],
          _circleButton(
            icon: _audioMuted ? Icons.mic_off_rounded : Icons.mic_rounded,
            color: _audioMuted ? Colors.white : const Color(0xFF1E293B),
            iconColor: _audioMuted ? const Color(0xFF0F172A) : Colors.white,
            onTap: () {
              _audioMuted = !_audioMuted;
              _callSdk.muteAudio(_audioMuted);
              setState(() {});
            },
          ),
          const SizedBox(width: 10),
          _circleButton(
            icon: Icons.call_end_rounded,
            color: const Color(0xFFEF4444),
            size: 60,
            onTap: _endCurrentCall,
          ),
          if (_isVideo) ...[
            const SizedBox(width: 10),
            _circleButton(
              icon: _videoMuted
                  ? Icons.videocam_off_rounded
                  : Icons.videocam_rounded,
              color: _videoMuted ? Colors.white : const Color(0xFF1E293B),
              iconColor: _videoMuted ? const Color(0xFF0F172A) : Colors.white,
              onTap: () {
                _videoMuted = !_videoMuted;
                _callSdk.muteVideo(_videoMuted);
                setState(() {});
              },
            ),
          ],
        ],
      ),
    );
  }

  Widget _circleButton({
    required IconData icon,
    required VoidCallback onTap,
    Color color = const Color(0xFF1E293B),
    Color iconColor = Colors.white,
    double size = 50,
  }) {
    return Material(
      color: Colors.transparent,
      child: InkWell(
        onTap: onTap,
        borderRadius: BorderRadius.circular(50),
        child: Ink(
          width: size,
          height: size,
          decoration: BoxDecoration(color: color, shape: BoxShape.circle),
          child: Icon(icon, color: iconColor, size: size == 60 ? 28 : 23),
        ),
      ),
    );
  }

  @override
  void onIncomingCall(String callId, String fromUserId, bool isVideo) {
    _callId = callId;
    _fromUserId = fromUserId;
    _targetUserId = fromUserId;
    _isVideo = isVideo;
    _isIncomingCall = true;

    _setCallState(
      _CallUiState.incoming,
      'Incoming ${isVideo ? 'video' : 'audio'} call from $fromUserId',
    );
  }

  @override
  void onCallRinging(String callId) {
    _callId = callId;
    _isIncomingCall = false;

    _setCallState(
      _CallUiState.ringing,
      'Ringing ${_targetUserId ?? _toUserIdController.text.trim()}...',
    );
  }

  @override
  Future<void> onCallAccepted() async {
    await _startMediaAsCaller();
  }

  @override
  void onCallRejected() {
    _clearCallSession();
    _setCallState(_CallUiState.rejected, 'Call rejected');
  }

  @override
  void onCallEnded() {
    _clearCallSession();
    _setCallState(_CallUiState.ended, 'Call ended');
  }

  @override
  void onUserOffline() {
    _clearCallSession();
    _setCallState(_CallUiState.offline, 'Target user is offline');
  }

  @override
  void onLocalAudioMuted(bool muted) {
    if (!mounted) return;
    setState(() => _audioMuted = muted);
  }

  @override
  void onLocalVideoMuted(bool muted) {
    if (!mounted) return;
    setState(() => _videoMuted = muted);
  }

  @override
  void onRemoteAudioMuted(bool muted, String remoteUserId) {
    if (!mounted) return;
    setState(() => _remoteAudioMuted = muted);
  }

  @override
  void onRemoteVideoMuted(bool muted, String remoteUserId) {
    if (!mounted) return;
    setState(() => _remoteVideoMuted = muted);
  }
}

Open the screen from main.dart

import 'package:flutter/material.dart';
import 'call_test_screen.dart';

void main() {
  runApp(const MyHubCallExampleApp());
}

class MyHubCallExampleApp extends StatelessWidget {
  const MyHubCallExampleApp({super.key});

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      debugShowCheckedModeBanner: false,
      home: CallTestScreen(),
    );
  }
}

9. Build and test

Normal setup

flutter clean
flutter pub get

cd ios
pod install --repo-update
cd ..

flutter run

Independent two-user test

  1. Run the app on two devices or simulators that support the required media features.
  2. Connect one instance as user_123 and the other as user_439.
  3. Select Video or Voice on the caller.
  4. Start the call and confirm the caller shows RINGING with a call ID.
  5. Accept on the receiver and confirm both sides join the same call.
  6. Test mute/unmute, camera on/off, camera switching, reject, caller cancellation, and call end.

10. CocoaPods/WebRTC lock conflict

Use this cleanup only when CocoaPods reports that the locked WebRTC-SDK version conflicts with the version required by flutter_webrtc.

flutter clean
rm -rf .dart_tool
rm -rf ios/Pods
rm -rf ios/Podfile.lock
rm -rf ios/.symlinks

flutter pub get

cd ios
pod install --repo-update
cd ..

flutter run
Dependency ownership: The main project installs the SDK's required iOS dependencies during pod install. Do not add internal SDK dependencies manually.