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.

Participant fields live on 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

  1. Caller runs BaxCalls.startOutgoing → kit returns invitationId, roomName, pushPayload, and optional server-push status.
  2. Server (if FCM / APNs VoIP configured) delivers a data message to each callee's registered token. If not configured, you must send pushPayload yourself — or rely on foreground incoming poll while the app is open (dev / no-push fallback).
  3. Callee app receives push → BaxCalls.showIncoming(BaxIncomingCall.fromPush(data)), or poll discovers a ringing invite → same showIncoming → native ring UI + incoming / ringing events.
  4. User accepts (native Accept or BaxCalls.accept) → kit emits accepted then connected → your UI opens BaxCallView for that roomName.
  5. Caller learns accept via the kit's automatic status polling while ringing and/or in-room system messages — then also gets accepted / connected.
When both users are already connected to the same realtime room, you can skip push and call 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.

BaxCloud does not ship Firebase or Apple certificates. You bring your own. Without project credentials, invites still succeed and return 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_only

Same 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)

  1. Enable Push Notifications + Voice over IP background mode.
  2. Obtain the VoIP device token via PushKit (native or a Flutter PushKit plugin).
  3. Register with BaxPushPlatform.iosVoip.
  4. On VoIP push received, call showIncoming promptly — 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.

MechanismWhenNotes
PushBackground / killed appInstant; requires FCM / PushKit wiring
Incoming pollApp open, push not configured or disabledKit polls for ringing invites every ~3s while idle (setIncomingPollEnabled)
Outgoing pollCaller while ringingAutomatic status polling every ~2s until accept / decline / timeout
No double ring: both push and poll call the same 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);
Incoming poll does not replace push for production. It only works while the app is in the foreground. Background and killed-state ringing still require FCM / PushKit (or your own delivery of pushPayload).

Call lifecycle (kit methods)

Use these APIs — the kit handles BaxCloud networking for you.

ActionKit API
Register pushBaxCalls.registerPushToken
InviteBaxCalls.startOutgoing
Accept / decline / cancel / endBaxCalls.accept / decline / cancel / end
Outgoing status while ringingAutomatic (no app code)
Incoming poll (foreground fallback)BaxCalls.setIncomingPollEnabled
Push capabilitiesBaxCalls.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.

EventWhen it firesUseful fields
incomingAfter showIncoming / system call_invitationincoming, invitationId, roomName, callType
outgoingAfter startOutgoing succeedsinvitation (includes pushPayload)
ringingImmediately after incoming or outgoing startsSame as above — show “Calling…” UI
acceptedLocal accept, poll sees accepted, or system call_acceptedinvitation / incoming, roomName
connectedRight after accepted when media path is ready — navigate hereroomName, callType
declined / cancelled / missed / timeout / endedRemote or local hang-up / reject / expiry pathsPop call UI, stop ringtone, clear state
muteChanged / cameraChanged / holdChangedCallKit / kit media togglesmuted, cameraEnabled, onHold
errorAPI or kit failureserror, 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}
CallKit Accept / Decline also drive the same stream — you usually do not need a separate button handler if you already listen for 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

ValueLookBest for
BaxCallLayout.faceTimeFull-screen remote + rounded local PiP1:1 video (default)
BaxCallLayout.gridEqual tilesGroup video (default)
BaxCallLayout.carouselLarge focus + strip of othersMulti-party with a speaker
BaxCallLayout.audioCentered avatar / nameVoice calls (default)

Camera & media defaults

  • cameraFacingfront (default) or back
  • autoEnableCamera / autoEnableMicrophone — publish on connect (default true)
  • enableCameraFlip — show Flip control on video calls
  • mirrorLocalVideo — mirror local preview (default true)

Controls

  • controlsStylefloating (FaceTime circles) or bar (compact bottom strip)
  • showDuration — live call timer (m:ss / h:mm:ss), default on
  • showConnectionStatus — Video/Audio · Secure chips in the top chrome
  • showControls, 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);
Drive media with 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.