call_ui_kit
A ready-to-use Flutter call UI kit that handles personal audio calls, personal video calls, and group calls. Zero external dependencies — only Flutter SDK.
Inspired by WhatsApp call design. Fully customizable through themes and localized strings.
Screenshots
| Screenshot | Description |
|---|---|
| Personal Audio Call | 1:1 audio call with caller avatar, status text, and controls |
| Personal Video Call | 1:1 video call with draggable PiP, flip camera, and more sheet |
| Screen Share | Group call with screen sharing and participant thumbnails |
| More Sheet | Customizable bottom sheet with encryption label |
| Speaker View | 7+ participants — active speaker prominent with thumbnail row |
| Speaking Border | Animated green border on the active speaker's tile |
Features
- Personal audio call — caller avatar, call status, speaker toggle
- Personal video call — remote/local video, draggable PiP, camera flip, swap on tap
- Group video call — adaptive grid (2x2, 2x3), speaker view (7+ participants), thumbnail row
- Group audio call — participant tiles with avatars, speaking indicators
- Screen sharing — dedicated layout with screen share banner and stop button
- Auto-hide controls — top bar, bottom bar, and side buttons hide after 4 seconds
- Participants panel — draggable bottom sheet with host actions (mute, remove)
- "More" bottom sheet — customizable actions sheet with encryption label
- Full localization — every user-facing string is configurable via
CallStrings - Full theming — every color is configurable via
CallTheme - Full sizing — every size is configurable via
CallDimensions, from a single button to the whole UI at once - Speaking indicators — animated sine-wave bars and glowing tile borders
- Signal strength — 4-level indicator per participant
- PiP view — draggable and corner-snapping; follows the controls, expanding into the bar areas while they are hidden and keeping the corner you left it in
- Reconnection-safe video — opaque video surfaces and per-build participant data, so a recreated renderer never leaves a blank or stale frame
Installation
Add to your pubspec.yaml:
dependencies:
call_ui_kit: ^0.6.0
Quick Start
Personal Audio Call
CallScreen(
callerName: 'Sarah Johnson',
callerAvatarUrl: 'https://example.com/avatar.jpg',
callType: CallType.audio,
localParticipant: const CallParticipant(
id: 'local',
displayName: 'You',
isLocalUser: true,
),
isMuted: _isMuted,
isSpeakerOn: _isSpeakerOn,
callStatusText: '02:45',
onEndCall: () => Navigator.pop(context),
onToggleMute: () => setState(() => _isMuted = !_isMuted),
onToggleSpeaker: () => setState(() => _isSpeakerOn = !_isSpeakerOn),
)
Personal Video Call
CallScreen(
callerName: 'Alex Rivera',
callerAvatarUrl: 'https://example.com/avatar.jpg',
callType: CallType.video,
localParticipant: const CallParticipant(
id: 'local',
displayName: 'You',
isLocalUser: true,
),
localVideoWidget: localVideoView, // your camera widget
remoteVideoWidget: remoteVideoView, // remote camera widget
isMuted: _isMuted,
isCameraOff: _isCameraOff,
isSpeakerOn: _isSpeakerOn,
callStatusText: '04:23',
onEndCall: () => Navigator.pop(context),
onToggleMute: () => setState(() => _isMuted = !_isMuted),
onToggleCamera: () => setState(() => _isCameraOff = !_isCameraOff),
onToggleSpeaker: () => setState(() => _isSpeakerOn = !_isSpeakerOn),
onFlipCamera: () => /* flip camera logic */,
)
Group Video Call
CallScreen(
callerName: 'Team Meeting',
isGroupCall: true,
callType: CallType.video,
localParticipant: const CallParticipant(
id: 'local',
displayName: 'You',
isLocalUser: true,
isHost: true,
),
participants: [
CallParticipant(
id: '2',
displayName: 'Alex Rivera',
avatarUrl: 'https://example.com/alex.jpg',
videoWidget: alexVideoView, // or null if camera off
isSpeaking: true,
),
CallParticipant(
id: '3',
displayName: 'Priya Sharma',
avatarUrl: 'https://example.com/priya.jpg',
isMuted: true,
isCameraOff: true,
),
// ... more participants
],
isMuted: _isMuted,
isCameraOff: _isCameraOff,
isSpeakerOn: _isSpeakerOn,
callStatusText: '12:07',
onEndCall: () => Navigator.pop(context),
onToggleMute: () => setState(() => _isMuted = !_isMuted),
onToggleCamera: () => setState(() => _isCameraOff = !_isCameraOff),
onToggleSpeaker: () => setState(() => _isSpeakerOn = !_isSpeakerOn),
onAddParticipant: () => /* invite logic */,
onMuteParticipant: (p) => /* mute participant */,
onRemoveParticipant: (p) => /* remove participant */,
)
Screen Sharing
CallScreen(
callerName: 'Design Review',
isGroupCall: true,
callType: CallType.video,
localParticipant: localParticipant,
participants: participants,
screenShareWidget: screenShareView, // the shared screen widget
isScreenSharing: _isLocalSharing,
onStopScreenShare: () => setState(() => _isLocalSharing = false),
// ... other required params
)
Incoming / Outgoing Call
IncomingCallScreen(
callerName: 'Alex Rivera',
callerAvatarUrl: 'https://example.com/avatar.jpg',
callType: CallType.video,
onAccept: _acceptCall,
onDecline: () => Navigator.pop(context),
)
OutgoingCallScreen(
callerName: 'Alex Rivera',
callType: CallType.video,
isMuted: _isMuted,
isSpeakerOn: _isSpeakerOn,
onEndCall: () => Navigator.pop(context),
onToggleMute: () => setState(() => _isMuted = !_isMuted),
onToggleSpeaker: () => setState(() => _isSpeakerOn = !_isSpeakerOn),
)
API Reference
CallScreen
The main widget. All UI is driven by the parameters you pass — the kit does not manage call state.
Required Parameters
| Parameter | Type | Description |
|---|---|---|
callerName |
String |
Caller or group name shown in the top bar |
localParticipant |
CallParticipant |
The local user's participant data |
onEndCall |
VoidCallback |
Called when end-call button is tapped |
onToggleMute |
VoidCallback |
Called when mute button is tapped |
onToggleSpeaker |
VoidCallback |
Called when speaker button is tapped |
Optional Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
callerAvatarUrl |
String? |
null |
Avatar URL for the caller |
isGroupCall |
bool |
false |
Enables group call layout |
callType |
CallType |
CallType.video |
.audio or .video |
participants |
List<CallParticipant> |
[] |
Remote participants (group call) |
localVideoWidget |
Widget? |
null |
Local camera stream widget |
remoteVideoWidget |
Widget? |
null |
Remote camera stream widget (personal call) |
screenShareWidget |
Widget? |
null |
Screen share stream widget |
isMuted |
bool |
false |
Local mic muted state |
isCameraOff |
bool |
false |
Local camera off state |
isSpeakerOn |
bool |
false |
Speaker active state |
isScreenSharing |
bool |
false |
Local screen sharing state |
connectionState |
CallConnectionState |
.connected |
Shows a persistent "Connecting…"/"Reconnecting…" banner |
showEncryptionLabel |
bool |
true |
Show encryption label in "more" sheet |
theme |
CallTheme |
CallTheme.whatsApp() |
Color theme |
dimensions |
CallDimensions |
CallDimensions() |
Size configuration |
strings |
CallStrings? |
null (English defaults) |
Localized strings |
callStatusText |
String? |
null |
Status text override (e.g. "02:45") |
callStatusListenable |
ValueListenable<String>? |
null |
Status text for values that tick; rebuilds only the status line |
Optional Callbacks
All optional callbacks — when null, the corresponding button is hidden.
| Callback | Type | Description |
|---|---|---|
onToggleCamera |
VoidCallback? |
Camera toggle button |
onFlipCamera |
VoidCallback? |
Flip camera button (top bar) |
onToggleScreenShare |
VoidCallback? |
Screen share button (bottom bar) |
onStopScreenShare |
VoidCallback? |
Stop button on screen share banner |
onAddParticipant |
VoidCallback? |
Add participant button (right side) |
onEffects |
VoidCallback? |
Effects button (right side) |
onMinimize |
VoidCallback? |
Minimize/PiP button (top bar) |
onMuteParticipant |
void Function(CallParticipant)? |
Host mutes a participant |
onMuteAll |
VoidCallback? |
Host taps "Mute all" in the participants panel |
onRemoveParticipant |
void Function(CallParticipant)? |
Host removes a participant |
moreSheetBuilder |
Widget Function(BuildContext, CallTheme)? |
Custom "more" sheet content |
CallParticipant
Immutable data model for a single participant. Supports copyWith() and proper equality (==/hashCode).
const CallParticipant({
required String id,
required String displayName,
String? avatarUrl,
bool isMuted = false,
bool isCameraOff = false,
bool isSpeaking = false,
bool isScreenSharing = false,
bool isHost = false,
bool isLocalUser = false,
SignalStrength signalStrength = SignalStrength.excellent,
Widget? videoWidget,
Widget? screenShareWidget,
})
| Field | Description |
|---|---|
id |
Unique identifier for the participant |
displayName |
Name shown on tile and in participant list |
avatarUrl |
Network image URL; falls back to colored initials |
isMuted |
Shows mute icon on tile |
isCameraOff |
Shows avatar instead of video |
isSpeaking |
Activates animated speaking border and indicator |
isScreenSharing |
Shows screen share badge |
isHost |
Shows host badge; enables host actions in panel |
isLocalUser |
Marks as local user |
signalStrength |
.excellent, .good, .poor, or .none |
videoWidget |
The live video stream widget |
screenShareWidget |
The screen share stream widget |
CallTheme
Controls all colors. Use CallTheme.whatsApp() as a starting point and copyWith() to customize.
CallTheme(
background: Color(0xFF000000),
barBackground: Color(0xFF1C1C1E),
buttonBackground: Color(0xFF2C2C2E),
endCallColor: Color(0xFFE53935),
speakingColor: Color(0xFF25D366),
speakerActiveBackground: Colors.white,
speakerActiveIconColor: Colors.black,
textPrimary: Colors.white,
textSecondary: Color(0xFFAAAAAA),
dividerColor: Color(0xFF3A3A3C),
acceptCallColor: Color(0xFF25D366),
)
Custom theme example:
final myTheme = const CallTheme.whatsApp().copyWith(
endCallColor: Colors.red,
speakingColor: Colors.blue,
);
CallDimensions
Every size the kit lays out — button diameters, icon and font sizes, bar heights, paddings, gaps and radii — is configurable: 138 metrics across 14 token classes. The defaults reproduce the layout the package has always shipped, so omitting dimensions changes nothing.
CallScreen, IncomingCallScreen and OutgoingCallScreen all take a dimensions parameter, as does every exported widget.
There are two ways to resize the UI, and they compose.
Set an individual metric. Sizes are grouped into one token class per surface, so you name the metric you want and leave the rest alone:
CallScreen(
dimensions: const CallDimensions(
bottomBar: CallBottomBarDimensions(
buttonSize: 56, // default 50
endCallButtonSize: 72, // default 58
iconSize: 24, // default 22
),
pip: CallPipDimensions(size: Size(120, 160)), // default 90x120
participantTile: CallParticipantTileDimensions(avatarRadius: 32),
),
...
)
Scale everything at once. The kit is laid out in raw logical pixels, which suit a phone held at reading distance. On a tablet the controls end up smaller than the rest of your app. scale multiplies every token:
CallScreen(
dimensions: CallDimensions(
scale: MediaQuery.sizeOf(context).shortestSide >= 550 ? 1.25 : 1.0,
),
...
)
scale applies on top of the tokens, so an override is scaled too — endCallButtonSize: 72 at scale: 1.25 renders 90.
Two presets are provided, and copyWith derives a variant from either:
const CallDimensions.compact(); // the original phone layout
const CallDimensions.comfortable(); // tablets, arm's length, gloves
// Note that copyWith replaces the preset's scale rather than multiplying it.
// comfortable() already carries scale 1.15, so this yields 1.4, not 1.61.
CallDimensions.comfortable().copyWith(scale: 1.4);
Token classes
| Group | Class | Fields | Covers |
|---|---|---|---|
topBar |
CallTopBarDimensions |
8 | bar height, name/status/count fonts, icon and tap-target sizes |
bottomBar |
CallBottomBarDimensions |
9 | control and end-call diameters, icon sizes, pill padding and radius |
rightButtons |
CallRightButtonsDimensions |
5 | side button diameter, spacing, insets |
videoContent |
CallVideoContentDimensions |
13 | personal-call avatar and name, screen-share panel, grid gutter |
participantTile |
CallParticipantTileDimensions |
17 | avatar, name, overlays, speaking bars and border |
thumbnailRow |
CallThumbnailRowDimensions |
6 | row height, thumbnail width, margin, radius |
pip |
CallPipDimensions |
8 | frame size, margin, radii, fallback avatar and label |
connectionBanner |
CallConnectionBannerDimensions |
5 | banner height, padding, icon and font |
screenShareBanner |
CallScreenShareBannerDimensions |
9 | banner height, padding, icon, font, stop chip |
participantsPanel |
CallParticipantsPanelDimensions |
24 | sheet radius, header, invite button, row metrics |
moreSheet |
CallMoreSheetDimensions |
9 | sheet radius, encryption label, cancel button |
handleBar |
CallHandleBarDimensions |
3 | drag-handle width, thickness, margin |
incoming |
CallIncomingScreenDimensions |
10 | avatar, name, status, accept/decline buttons |
outgoing |
CallOutgoingScreenDimensions |
12 | avatar, name, status, end-call and toggle buttons |
Every token class has copyWith, == and hashCode, so a rebuilt instance with the same values does not count as a change.
Reserved areas
If you compose your own overlay on top of CallScreen, reserve the same space the kit does. These follow whatever you configure:
dimensions.topBarHeight
dimensions.bottomBarHeight
dimensions.connectionBannerHeight
dimensions.rightButtonsHeight(hasAdd: true, hasEffects: false)
What scale does not touch
Four fields stay as declared, and each says so in its own doc comment: pip.borderWidth, participantTile.speakingBorderWidth and videoContent.gridGutter are hairlines, which should stay hairlines however large the rest grows; videoContent.personalAvatarFontRatio is a ratio, and the avatar it multiplies is already scaled. You can still set all four directly.
The Custom Dimensions entry in the example app switches between the presets, a per-metric override and a live scale slider — see example/lib/demos/custom_dimensions.dart.
CallStrings
Every user-facing string is configurable. Use CallStrings.english() as default, or provide your own for localization.
CallStrings(
calling: 'Calling...',
cameraIsOff: 'Camera is off',
you: 'You',
endToEndEncrypted: 'End-to-end encrypted',
shareScreen: 'Share screen',
sendMessage: 'Send message',
participants: 'Participants',
shareCallLink: 'Share call link',
cancel: 'Cancel',
stop: 'Stop',
youAreSharingYourScreen: 'You are sharing your screen',
speaking: 'Speaking',
muted: 'Muted',
muteAll: 'Mute all',
invite: 'Invite',
mute: 'Mute',
unmute: 'Unmute',
removeFromCall: 'Remove from call',
pictureInPicture: 'Picture in picture',
addParticipant: 'Add participant',
flipCamera: 'Flip camera',
effects: 'Effects',
connecting: 'Connecting…', // optional, defaulted
reconnecting: 'Reconnecting…', // optional, defaulted
isSharingScreen: (name) => '$name is sharing their screen',
participantsCount: (count) => '$count participant${count == 1 ? '' : 's'}',
moreParticipants: (count) => '+$count more',
)
CallType
enum CallType { audio, video }
CallConnectionState
enum CallConnectionState { connected, connecting, reconnecting }
Pass it to CallScreen.connectionState. Anything other than connected shows a
persistent banner under the top bar, using CallStrings.connecting /
CallStrings.reconnecting. The banner is never auto-hidden.
SignalStrength
enum SignalStrength { excellent, good, poor, none }
Reconnection
This kit is presentational: it renders the renderer widgets you hand it, in place. That matters when a call reconnects.
When a call drops, most WebRTC stacks dispose the old renderer and create a new
one. If the widget you pass has the same type and no key as the previous one,
Flutter treats it as an update to the existing element rather than a new
widget — initState does not re-run, and a renderer widget that subscribed to
the old (now dead) texture keeps painting nothing.
Give video widgets a key tied to renderer identity. Bump a generation counter whenever you recreate a renderer:
int _rendererGeneration = 0;
void _onReconnected() {
// ... recreate RTCVideoRenderer instances ...
setState(() => _rendererGeneration++);
}
CallScreen(
// ...
connectionState: _isReconnecting
? CallConnectionState.reconnecting
: CallConnectionState.connected,
localVideoWidget: RTCVideoView(
_localRenderer,
key: ValueKey('local-$_rendererGeneration'),
),
remoteVideoWidget: RTCVideoView(
_remoteRenderer,
key: ValueKey('remote-$_rendererGeneration'),
),
)
The same applies to CallParticipant.videoWidget and screenShareWidget in
group calls.
Two things the kit guarantees on its side:
- Every externally-provided video widget is mounted on an opaque
VideoSurface. A renderer that paints nothing — because its texture is being attached, was disposed, or threw during paint — shows as black, never as a transparent hole in the UI. - Participant data is read fresh on every build. Replacing a participant's
videoWidgetviacopyWithtakes effect immediately, even thoughCallParticipantequality deliberately ignores widget fields.
Performance
Two host-side habits decide how much work a call screen does per second. Both
are measured by test/screens/call_screen_rebuild_test.dart.
Push ticking values through a listenable. A call-duration timer updated via
setState re-runs CallScreen.build every second, and with it every video
surface: with three participants that is 30 extra video widget builds over ten
seconds. Pass callStatusListenable instead and only the status line rebuilds:
final _callStatus = ValueNotifier<String>('00:00');
// ... a periodic timer writes into _callStatus.value, no setState involved.
CallScreen(
callStatusListenable: _callStatus, // instead of callStatusText
// ...
)
Hold renderer widgets in fields, don't build them inline. Constructing
RTCVideoView(...) inside build hands the kit a new instance every rebuild,
so Flutter cannot skip the video subtree. Build them once per renderer
generation (see Reconnection) and store them:
late Widget _localVideo; // rebuilt only when the renderer changes
late Widget _remoteVideo;
With both in place, a ticking timer costs one Text rebuild — zero video
rebuilds. example/lib/demos/personal_video_call.dart implements this pattern.
Group Call Layouts
The layout is automatically resolved based on participant count:
| Participants | Layout |
|---|---|
| 1-2 | Full screen + PiP |
| 3-4 | 2x2 grid |
| 5-6 | 2x3 grid |
| 7+ | Speaker view (active speaker + thumbnail row) |
| Any + screen share | Screen share view + thumbnail row |
"More" Bottom Sheet
The moreSheetBuilder callback lets you add custom actions to the "more" bottom sheet. The kit wraps your content with a handle bar, encryption label, and cancel button.
CallScreen(
// ...
moreSheetBuilder: (context, theme) => Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: theme.buttonBackground,
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
ListTile(
title: Text('Share Screen', style: TextStyle(color: theme.textPrimary)),
trailing: Icon(Icons.screen_share, color: theme.textPrimary),
onTap: () {
Navigator.pop(context);
// your logic
},
),
ListTile(
title: Text('Send Message', style: TextStyle(color: theme.textPrimary)),
trailing: Icon(Icons.message, color: theme.textPrimary),
onTap: () {
Navigator.pop(context);
// your logic
},
),
],
),
),
)
Exported Widgets
These widgets are exported for standalone use if needed.
The Sizing column says how each one is sized. dimensions means it takes a CallDimensions and reads its own token group from it, defaulting to const CallDimensions(). The rest take their sizes directly as constructor parameters, so the caller scales what it passes.
| Widget | Description | Sizing |
|---|---|---|
CallAvatar |
Circle avatar with network image or colored initials fallback | radius, fontSize |
ParticipantTile |
Single participant tile with video, overlays, and speaking border | dimensions |
FloatingPipView |
Draggable picture-in-picture overlay that snaps to corners | dimensions |
SpeakingIndicator |
Animated 3-bar sine-wave speaking indicator | maxHeight, minHeight, barWidth, gap |
ScreenShareBanner |
Slide-in banner for screen sharing status | dimensions |
ConnectionStateBanner |
Persistent "Connecting…"/"Reconnecting…" status banner | dimensions |
MoreBottomSheet |
Bottom sheet with handle bar, encryption label, and cancel button | dimensions |
ParticipantsPanel |
Draggable scrollable participant list with host actions | dimensions |
SignalStrengthIcon |
4-bar signal strength indicator | size |
VideoSurface |
Opaque mounting surface for externally-provided video widgets | fills its parent |
Exported Utilities
| Utility | Description |
|---|---|
GroupCallLayoutResolver |
Resolves grid layout mode based on participant count |
PipSnapCalculator |
Resolves the PiP's anchor corner (PipCorner) and the offset for it |
Requirements
- Flutter SDK >= 3.22.0
- Dart SDK ^3.4.0
License
See LICENSE file.
Libraries
- call_ui_kit
- A universal call UI kit for Flutter.