UI Kits / Calls
Calls UI Kit (Flutter)
1:1 and group audio/video calling with native ringing UI, server-mediated invites, and bring-your-own FCM / APNs VoIP push. Package: baxcloud_calls_uikit_sdk.
Overview
The Calls UI Kit handles ringing, accept/decline, native call UI, and joining the call room after accept. Your app owns user identity, Firebase / PushKit certificates, and navigation into BaxCallView (or your own in-call screen).
Cold-start ringing
Data push + native ringing UI so calls ring when the app is backgrounded or killed.
1:1 and group
Invite one toUserId or many toUserIds.
BYO push
Optional project FCM + APNs VoIP credentials; without them, you deliver pushPayload yourself.
BaxcloudUser only: userId, name, avatarUrl, metadata. BaxConfig is credentials / analytics — not participant data. Optional view metadata overrides user keys on join.Package: baxcloud_core ^0.1.4
How a call flows
Where data comes from at each step
- Caller runs
BaxCalls.startOutgoing→ kit returnsinvitationId,roomName,pushPayload, and optional server-push status. - Server (if FCM / APNs VoIP configured) delivers a data message to each callee's registered token. If not configured, you must send
pushPayloadyourself — or rely on foreground incoming poll while the app is open (dev / no-push fallback). - Callee app receives push →
BaxCalls.showIncoming(BaxIncomingCall.fromPush(data)), or poll discovers a ringing invite → sameshowIncoming→ native ring UI +incoming/ringingevents. - User accepts (native Accept or
BaxCalls.accept) → kit emitsacceptedthenconnected→ your UI opensBaxCallViewfor thatroomName. - Caller learns accept via the kit's automatic status polling while ringing and/or in-room system messages — then also gets
accepted/connected.
BaxCalls.handleSystemMessage(map) for call_invitation / call_accepted / call_declined / call_cancelled.Push setup (FCM + APNs VoIP)
Required for reliable cold-start ringing. Configure credentials in the dashboard, register device tokens in the app, then forward push payloads into BaxCalls.showIncoming.
pushPayload, but server-side cold-start delivery is limited — your backend or app must deliver the push.1. Dashboard — Project → Features → Call push
- FCM: paste a Firebase service account JSON so invite can send Android data messages.
- APNs VoIP: Key ID, Team ID, Bundle ID, AuthKey
.p8, and production flag for PushKit wakes on iOS. - Open your project in the dashboard → Features → Call push (links back to this guide).
2. Check capabilities
1final caps = await BaxCalls.refreshCapabilities();
2print(caps.fcmConfigured); // project has FCM JSON?
3print(caps.apnsVoipConfigured); // project has APNs VoIP?
4print(caps.serverPushAndroid); // server will send FCM?
5print(caps.serverPushIosVoip); // server will send VoIP?
6print(caps.coldStartAndroid); // e.g. app_owned_push_only
7print(caps.coldStartIos); // e.g. app_owned_pushkit_onlySame flags are available anytime via BaxCalls.refreshCapabilities().
3. Register device tokens
Tokens are stored per userId from localUser. Re-register after login and whenever FCM / VoIP tokens refresh.
1await BaxCalls.registerPushToken(
2 fcmToken,
3 platform: BaxPushPlatform.android,
4);
5
6await BaxCalls.registerPushToken(
7 apnsToken,
8 platform: BaxPushPlatform.ios,
9);
10
11await BaxCalls.registerPushToken(
12 voipToken,
13 platform: BaxPushPlatform.iosVoip,
14);
15
16// On logout:
17await BaxCalls.unregisterPushToken(fcmToken);4. Android — Firebase Messaging
1('vm:entry-point')
2Future<void> firebaseMessagingBackgroundHandler(RemoteMessage message) async {
3 // Ensure BaxCalls.initialize ran (or re-init with stored credentials).
4 final data = message.data;
5 if (data['type'] == 'bax_call_invite') {
6 await BaxCalls.showIncoming(
7 BaxIncomingCall.fromPush(Map<String, dynamic>.from(data)),
8 );
9 }
10}
11
12FirebaseMessaging.onBackgroundMessage(firebaseMessagingBackgroundHandler);
13
14FirebaseMessaging.onMessage.listen((m) async {
15 if (m.data['type'] == 'bax_call_invite') {
16 await BaxCalls.showIncoming(
17 BaxIncomingCall.fromPush(Map<String, dynamic>.from(m.data)),
18 );
19 }
20});
21
22FirebaseMessaging.instance.onTokenRefresh.listen((token) async {
23 await BaxCalls.registerPushToken(token, platform: BaxPushPlatform.android);
24});5. iOS — PushKit (VoIP)
- Enable Push Notifications + Voice over IP background mode.
- Obtain the VoIP device token via PushKit (native or a Flutter PushKit plugin).
- Register with
BaxPushPlatform.iosVoip. - On VoIP push received, call
showIncomingpromptly — Apple expects CallKit to be reported for VoIP pushes.
1await BaxCalls.registerPushToken(
2 voipToken,
3 platform: BaxPushPlatform.iosVoip,
4);
5
6// Inside your PushKit callback:
7await BaxCalls.showIncoming(
8 BaxIncomingCall.fromPush(Map<String, dynamic>.from(payloadMap)),
9);6. Push payload shape
Every invite response includes pushPayload. FCM / VoIP data should match this (string values are fine — fromPush parses them):
1{
2 "type": "bax_call_invite",
3 "invitationId": "inv_...",
4 "roomName": "call_...",
5 "callType": "video",
6 "fromUserId": "u1",
7 "fromName": "Ada",
8 "fromAvatarUrl": "https://...",
9 "toUserId": "u2",
10 "toUserIds": "u2,u3",
11 "isGroup": "true",
12 "message": "Join us?",
13 "expiresAt": "2026-08-25T12:00:00.000Z"
14}Foreground incoming poll (push fallback)
Optional foreground polling via BaxCalls.setIncomingPollEnabled when push is unavailable — for dev, simulators, or apps in the foreground without FCM.
Push (FCM / APNs VoIP) is the primary delivery path for background and killed-state ringing. The kit also supports a lightweight foreground poll so callees can receive invites while the app is open — useful for two-device testing without configuring Firebase.
| Mechanism | When | Notes |
|---|---|---|
| Push | Background / killed app | Instant; requires FCM / PushKit wiring |
| Incoming poll | App open, push not configured or disabled | Kit polls for ringing invites every ~3s while idle (setIncomingPollEnabled) |
| Outgoing poll | Caller while ringing | Automatic status polling every ~2s until accept / decline / timeout |
BaxCalls.showIncoming. The kit deduplicates by invitationId and skips poll ticks when an incoming call is already active — push and poll do not fight each other.Default behavior
- After
BaxCalls.initialize, poll starts only when the project has no server push (FCM / APNs VoIP not configured in the dashboard). - Poll runs only when the user is not in an outgoing call and has no active incoming call (negligible overhead: one small indexed API read every ~3 seconds).
- When a ringing invite is found, the kit calls
showIncoming— same path as push.
Production (push configured)
Register FCM / VoIP tokens, then disable incoming poll — push handles delivery:
1await BaxCalls.registerPushToken(
2 fcmToken,
3 platform: BaxPushPlatform.android,
4);
5
6// Push is primary — turn off foreground poll
7BaxCalls.setIncomingPollEnabled(false);Dev / testing (no FCM)
Keep poll enabled (or force it on) for two-device testing while both apps stay in the foreground:
1// Example app pattern — poll when push is not set up
2BaxCalls.setIncomingPollEnabled(true);
3
4// Each device uses a distinct localUser.userId (e.g. user1 / user2)
5await BaxCalls.initialize(
6 config: config,
7 localUser: BaxcloudUser(userId: 'user2', name: 'Bob'),
8);pushPayload).Call lifecycle (kit methods)
Use these APIs — the kit handles BaxCloud networking for you.
| Action | Kit API |
|---|---|
| Register push | BaxCalls.registerPushToken |
| Invite | BaxCalls.startOutgoing |
| Accept / decline / cancel / end | BaxCalls.accept / decline / cancel / end |
| Outgoing status while ringing | Automatic (no app code) |
| Incoming poll (foreground fallback) | BaxCalls.setIncomingPollEnabled |
| Push capabilities | BaxCalls.refreshCapabilities |
After accept, open BaxCallView for the room. Hang up with BaxCalls.end() (also called when BaxCallView disposes if autoEndOnHangUp is true).
Events
Subscribe to BaxCalls.instance.events — a broadcast stream of BaxCallEvent
Events are how your UI learns what happened. Sources include: your own kit API calls, native CallKit button presses, server invite/status responses, outgoing status polling, and in-room system messages via handleSystemMessage.
| Event | When it fires | Useful fields |
|---|---|---|
incoming | After showIncoming / system call_invitation | incoming, invitationId, roomName, callType |
outgoing | After startOutgoing succeeds | invitation (includes pushPayload) |
ringing | Immediately after incoming or outgoing starts | Same as above — show “Calling…” UI |
accepted | Local accept, poll sees accepted, or system call_accepted | invitation / incoming, roomName |
connected | Right after accepted when media path is ready — navigate here | roomName, callType |
declined / cancelled / missed / timeout / ended | Remote or local hang-up / reject / expiry paths | Pop call UI, stop ringtone, clear state |
muteChanged / cameraChanged / holdChanged | CallKit / kit media toggles | muted, cameraEnabled, onHold |
error | API or kit failures | error, optional message |
Event object
1class BaxCallEvent {
2 final BaxCallEventType type;
3 final String? invitationId;
4 final String? roomName;
5 final BaxCallType? callType;
6 final BaxIncomingCall? incoming; // callee side
7 final BaxCallInvitation? invitation; // caller side / API row
8 final bool? muted;
9 final bool? cameraEnabled;
10 final bool? onHold;
11 final Object? error;
12 final String? message;
13}Full listener example
1late final StreamSubscription<BaxCallEvent> _sub;
2
3void startListening() {
4 _sub = BaxCalls.instance.events.listen((event) {
5 switch (event.type) {
6 case BaxCallEventType.incoming:
7 final from = event.incoming?.fromName ?? 'Unknown';
8 debugPrint('Incoming from $from room=${event.roomName}');
9 case BaxCallEventType.outgoing:
10 debugPrint('Outgoing id=${event.invitation?.invitationId}');
11 case BaxCallEventType.ringing:
12 setState(() => status = 'Ringing…');
13 case BaxCallEventType.accepted:
14 // Optional: prepare UI; wait for connected to join media
15 break;
16 case BaxCallEventType.connected:
17 openCallScreen(
18 roomName: event.roomName!,
19 callType: event.callType ?? BaxCallType.video,
20 );
21 case BaxCallEventType.declined:
22 case BaxCallEventType.cancelled:
23 case BaxCallEventType.missed:
24 case BaxCallEventType.timeout:
25 case BaxCallEventType.ended:
26 closeCallScreen();
27 case BaxCallEventType.muteChanged:
28 setState(() => micMuted = event.muted ?? false);
29 case BaxCallEventType.cameraChanged:
30 setState(() => camOn = event.cameraEnabled ?? true);
31 case BaxCallEventType.holdChanged:
32 setState(() => onHold = event.onHold ?? false);
33 case BaxCallEventType.error:
34 showError(event.error ?? event.message);
35 }
36 });
37}
38
39
40void dispose() {
41 _sub.cancel();
42 super.dispose();
43}connected / declined.In-call UI
BaxCallView fetches a token and shows the media surface. Provide config via BaxCallScope, the widget's config argument, or the config already stored in BaxCalls.initialize.
Defaults are FaceTime-style for 1:1 video: front camera, full-screen remote, local picture-in-picture, and floating circular controls. Customize with uiConfig — see UI design.
1BaxCallView(
2 roomName: roomName,
3 user: localUser, // BaxcloudUser
4 isHost: true,
5 canJoinWithNoHost: true,
6 callType: BaxCallType.video,
7 isGroup: false,
8 autoEndOnHangUp: true,
9 uiConfig: const BaxCallUiConfig(
10 layout: BaxCallLayout.faceTime,
11 cameraFacing: BaxCallCameraFacing.front,
12 fullScreen: true,
13 controlsStyle: BaxCallControlsStyle.floating,
14 ),
15 // Optional builders for full custom chrome:
16 // participantTileBuilder: (context, participant) => ...,
17 // controlsBuilder: (context) => ...,
18 // inCallBuilder: (context) => ...,
19);UI design options
Layouts, camera, controls, and colors via BaxCallUiConfig
Pass uiConfig to BaxCallView so product / UI developers can choose the call look without forking the kit. When layout is omitted, the kit picks automatically: audio → audio stage, group video → grid, 1:1 video → FaceTime.
Layouts
| Value | Look | Best for |
|---|---|---|
| BaxCallLayout.faceTime | Full-screen remote + rounded local PiP | 1:1 video (default) |
| BaxCallLayout.grid | Equal tiles | Group video (default) |
| BaxCallLayout.carousel | Large focus + strip of others | Multi-party with a speaker |
| BaxCallLayout.audio | Centered avatar / name | Voice calls (default) |
Camera & media defaults
cameraFacing—front(default) orbackautoEnableCamera/autoEnableMicrophone— publish on connect (defaulttrue)enableCameraFlip— show Flip control on video callsmirrorLocalVideo— mirror local preview (defaulttrue)
Controls
controlsStyle—floating(FaceTime circles) orbar(compact bottom strip)showDuration— live call timer (m:ss/h:mm:ss), default onshowConnectionStatus— Video/Audio · Secure chips in the top chromeshowControls,enableMute,enableCameraToggle,enableSpeaker- Colors:
hangUpColor,controlBackgroundColor,controlActiveColor,backgroundColor,textColor
PiP (FaceTime layout)
1const BaxCallUiConfig(
2 layout: BaxCallLayout.faceTime,
3 showLocalPip: true,
4 pipAlignment: Alignment.topRight,
5 pipSize: Size(110, 160),
6 pipBorderRadius: 16,
7);Presets
1// 1:1 video (same as default constructor)
2BaxCallView(..., uiConfig: BaxCallUiConfig.faceTime);
3
4// Group meeting grid
5BaxCallView(..., uiConfig: BaxCallUiConfig.groupGrid);
6
7// Voice-only
8BaxCallView(..., uiConfig: BaxCallUiConfig.audioOnly);
9
10// Brand accents
11BaxCallView(
12 ...,
13 uiConfig: const BaxCallUiConfig(
14 hangUpColor: Color(0xFFE11D48),
15 controlBackgroundColor: Color(0xCC1E293B),
16 controlsStyle: BaxCallControlsStyle.bar,
17 ),
18);BaxCallController (BaxCalls.instance.activeController, session.controller, or BaxCallController.of(context)). For custom chrome, use inCallBuilder and embed session.media — the kit keeps the room connected. Partial overrides: controlsBuilder / participantTileBuilder. Theme: BaxCallUiConfig.Call controller
1final c = BaxCalls.instance.activeController;
2await c?.toggleMute();
3await c?.setCameraEnabled(false);
4await c?.flipCamera();
5await c?.setSpeakerOn(true);
6await c?.hangUp();Bring your own full screen
1BaxCallView(
2 roomName: roomName,
3 user: const BaxcloudUser(userId: 'u1', name: 'Ada'),
4 callType: BaxCallType.video,
5 inCallBuilder: (context, session) {
6 return Stack(
7 fit: StackFit.expand,
8 children: [
9 session.media,
10 MyControls(controller: session.controller),
11 ],
12 );
13 },
14);Prefer session.media + session.controller — do not open a second media connection with credentials. To own token + media entirely, skip BaxCallView and navigate after BaxCalls.accept / connected.
Troubleshooting
No ring when app is killed
Confirm project Call push credentials, token registered under the correct userId, and (iOS) PushKit + ios_voip platform. Check refreshCapabilities().
Push arrives but CallKit never shows
Ensure BaxCalls.initialize completed before showIncoming. Parse data with Map<String, dynamic>.from(...). Filter on type == bax_call_invite.
Caller never leaves “ringing”
Callee must accept successfully. Caller relies on poll + system messages — verify network and that both sides use the same project / invitation id.
Accept works but no video/audio
Open BaxCallView (or your media UI) on connected with the event’s roomName. Check camera/mic permissions and that your API key can create tokens.
401 / key errors
Use a client key bax_pk_… with the right scopes. If the key has allowed bundle IDs, set bundleId on BaxConfig or rely on auto-detect.
Limits & best practices
Always initialize BaxCalls before handling a push so CallKit can accept from a cold start.
Prefer client keys (bax_pk_…); restrict by bundle ID in the dashboard when shipping mobile apps.
With FCM / PushKit in production, call BaxCalls.setIncomingPollEnabled(false) after registering push tokens — see incoming poll.
Without FCM/APNs VoIP on the project, treat cold-start as app-owned — deliver pushPayload yourself, or use foreground poll for dev only.
Group calls: register each callee's push token under their userId; invite with toUserIds.
Cancel the events subscription in dispose to avoid duplicate navigations.