baxcloud_calls_uikit_sdk 0.1.1 copy "baxcloud_calls_uikit_sdk: ^0.1.1" to clipboard
baxcloud_calls_uikit_sdk: ^0.1.1 copied to clipboard

PlatformAndroid

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),
        ),
      ],
    );
  }
}
0
likes
160
points
111
downloads

Documentation

Documentation
API reference

Publisher

unverified uploader

Weekly Downloads

BaxCloud Calls UI Kit — 1:1 and group audio/video calling with native CallKit and BYO push.

Homepage

Topics

#baxcloud #calls #callkit #video #flutter

License

MIT (license)

Dependencies

baxcloud_core, flutter, flutter_callkit_incoming, livekit_client, livekit_components, provider, shared_preferences, uuid

More

Packages that depend on baxcloud_calls_uikit_sdk