baxcloud_calls_uikit_sdk 0.1.1
baxcloud_calls_uikit_sdk: ^0.1.1 copied to clipboard
BaxCloud Calls UI Kit — 1:1 and group audio/video calling with native CallKit and BYO push.
example/lib/main.dart
import 'dart:async';
import 'package:baxcloud_calls_uikit_sdk/baxcloud_calls_uikit_sdk.dart';
import 'package:baxcloud_core/baxcloud_core.dart';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'call_permissions.dart';
/// Stable demo avatar from the internet (pravatar) keyed by user id.
String _avatarUrlFor(String userId) {
final seed = Uri.encodeComponent(userId.isEmpty ? 'bax' : userId);
return 'https://i.pravatar.cc/300?u=$seed';
}
/// Two-device test flow (no FCM required while app is open):
/// 1. Device A → Save profile as `user1`
/// 2. Device B → Save profile as `user2`, peer = `user1`
/// 3. Device A → peer = `user2`, Start call → media UI opens while ringing
/// 4. Device B → incoming banner → Accept → media UI opens
void main() async {
WidgetsFlutterBinding.ensureInitialized();
const projectId = String.fromEnvironment('BAXCLOUD_PROJECT_ID');
const apiKey = String.fromEnvironment('BAXCLOUD_API_KEY');
final prefs = await SharedPreferences.getInstance();
final savedUserId = prefs.getString('bax_example_user_id') ??
const String.fromEnvironment('BAXCLOUD_USER_ID', defaultValue: 'user1');
final savedUserName = prefs.getString('bax_example_user_name') ??
const String.fromEnvironment('BAXCLOUD_USER_NAME', defaultValue: 'User 1');
final savedPeerId = prefs.getString('bax_example_peer_id') ?? 'user2';
final config = BaxConfig(projectId: projectId, apiKey: apiKey);
if (config.isValid) {
await BaxCalls.initialize(
config: config,
localUser: BaxcloudUser(
userId: savedUserId,
name: savedUserName,
avatarUrl: _avatarUrlFor(savedUserId),
),
theme: const BaxCallTheme(appName: 'BaxCloud Calls'),
);
BaxCalls.setIncomingPollEnabled(true);
}
runApp(CallsExampleApp(
config: config,
initialUserId: savedUserId,
initialUserName: savedUserName,
initialPeerId: savedPeerId,
));
}
class CallsExampleApp extends StatelessWidget {
final BaxConfig config;
final String initialUserId;
final String initialUserName;
final String initialPeerId;
const CallsExampleApp({
super.key,
required this.config,
required this.initialUserId,
required this.initialUserName,
required this.initialPeerId,
});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'BaxCloud Calls',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF0B1220)),
useMaterial3: true,
),
home: BaxCallScope(
config: config,
child: CallsHomePage(
config: config,
initialUserId: initialUserId,
initialUserName: initialUserName,
initialPeerId: initialPeerId,
),
),
);
}
}
class CallsHomePage extends StatefulWidget {
final BaxConfig config;
final String initialUserId;
final String initialUserName;
final String initialPeerId;
const CallsHomePage({
super.key,
required this.config,
required this.initialUserId,
required this.initialUserName,
required this.initialPeerId,
});
@override
State<CallsHomePage> createState() => _CallsHomePageState();
}
class _CallsHomePageState extends State<CallsHomePage> {
late final TextEditingController _userIdController;
late final TextEditingController _userNameController;
late final TextEditingController _peerController;
final _groupController = TextEditingController(text: 'user2,user3');
StreamSubscription<BaxCallEvent>? _sub;
String _status = 'Idle';
BaxIncomingCall? _activeIncoming;
BaxCallInvitation? _activeOutgoing;
bool _inCall = false;
bool _outgoingRinging = false;
/// True only when this device placed the call (not when accepting an invite).
bool _isCaller = false;
BaxCallCapabilities _caps = BaxCallCapabilities.limited;
bool _saving = false;
bool _callBusy = false;
/// Demo: replace kit chrome with custom buttons driven by [BaxCallController].
bool _useCustomControls = true;
String? _lastError;
String get _localUserId => _userIdController.text.trim();
String get _localUserName => _userNameController.text.trim();
@override
void initState() {
super.initState();
_userIdController = TextEditingController(text: widget.initialUserId);
_userNameController = TextEditingController(text: widget.initialUserName);
_peerController = TextEditingController(text: widget.initialPeerId);
_caps = BaxCalls.instance.capabilities;
_sub = BaxCalls.instance.events.listen((event) {
setState(() {
_status = event.type.name;
switch (event.type) {
case BaxCallEventType.incoming:
case BaxCallEventType.ringing:
// Native CallKit owns the ring UI — only track state for media after accept.
if (event.incoming != null) {
_activeIncoming = event.incoming;
// Pre-request mic/camera so Accept can join media immediately.
unawaited(
CallPermissions.ensureForCall(
context,
callType: event.incoming!.callType,
),
);
}
case BaxCallEventType.outgoing:
_isCaller = true;
_activeOutgoing = event.invitation ?? _activeOutgoing;
_outgoingRinging = true;
_inCall = true;
case BaxCallEventType.accepted:
case BaxCallEventType.connected:
_inCall = true;
_outgoingRinging = false;
if (event.incoming != null) {
_activeIncoming = event.incoming;
}
// Keep invitation for room metadata; host role stays on _isCaller.
if (event.invitation != null) {
_activeOutgoing = event.invitation;
}
case BaxCallEventType.declined:
case BaxCallEventType.cancelled:
case BaxCallEventType.ended:
case BaxCallEventType.missed:
case BaxCallEventType.timeout:
// Ignore echoes while connecting / in an accepted session suppress window.
if (BaxCalls.instance.isSuppressingNativeHangup) {
break;
}
_inCall = false;
_outgoingRinging = false;
_isCaller = false;
_activeIncoming = null;
_activeOutgoing = null;
case BaxCallEventType.muteChanged:
case BaxCallEventType.cameraChanged:
case BaxCallEventType.holdChanged:
break;
case BaxCallEventType.error:
_lastError = event.error?.toString() ?? 'Unknown error';
_inCall = false;
_outgoingRinging = false;
_isCaller = false;
_activeIncoming = null;
_activeOutgoing = null;
}
});
});
}
@override
void dispose() {
_sub?.cancel();
_userIdController.dispose();
_userNameController.dispose();
_peerController.dispose();
_groupController.dispose();
super.dispose();
}
Future<void> _saveProfile() async {
final userId = _localUserId;
final userName = _localUserName.isEmpty ? userId : _localUserName;
final peerId = _peerController.text.trim();
if (userId.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('User id is required')),
);
return;
}
setState(() => _saving = true);
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('bax_example_user_id', userId);
await prefs.setString('bax_example_user_name', userName);
if (peerId.isNotEmpty) {
await prefs.setString('bax_example_peer_id', peerId);
}
if (widget.config.isValid) {
await BaxCalls.initialize(
config: widget.config,
localUser: BaxcloudUser(
userId: userId,
name: userName,
avatarUrl: _avatarUrlFor(userId),
),
theme: const BaxCallTheme(appName: 'BaxCloud Calls'),
);
BaxCalls.setIncomingPollEnabled(true);
final caps = await BaxCalls.refreshCapabilities();
if (mounted) setState(() => _caps = caps);
}
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Saved as $userName ($userId)')),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('$e')));
} finally {
if (mounted) setState(() => _saving = false);
}
}
Future<void> _refreshCaps() async {
try {
final caps = await BaxCalls.refreshCapabilities();
setState(() => _caps = caps);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('$e')));
}
}
Future<void> _startOutgoing(BaxCallType type, {bool group = false}) async {
if (!widget.config.isValid) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Build with BAXCLOUD_PROJECT_ID and BAXCLOUD_API_KEY')),
);
return;
}
if (_callBusy) return;
final ok = await CallPermissions.ensureForCall(context, callType: type);
if (!ok || !mounted) return;
setState(() {
_callBusy = true;
_lastError = null;
});
try {
final BaxCallInvitation inv;
if (group) {
final ids = _groupController.text
.split(',')
.map((e) => e.trim())
.where((e) => e.isNotEmpty)
.toList();
inv = await BaxCalls.startOutgoing(
toUserIds: ids,
callType: type,
displayName: 'Group',
avatarUrl: _avatarUrlFor(_localUserId),
);
} else {
final peer = _peerController.text.trim();
if (peer.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Set a peer user id and tap Save profile')),
);
return;
}
if (peer == _localUserId) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Peer must be a different user id')),
);
return;
}
inv = await BaxCalls.startOutgoing(
toUserId: peer,
callType: type,
displayName: peer,
avatarUrl: _avatarUrlFor(_localUserId),
);
}
setState(() {
_activeOutgoing = inv;
_inCall = true;
_outgoingRinging = true;
if (inv.capabilities != null) _caps = inv.capabilities!;
});
} catch (e) {
if (!mounted) return;
setState(() => _lastError = e.toString());
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('$e')),
);
} finally {
if (mounted) setState(() => _callBusy = false);
}
}
Widget _buildInCall() {
final activeIncoming =
_activeIncoming ?? BaxCalls.instance.activeIncoming;
final activeInvitation =
_activeOutgoing ?? BaxCalls.instance.activeInvitation;
final room = activeInvitation?.roomName ??
activeIncoming?.roomName;
final type = activeInvitation?.callType ??
activeIncoming?.callType ??
BaxCallType.video;
final isGroup = activeInvitation?.isGroup == true ||
activeIncoming?.isGroup == true ||
BaxCalls.instance.isGroupCall;
if (room == null) {
return const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Connecting…', style: TextStyle(color: Colors.white70)),
],
),
);
}
final uiConfig = isGroup
? BaxCallUiConfig.groupGrid()
: type == BaxCallType.audio
? BaxCallUiConfig.audioOnly()
: BaxCallUiConfig.faceTime();
return Stack(
children: [
BaxCallView(
roomName: room,
user: BaxcloudUser(
userId: _localUserId,
name: _localUserName,
avatarUrl: BaxCalls.instance.localUser?.avatarUrl ??
_avatarUrlFor(_localUserId),
),
callType: type,
isGroup: isGroup,
isHost: _isCaller,
canJoinWithNoHost: true,
uiConfig: uiConfig,
// Bring-your-own chrome: kit media + app-owned controls via controller.
inCallBuilder: _useCustomControls
? (context, session) {
// Media is already full-bleed (video behind toolbar) + name chrome.
return Stack(
fit: StackFit.expand,
children: [
Positioned.fill(child: session.media),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: _ExampleCustomControls(
controller: session.controller,
isVideo: type == BaxCallType.video,
),
),
],
);
}
: null,
),
if (_outgoingRinging)
Positioned(
top: MediaQuery.of(context).padding.top + 12,
left: 16,
right: 16,
child: Material(
color: Colors.black87,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
const Icon(Icons.ring_volume, color: Colors.white70),
const SizedBox(width: 12),
Expanded(
child: Text(
'Ringing ${_peerController.text.trim()}…',
style: const TextStyle(color: Colors.white),
),
),
TextButton(
onPressed: () => BaxCalls.cancel(),
child: const Text(
'Cancel',
style: TextStyle(color: Colors.redAccent),
),
),
],
),
),
),
),
],
);
}
@override
Widget build(BuildContext context) {
final missingCreds = !widget.config.isValid;
if (_inCall) {
return Scaffold(
backgroundColor: const Color(0xFF0A0A0A),
body: _buildInCall(),
);
}
return Scaffold(
appBar: AppBar(
title: const Text('BaxCloud Calls'),
actions: [
IconButton(
onPressed: _refreshCaps,
icon: const Icon(Icons.refresh),
tooltip: 'Refresh push capabilities',
),
],
),
body: ListView(
padding: const EdgeInsets.all(24),
children: [
if (missingCreds)
const Card(
color: Color(0xFFFFF3CD),
child: Padding(
padding: EdgeInsets.all(12),
child: Text(
'Rebuild with --dart-define=BAXCLOUD_PROJECT_ID=… and '
'BAXCLOUD_API_KEY=bax_pk_…',
),
),
),
Card(
color: const Color(0xFFE8F4FD),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: const [
Text(
'Two-device test (app open, no push needed)',
style: TextStyle(fontWeight: FontWeight.w600),
),
SizedBox(height: 8),
Text('• Device A: user id = user1, peer = user2 → Save'),
Text('• Device B: user id = user2, peer = user1 → Save'),
Text('• Allow mic (and camera for video) when prompted'),
Text('• Device A: Start call → media opens while ringing'),
Text('• Device B: Accept on the native CallKit / system UI'),
Text('• Either side hang up → media closes for both'),
Text(
'• Toggle “Custom controls” to demo BaxCallController BYO UI',
),
],
),
),
),
const SizedBox(height: 12),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Custom controls (BYO)'),
subtitle: const Text(
'inCallBuilder + session.media + BaxCallController',
),
value: _useCustomControls,
onChanged: (v) => setState(() => _useCustomControls = v),
),
if (_lastError != null) ...[
const SizedBox(height: 12),
Card(
color: const Color(0xFFFFEBEE),
child: ListTile(
leading: const Icon(Icons.error_outline, color: Colors.red),
title: Text(_lastError!, style: const TextStyle(fontSize: 13)),
trailing: IconButton(
icon: const Icon(Icons.close),
onPressed: () => setState(() => _lastError = null),
),
),
),
],
const SizedBox(height: 12),
Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Your profile',
style: TextStyle(fontWeight: FontWeight.w600),
),
const SizedBox(height: 12),
TextField(
controller: _userIdController,
decoration: const InputDecoration(
labelText: 'Your user id',
hintText: 'user1',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
controller: _userNameController,
decoration: const InputDecoration(
labelText: 'Display name',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
controller: _peerController,
decoration: const InputDecoration(
labelText: 'Peer user id to call',
hintText: 'user2',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: _saving ? null : _saveProfile,
icon: _saving
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.save),
label: const Text('Save profile'),
),
],
),
),
),
const SizedBox(height: 12),
Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Server push capabilities',
style: TextStyle(fontWeight: FontWeight.w600),
),
const SizedBox(height: 8),
Text('FCM: ${_caps.fcmConfigured ? "configured" : "not set"}'),
Text('APNs VoIP: ${_caps.apnsVoipConfigured ? "configured" : "not set"}'),
Text('Incoming poll: active (foreground, no push)'),
Text('Status: $_status'),
],
),
),
),
const SizedBox(height: 16),
TextField(
controller: _groupController,
decoration: const InputDecoration(
labelText: 'Group peer ids (comma-separated)',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: _callBusy ? null : () => _startOutgoing(BaxCallType.video),
icon: _callBusy
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.videocam),
label: const Text('Start 1:1 video'),
),
const SizedBox(height: 8),
FilledButton.icon(
onPressed: _callBusy ? null : () => _startOutgoing(BaxCallType.audio),
icon: const Icon(Icons.call),
label: const Text('Start 1:1 audio'),
),
const SizedBox(height: 8),
FilledButton.tonalIcon(
onPressed: _callBusy ? null : () => _startOutgoing(BaxCallType.video, group: true),
icon: const Icon(Icons.groups),
label: const Text('Start group video'),
),
const SizedBox(height: 8),
OutlinedButton(
onPressed: () => BaxCalls.end(),
child: const Text('End / cancel active call'),
),
],
),
);
}
}
/// Minimal custom control bar — shows how apps wire [BaxCallController].
class _ExampleCustomControls extends StatelessWidget {
final BaxCallController controller;
final bool isVideo;
const _ExampleCustomControls({
required this.controller,
required this.isVideo,
});
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: controller,
builder: (context, _) {
final muted = controller.isMuted;
final camOn = controller.isCameraEnabled;
final speakerOn = controller.isSpeakerOn;
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
child: DecoratedBox(
decoration: BoxDecoration(
// Semi-transparent so video remains visible behind the bar.
color: const Color(0x99111827),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: Colors.white24),
),
child: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_CtrlBtn(
icon: muted ? Icons.mic_off : Icons.mic,
label: muted ? 'Unmute' : 'Mute',
onTap: () => controller.toggleMute(),
),
if (isVideo)
_CtrlBtn(
icon: camOn ? Icons.videocam : Icons.videocam_off,
label: camOn ? 'Cam' : 'Cam off',
onTap: () => controller.toggleCamera(),
),
if (isVideo && camOn)
_CtrlBtn(
icon: Icons.cameraswitch,
label: 'Flip',
onTap: () => controller.flipCamera(),
),
_CtrlBtn(
icon: speakerOn ? Icons.volume_up : Icons.volume_off,
label: 'Speaker',
onTap: () => controller.toggleSpeaker(),
),
_CtrlBtn(
icon: Icons.call_end,
label: 'End',
background: const Color(0xFFE11D48),
onTap: () => controller.hangUp(),
),
],
),
),
),
),
);
},
);
}
}
class _CtrlBtn extends StatelessWidget {
final IconData icon;
final String label;
final VoidCallback onTap;
final Color? background;
const _CtrlBtn({
required this.icon,
required this.label,
required this.onTap,
this.background,
});
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Material(
color: background ?? const Color(0xFF334155),
shape: const CircleBorder(),
child: InkWell(
customBorder: const CircleBorder(),
onTap: onTap,
child: SizedBox(
width: 52,
height: 52,
child: Icon(icon, color: Colors.white),
),
),
),
const SizedBox(height: 6),
Text(
label,
style: const TextStyle(color: Colors.white70, fontSize: 11),
),
],
);
}
}