Kayla Agora Voice Call

A highly customizable and flexible Agora voice call package for Flutter applications.

Features

  • 📱 Complete voice call solution with Agora RTC Engine
  • 🎨 Highly customizable UI
  • 🔌 Flexible integration with any state management solution
  • 💾 Support for various database backends (Firebase, REST API..)
  • 📞 CallKit integration for native call UI
  • 🔄 Call state management and events
  • 📊 Call history tracking
  • 🛠️ Extensive configuration options

Installation

dependencies:
  kayla_agora_voice_call: ^0.1.0

Getting Started

1. Initialize the package

First, you need to configure and initialize the call service.

import 'package:kayla_agora_voice_call/kayla_agora_voice_call.dart';

// Configure the call service
final callConfig = KaylaCallConfig(
  agoraAppId: 'YOUR_AGORA_APP_ID',
  callTimeoutDuration: 60, // seconds
  theme: KaylaCallTheme(
    primaryColor: Colors.blue,
    backgroundColor: Colors.black,
    textColor: Colors.white,
    acceptCallColor: Colors.green,
    declineCallColor: Colors.red,
  ),
  tokenGenerator: (channelId, userId) async {
    // Generate or fetch your Agora token here
    // This could be a call to your backend server
    final response = await http.get(
      Uri.parse('https://your-api.com/generate-token?channelId=$channelId&userId=$userId'),
    );
    return response.body;
  },
);

// Create and initialize the call service
final callService = KaylaCallService(
  config: callConfig,
  // Optional: provide a database implementation
  database: MyFirebaseCallDatabase(),
);

await callService.initialize();

2. Start a call

To start a call, use the startCall method:

final call = await callService.startCall(
  callerId: currentUser.id,
  callerName: currentUser.name,
  receiverId: otherUser.id,
  receiverName: otherUser.name,
  callerProfilePic: currentUser.profilePic,
  receiverProfilePic: otherUser.profilePic,
);

// Show the call screen
Navigator.push(
  context,
  MaterialPageRoute(
    builder: (context) => KaylaCallScreen(
      callService: callService,
      call: call!,
      uiConfig: CallUIConfig(
        showCallerProfilePicture: true,
        showCallerName: true,
        showCallDuration: true,
        useBlurredBackground: true,
        useBackgroundGradient: true,
        backgroundGradientColors: [
          Colors.blue.shade900,
          Colors.black,
        ],
      ),
      onCallEnded: () {
        Navigator.pop(context);
      },
    ),
  ),
);

3. Handle incoming calls

To handle incoming calls, show the incoming call UI:

callService.callEvents.listen((event) {
  if (event == CallEvent.callKitAccept) {
    // Get the call from the event data and navigate to the call screen
    final callId = callService.currentCall?.callId;
    if (callId != null) {
      Navigator.push(
        context,
        MaterialPageRoute(
          builder: (context) => KaylaCallScreen(
            callService: callService,
            call: callService.currentCall!,
          ),
        ),
      );
    }
  }
});

// Show incoming call UI (usually triggered by a push notification)
await callService.showIncomingCallUI(
  callId: incomingCallData.callId,
  callerName: incomingCallData.callerName,
  callerId: incomingCallData.callerId,
  callerImageUrl: incomingCallData.callerProfilePic,
);

4. Implement a database

For call history and state persistence, implement a database:

class MyFirebaseCallDatabase implements CallDatabaseInterface {
  final FirebaseFirestore _firestore;
  
  MyFirebaseCallDatabase({FirebaseFirestore? firestore}) 
      : _firestore = firestore ?? FirebaseFirestore.instance;
  
  @override
  Future<void> saveCall(CallModel call) async {
    await _firestore.collection('calls').doc(call.callId).set(call.toMap());
  }
  
  @override
  Future<void> updateCall(CallModel call) async {
    await _firestore.collection('calls').doc(call.callId).update(call.toMap());
  }
  
  @override
  Future<void> updateCallStatus(String callId, CallStatus status) async {
    await _firestore.collection('calls').doc(callId).update({
      'status': status.name,
      if (status == CallStatus.completed || 
          status == CallStatus.missed || 
          status == CallStatus.rejected)
        'endTime': DateTime.now().toIso8601String(),
    });
  }
  
  @override
  Future<CallModel?> getCall(String callId) async {
    final doc = await _firestore.collection('calls').doc(callId).get();
    if (doc.exists && doc.data() != null) {
      return CallModel.fromMap(doc.data()!);
    }
    return null;
  }
  
  @override
  Future<List<CallModel>> getCallsForUser(String userId, {int limit = 20}) async {
    final query = await _firestore.collection('calls')
        .where(Filter.or(
          Filter.equalTo('callerId', userId),
          Filter.equalTo('receiverId', userId),
        ))
        .orderBy('startTime', descending: true)
        .limit(limit)
        .get();
    
    return query.docs
        .map((doc) => CallModel.fromMap(doc.data()))
        .toList();
  }
  
  @override
  Future<List<CallModel>> getCallsBetweenUsers(String user1Id, String user2Id, {int limit = 20}) async {
    final query = await _firestore.collection('calls')
        .where(Filter.or(
          Filter.and(
            Filter.equalTo('callerId', user1Id),
            Filter.equalTo('receiverId', user2Id),
          ),
          Filter.and(
            Filter.equalTo('callerId', user2Id),
            Filter.equalTo('receiverId', user1Id),
          ),
        ))
        .orderBy('startTime', descending: true)
        .limit(limit)
        .get();
    
    return query.docs
        .map((doc) => CallModel.fromMap(doc.data()))
        .toList();
  }
}

Customizing the UI

You can fully customize the call screen UI:

KaylaCallScreen(
  callService: callService,
  call: call,
  uiConfig: CallUIConfig(
    // Basic configuration
    showCallerProfilePicture: true,
    showCallerName: true,
    showCallDuration: true,
    useBlurredBackground: true,
    showMuteButton: true,
    showSpeakerButton: true,
    
    // Custom UI elements
    avatarBuilder: (profileUrl, name) {
      return CircleAvatar(
        backgroundImage: profileUrl != null ? NetworkImage(profileUrl) : null,
        backgroundColor: Colors.purple,
        radius: 60,
        child: profileUrl == null ? Text(name[0], style: TextStyle(fontSize: 40)) : null,
      );
    },
    
    callerInfoBuilder: (name, status) {
      return Column(
        children: [
          Text(name, style: TextStyle(fontSize: 30, fontWeight: FontWeight.bold)),
          Text(status ?? '', style: TextStyle(fontSize: 18)),
        ],
      );
    },
    
    // Custom action buttons
    customActionButtons: [
      CallActionButton(
        icon: Icons.message,
        color: Colors.white,
        label: 'Message',
        onPressed: () {
          // Handle message button press
        },
      ),
    ],
    
    // Custom control buttons
    customControlButtons: [
      CallControlButton(
        icon: Icons.mic_off,
        color: Colors.white,
        backgroundColor: Colors.grey,
        label: 'Mute',
        onPressed: () {
          callService.toggleMute();
        },
      ),
      CallControlButton(
        icon: Icons.call_end,
        color: Colors.white,
        backgroundColor: Colors.red,
        label: 'End',
        onPressed: () {
          callService.endCall();
        },
      ),
    ],
    
    // Background styling
    useBackgroundGradient: true,
    backgroundGradientColors: [
      Colors.blue.shade900,
      Colors.black,
    ],
  ),
),

CallKit Integration

The package integrates with CallKit on iOS and the equivalent on Android for a native call experience:

// Show native call UI for incoming calls
await callService.showIncomingCallUI(
  callId: callId,
  callerName: callerName,
  callerId: callerId,
  callerImageUrl: callerImageUrl,
);

// Listen for CallKit events
callService.callEvents.listen((event) {
  switch (event) {
    case CallEvent.callKitAccept:
      // Handle call accept from CallKit
      break;
    case CallEvent.callKitDecline:
      // Handle call decline from CallKit
      break;
    case CallEvent.callKitEnd:
      // Handle call end from CallKit
      break;
    case CallEvent.callKitTimeout:
      // Handle call timeout from CallKit
      break;
    default:
      break;
  }
});

Managing Calls

Control ongoing calls with these methods:

// Mute/unmute microphone
await callService.toggleMute();

// Enable/disable speaker
await callService.toggleSpeaker();

// End the current call
await callService.endCall();

// Accept an incoming call
await callService.acceptCall(callId);

// Decline an incoming call
await callService.declineCall(callId);

// Check call status and properties
final isMuted = callService.isMuted;
final isSpeakerOn = callService.isSpeakerOn;
final callDuration = callService.callDuration;
final isCallInProgress = callService.isCallInProgress;
final callStatus = callService.callStatus;

Permissions

The package automatically handles microphone permissions, but you can also check them manually:

final hasPermissions = await callService.checkPermissions();
if (!hasPermissions) {
  // Handle permission denied
}

Events

Listen to call events:

callService.callEvents.listen((event) {
  switch (event) {
    case CallEvent.initialized:
      print('Call service initialized');
      break;
    case CallEvent.initiating:
      print('Initiating call');
      break;
    case CallEvent.started:
      print('Call started');
      break;
    case CallEvent.joined:
      print('Joined call');
      break;
    case CallEvent.remoteUserJoined:
      print('Remote user joined');
      break;
    case CallEvent.remoteUserLeft:
      print('Remote user left');
      break;
    case CallEvent.ended:
      print('Call ended');
      break;
    case CallEvent.error:
      print('Call error');
      break;
    default:
      break;
  }
});

License

This package is licensed under the MIT License.

Credits

Developed by Kayla.