
Cometchat Flutter V6 Calls
- 5 installs
- 70 repo stars
- Updated June 23, 2026
- cometchat/cometchat-skills
Add voice and video calling to a CometChat Flutter UIKit v6 app using the Bloc-based bundled call widgets, CallKit, and VoIP push.
About
Covers production-grade voice and video calling for CometChat Flutter UIKit v6 in standalone and additive modes. A developer uses it to wire call widgets, CallKit/ConnectionService, and VoIP push into a v6 Flutter app.
- Requires MaterialApp navigatorKey wired to CallNavigationContext.navigatorKey
- CometChatUIKitCalls.init() must run after chat init succeeds
Cometchat Flutter V6 Calls by the numbers
- 5 all-time installs (skills.sh)
- Ranked #828 of 1,039 Mobile Development skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/cometchat/cometchat-skills --skill cometchat-flutter-v6-callsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 5 |
|---|---|
| repo stars | ★ 70 |
| Last updated | June 23, 2026 |
| Repository | cometchat/cometchat-skills ↗ |
What it does
Add voice and video calling to a CometChat Flutter UIKit v6 app using the Bloc-based bundled call widgets, CallKit, and VoIP push.
Files
Purpose
Production-grade voice + video calling for Flutter UIKit v6 (stable, Bloc-based). Loaded by cometchat-calls when framework === "flutter" and flutter_version === "v6". Operates in two modes:
- Standalone — calls is the product. Chat SDK + Calls SDK without the v6 UI Kit (rare today, since the UI Kit ships calling bundled). Custom call screens on the SDKs.
- Additive — calls layered onto an existing v6 chat integration. The v6 UI Kit ships call widgets in the same package (
cometchat_chat_uikit) — no extra dependency. The skill enables calling onUIKitSettings, callsCometChatUIKitCalls.init()in the chat-init success callback, mounts the global incoming-call overlay at app root.
Read these other skills first:
cometchat-calls— dispatcher (modes, hard rules, anti-patterns)cometchat-flutter-v6-core— UIKitSettings, init/login order (CHAT_INIT_BEFORE_CALLS_INIT is THE rule)cometchat-flutter-v6-events— Bloc event streams + listener registration
V6 vs V5 difference (critical for migration):
- V6 — single
cometchat_chat_uikitpackage (calls bundled in) - V5 —
cometchat_chat_uikit+cometchat_calls_uikit(separate) - V6 uses Bloc; V5 uses GetX
- V6
CometChatUIKitCalls.init(appId, region)must run AFTERCometChatUIKit.init()succeeds; V5 hides this viaCometChatCallingExtension
Ground truth:
- SDK source — installed
cometchat_chat_uikit@6.0.1artifacts under~/.pub-cache/ - Sample app —
~/Downloads/calls-sdk/calls-sdk-flutter-5/sample-apps/(V5 sample; V6 sample app may not exist yet — verify before citing) - Public docs — https://www.cometchat.com/docs/calls/flutter/overview (note: V6 docs may still reference V5 module split)
---
1. The seven hard rules — Flutter v6 specialization
1.0 Calls SDK login is its own step (v5+)
Same as v5 cohort — the v5 Calls SDK has its own auth state, separate from the Chat SDK. After CometChat.login succeeds, you MUST also call CometChatCalls.login — without it, the FIRST calls API call throws "auth token cannot be null".
V6's CometChatUIKitCalls.init() initializes the SDK but does not handle login. Login still needs to happen explicitly after the user signs in via CometChatUIKit.login:
import 'package:cometchat_sdk/cometchat_sdk.dart';
import 'package:cometchat_calls_sdk/cometchat_calls_sdk.dart';
// After CometChatUIKit.login resolves
CometChatCalls.login(
uid: uid,
authKey: AUTH_KEY,
onSuccess: (User? callUser) { /* both ready */ },
onError: (CometChatException e) { /* surface to user */ },
);
// Production:
CometChatCalls.loginWithAuthToken(
authToken: tokenFromBackend,
onSuccess: (User? user) { /* … */ },
onError: (CometChatException e) { /* … */ },
);Surprises:
- Chat SDK persists login via shared preferences. The Calls SDK does NOT — always check
CometChatCalls.getLoggedInUser()on app start and re-login if it returns null. - Even though V6 bundles the call UI into
CometChatUIKit, the underlying Calls SDK login is still mandatory and separate.
1.1 Dual-SDK contract — CometChatUIKitCalls.init after CometChatUIKit.init
The V6 UI Kit unifies the two SDKs but init order is still load-bearing:
// ✓ RIGHT — calls init in chat init's onSuccess
CometChatUIKit.init(
uiKitSettings: settings,
onSuccess: (_) {
CometChatUIKitCalls.init(appId, region,
onSuccess: (_) => debugPrint('Calls SDK ready'),
onError: (e) => debugPrint('Calls init failed: ${e.message}'),
);
},
);// ✗ WRONG — parallel init causes "auth token null" intermittently
CometChatUIKit.init(uiKitSettings: settings); // returns immediately
CometChatUIKitCalls.init(appId, region); // race — chat auth not ready yetCometChatUIKitCalls.init() must be called exactly once per app lifecycle. Re-calling it after logout + re-login causes "session already started" errors.
After logout: the calls SDK session is invalidated; on next login, call CometChatUIKitCalls.init() again. Treat this as a "first run" of the calls subsystem.
1.2 VoIP push — native_call_kit module + FCM + PushKit bridge
V6 ships a native_call_kit sub-module inside cometchat_chat_uikit that wraps CallKit (iOS) and ConnectionService (Android). The skill still wires:
- `firebase_messaging` — FCM data messages on Android
- iOS PushKit — same platform-channel bridge as v5 (Flutter has no first-party PushKit plugin)
- `native_call_kit` API — Dart-side handlers for incoming-call payloads → OS-level ring UI
In additive mode, opt-in. In standalone, mandatory.
1.3 Foreground service — same Android 14+ rules
⚠️ Android build prerequisite — Jetifier is mandatory. One of the cometchat_calls_uikit transitive deps still pulls in the legacy com.android.support:support-compat:26.1.0 AAR in V6. Without Jetifier, AGP fails with Duplicate class android.support.v4.* errors. Set in android/gradle.properties:
android.useAndroidX=true
android.enableJetifier=trueFlutter 3.x scaffolds omit enableJetifier=true by default — the build fails on first flutter build apk if you skip this.
Same as native Android / Flutter v5. The four FOREGROUND_SERVICE_* permissions plus MANAGE_OWN_CALLS / BIND_TELECOM_CONNECTION_SERVICE in android/app/src/main/AndroidManifest.xml. V6 raised Android minSdk to 26 (calls SDK in V6 raised the floor) — verify this is set in android/app/build.gradle.
1.4 Server-minted auth tokens
cometchat-flutter-v6-production covers it. CometChatUIKit.loginWithAuthToken(token) for production, never loginWithAuthKey(uid, authKey).
1.5 Hangup cleanup — Calls + ServiceLocator + native call UI
Future<void> endCall(String sessionId) async {
await CometChatUIKitCalls.endSession(); // 1. end WebRTC + release tracks
await FlutterCallkitIncoming.endAllCalls(); // 2. clear OS-level ring UI
if (mounted) Navigator.of(context, rootNavigator: true).pop(); // 3. pop call screen
}After CometChatUIKit.logout(), also reset the calls service locator:
CallOperationsServiceLocator.instance.reset();The kit's call widgets handle the basic teardown automatically; custom WebRTC surfaces must replicate this.
1.6 Permissions — permission_handler + CallPermissions
V6 ships an internal CallPermissions helper that wraps the standard permission flow:
final granted = await CallPermissions.requestCallPermissions(callType: CallType.video);
if (!granted) { /* surface a clear UI message */ }Native config (Info.plist + AndroidManifest) is identical to V5 (rule 1.6 in cometchat-flutter-v5-calls).
1.7 IncomingCall mounted at app root
V6 exposes the incoming-call overlay via CometChatDisplayIncomingCallOverlay mounted in MaterialApp's builder:
MaterialApp(
navigatorKey: CallNavigationContext.navigatorKey, // ⚠️ REQUIRED — see warning below
builder: (context, child) {
return Stack(
children: [
child!,
const CometChatDisplayIncomingCallOverlay(),
],
);
},
);⚠️ `navigatorKey: CallNavigationContext.navigatorKey` is REQUIRED on MaterialApp — still active in v6.0.1 GA. The kit's CometChatCallButtons and outgoing-call flow navigate via CallNavigationContext.navigatorKey.currentContext. Without this line, CometChat.initiateCall succeeds (CALL-TRAP confirms onSuccess fires with a valid sessionId) but currentContext is null so CometChatOutgoingCall never mounts. Symptom: user taps call button, peer rings, but the Flutter app shows nothing. Note: the vendor's own 6.0.1 sample app (`examples/sample_app`) is missing this line and is broken out-of-the-box — its user_info_screen.dart reads CallNavigationContext.navigatorKey.currentContext but main.dart's MaterialApp never sets the key. Don't copy the vendor sample's main.dart verbatim; add the navigatorKey.
Import: import 'package:cometchat_chat_uikit/cometchat_calls_uikit.dart' show CallNavigationContext; (use show to avoid a name collision with kit-exported IncomingCallOverlay).
✅ Outgoing → in-call screen transition is FIXED in v6.0.1 GA. (It was broken in v6.0.0-beta2 — the outgoing-call screen stayed on "Calling…" indefinitely after the peer accepted.) Validated end-to-end 2026-05-27 on Pixel 3 with full CALL-TRAP instrumentation: with the navigatorKey wired (above), when the peer accepts, the kit's OutgoingCallBloc fires pushReplacement and the screen transitions automatically from CometChatOutgoingCall ("Calling…") to the in-call surface — observed call-duration timer ticking + WebRTC rendering frames @ ~27 fps. The v6.0.0 changelog's "Refreshed BLoC implementations across … call buttons, and ongoing call flows" was the fix. No client-side workaround needed beyond the navigatorKey wiring.
In standalone mode, native_call_kit owns the OS-level ring UI; the in-app overlay only fires when the app is foregrounded.
---
2. Setup
# pubspec.yaml
dependencies:
flutter:
sdk: flutter
cometchat_chat_uikit: ^6.0 # calls bundled in
permission_handler: ^11.0.0
flutter_callkit_incoming: ^2.0.0 # standalone — VoIP UI bridge
firebase_messaging: ^14.0.0 # standalone — Android FCM
firebase_core: ^2.0.0If pub.dev resolution lags, use the Cloudsmith hosted pin:
cometchat_chat_uikit:
hosted: https://dart.cloudsmith.io/cometchat/cometchat/
version: ^6.0.1Init (additive mode):
import 'package:cometchat_chat_uikit/cometchat_chat_uikit.dart';
import 'package:cometchat_chat_uikit/cometchat_calls_uikit.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
final settings = (UIKitSettings.builder()
..appId = CometChatConfig.appId
..region = CometChatConfig.region
..authKey = CometChatConfig.authKey
..subscriptionType = CometChatSubscriptionType.allUsers)
.build();
CometChatUIKit.init(
uiKitSettings: settings,
onSuccess: (_) {
CometChatUIKitCalls.init(
CometChatConfig.appId,
CometChatConfig.region,
onSuccess: (_) => runApp(const MyApp()),
onError: (e) => runApp(MyErrorApp(e.message)),
);
},
onError: (e) => runApp(MyErrorApp(e.message)),
);
}The first import gives you chat components. The second gives you call-specific types.
---
3. Components catalog (V6 widgets — Bloc-driven)
Architecture (lives inside cometchat_chat_uikit):
call_ui/src/
├── call_buttons/ # CometChatCallButtons + CallButtonsBloc
├── incoming_call/ # CometChatIncomingCall + IncomingCallBloc
├── outgoing_call/ # CometChatOutgoingCall + OutgoingCallBloc
├── ongoing_call/ # CometChatOngoingCall + OngoingCallBloc
├── call_logs/ # CometChatCallLogs + CallLogsBloc + Clean Architecture
├── call_operations/ # Shared DI / use cases / repositories (CallOperationsServiceLocator)
├── call_bubble/ # CometChatCallBubble — call-event bubble in message list (auto-rendered)
├── call_settings/ # CometChatUIKitCalls, CallNavigationContext
├── native_call_kit/ # iOS CallKit / Android ConnectionService bridge
├── utils/ # CallUtils, CallStateService, CallPermissions
├── call_event_service.dart # Centralized call event handling
└── calling_configuration.dart # Top-level config objectComponent reference
| Widget | Purpose | Notes |
|---|---|---|
CometChatCallButtons(user:, group:) | Voice + video buttons | Mutually exclusive user / group. Use group: for group calls (meetings). |
CometChatIncomingCall(call:, user:, onAccept:, onDecline:) | In-app foreground ring UI | onAccept/onDecline are (BuildContext, Call) -> void. Custom view slots: titleView, subTitleView, leadingView, trailingView. |
CometChatOutgoingCall(call:, user:, outgoingCallStyle:) | Dialing UI | Auto-mounted when initiateCall is called via the kit. |
CometChatOngoingCall(callSettingsBuilder:, sessionId:, callWorkFlow:) | Active call view | callWorkFlow: CallWorkFlow.directCalling or CallWorkFlow.defaultCalling. Set resizeToAvoidBottomInset: false on host Scaffold. |
CometChatCallLogs(onItemClick:, callLogsStyle:) | Paginated history | Clean Architecture + BLoC; CallLogsServiceLocator is initialized automatically when the widget mounts. |
CometChatCallBubble | Call-event message bubble | Auto-rendered for call-type messages in CometChatMessageList. |
CometChatUIKitCalls API
| Method | Purpose |
|---|---|
CometChatUIKitCalls.init(appId, region) | Initialize calls SDK (after chat init — rule 1.1) |
CometChatUIKitCalls.initiateCall(call) | Start a call (Chat SDK initiate + Calls SDK preflight) |
CometChatUIKitCalls.acceptCall(sessionId) | Accept incoming |
CometChatUIKitCalls.rejectCall(sessionId, status) | Reject / cancel |
CometChatUIKitCalls.generateToken(sessionId) | Mint session-scoped RTC token |
CometChatUIKitCalls.startSession(sessionId, settings) | Start WebRTC |
CometChatUIKitCalls.endSession() | End and cleanup |
CallingConfiguration — top-level config
CallingConfiguration(
outgoingCallConfiguration: CometChatOutgoingCallConfiguration(...),
incomingCallConfiguration: CometChatIncomingCallConfiguration(...),
callButtonsConfiguration: CallButtonsConfiguration(...),
groupSessionSettingsBuilder: sessionSettingsBuilder,
)Pass to UIKitSettings.callingConfiguration to apply globally without per-component plumbing.
Bloc events (when interacting directly — UIKit widgets handle these for you)
| Bloc | Events |
|---|---|
CallButtonsBloc | InitiateVoiceCall, InitiateVideoCall |
IncomingCallBloc | AcceptCall, RejectCall |
OutgoingCallBloc | CancelCall, CallAccepted(call), CallRejected(call) |
OngoingCallBloc | StartSession(sessionId, settings), EndSession |
CallLogsBloc | LoadCallLogs, LoadMoreCallLogs |
CallOperationsServiceLocator must be initialized before using call BLoCs directly — UIKit widgets handle this automatically. After logout, call CallOperationsServiceLocator.instance.reset() to clean up.
---
4. Standalone integration
When product === "voice-video" and there is no v6 chat integration.
Split by calling mode:
4a. Standalone — Session mode (meeting-room UX, no ringing)
Calls SDK ONLY. NO Chat SDK, NO UIKit. Same SDK as v5 (cometchat_calls_sdk). Scaffold:
1. `pubspec.yaml` — cometchat_calls_sdk: ^5.0.0 + flutter_bloc + equatable + permission_handler ONLY. 2. `lib/main.dart` — CometChatCalls.init(appId, region, authKey). Permission requests. NO Chat SDK init. 3. `lib/cubits/call_session_cubit.dart` — Cubit implements SessionStatusListeners. CometChatCalls.joinSession(sessionId:, sessionSettings: SessionSettingsBuilder().build(), onSuccess:, onError:). Renders the returned Widget? via SizedBox.expand. See references/call-session.md. 4. `lib/screens/call_room.dart` — BlocConsumer for auto-pop on idle transition. 5. Native config — Camera + microphone permissions only.
Why no Chat SDK / no UIKit: session mode never touches a Chat SDK call entity. No ringing, no UIKit incoming-call overlay needed.
4b. Standalone — Ringing mode (kit overlay + CallKit + FCM)
Dual-SDK + UIKit. Scaffold:
1. `lib/main.dart` — Chat SDK + Calls SDK init (no UIKit), permission requests, flutter_callkit_incoming + Firebase setup. 2. `lib/services/voip_service.dart` — FCM + PushKit + native_call_kit bridge. 3. `lib/widgets/call_button.dart` — Voice + video buttons next to a contact. 4. `lib/screens/ongoing_call_screen.dart` — CometChatCalls.joinSession(sessionId:, sessionSettings:, onSuccess:, onError:) with Widget rendered via SizedBox.expand. Cubit-driven state. 5. `lib/screens/call_logs_screen.dart` — /calls route. 6. `MaterialApp.builder` — CometChatDisplayIncomingCallOverlay for foreground rings (rule 1.7). 7. Native config — same as V5 standalone (Info.plist + AndroidManifest + Firebase config files).
5. Additive integration
When chat is already integrated. The skill:
1. Confirms cometchat_chat_uikit is on ^6.0.1 — calls are already bundled. 2. Patches the CometChatUIKit.init call to add CometChatUIKitCalls.init in the success callback (rule 1.1). 3. Adds CometChatDisplayIncomingCallOverlay to MaterialApp.builder (rule 1.7). 4. Confirms CometChatMessageHeader shows call buttons by default (hideVoiceCallButton: false, hideVideoCallButton: false). 5. Optionally adds CometChatCallLogs as a tab/screen. 6. VoIP push: opt-in.
6. Anti-patterns
1. Init Calls SDK before Chat SDK. Auth-token race; CometChatUIKitCalls.init must run inside CometChatUIKit.init's onSuccess. Rule 1.1. 2. Calling `CometChatUIKitCalls.init` more than once per app lifecycle. Causes "session already started". After logout-relogin, this is the first run again — but in a single session, do it exactly once. 3. Per-screen incoming-call handling. CometChatIncomingCall(call: c, user: u) mounted on the messages screen only fires there. Mount the overlay at app root via MaterialApp.builder (rule 1.7). 4. Forgetting `resizeToAvoidBottomInset: false` on Scaffolds with call UI — keyboard show breaks WebRTC layout. 5. Group calls with `user:` instead of `group:` on CometChatCallButtons. Group calls require group:; the widget silently mismatches if both are passed. 6. Caching the theme in `build()` instead of didChangeDependencies(). Listed in V6 components catalog as a perf rule but applies to every call surface. 7. Skipping `CallOperationsServiceLocator.instance.reset()` after logout. Stale singletons leak across user sessions; calls subsystem behaves erratically on next login. 8. Mixing V5 and V6 widget imports. V5's cometchat_calls_uikit namespace and V6's cometchat_chat_uikit/cometchat_calls_uikit.dart barrel re-export different classes with the same name. Pick a cohort and stay there.
7. Verification checklist
Static:
- [ ]
cometchat_chat_uikit ^6.0.1in pubspec.yaml (V6 bundles calls — no separate calls package) - [ ]
CometChatUIKitCalls.initcalled insideCometChatUIKit.init'sonSuccess(rule 1.1) - [ ]
CometChatUIKitCalls.initcalled exactly once per app lifecycle - [ ]
CometChatDisplayIncomingCallOverlaymounted inMaterialApp.builder(rule 1.7) - [ ] iOS
Info.plist: NSCameraUsageDescription + NSMicrophoneUsageDescription + UIBackgroundModes (audio + voip + remote-notification) - [ ] Android
minSdk = 26, ProGuard rules-keep class com.cometchat.** { *; } - [ ] Android manifest: four FOREGROUND_SERVICE_* permissions + MANAGE_OWN_CALLS + BIND_TELECOM_CONNECTION_SERVICE
- [ ] Hangup path:
endSession+FlutterCallkitIncoming.endAllCalls+ Navigator pop - [ ] After logout:
CallOperationsServiceLocator.instance.reset() - [ ]
resizeToAvoidBottomInset: falseon call-screen Scaffolds - [ ] Theme cached in
didChangeDependencies(), notbuild() - [ ] Standalone only:
flutter_callkit_incoming+firebase_messaging+ iOS PushKit platform-channel bridge
Runtime (real devices, both platforms):
- [ ] iOS — terminated app, lock-screen rings on incoming call (CallKit)
- [ ] Android — terminated app, heads-up notification rings (ConnectionService)
- [ ] Both — outgoing call connects, two-way audio + video
- [ ] Both — hangup releases camera + mic, no system call UI stuck
- [ ] Logout + relogin: calls subsystem reinitializes cleanly (no "session already started")
- [ ] Android 14+: ongoing-call notification visible, swipe-up doesn't kill the call
- [ ] Keyboard during call doesn't break layout
8. Pointers
cometchat-calls— dispatchercometchat-flutter-v6-core— UIKitSettings, init/login order, init guard rulescometchat-flutter-v6-events— Bloc event streamscometchat-flutter-v6-features— feature catalog (calls is a base capability — features layer on top)cometchat-flutter-v6-production— server-minted tokens, ProGuard, environment configcometchat-flutter-v6-troubleshooting— pubspec resolution, Bloc errors, theme cache, build errorscometchat-flutter-v6-migration— V5 → V6 migration recipes (GetX → Bloc, calls package → bundled, theme API rewrite)
Known external SDK gotchas (worth surfacing in any agent message)
startSessionon Android can returnnullwith a 5-second timeout and no error feedback — known platform issue; retry once before surfacing failure.SessionType.audiois ignored on Android in some V6 SDK builds — always opens with video. External SDK bug; track upstream.
Adding calls to an existing chat integration (Flutter V6 / Bloc)
V6 calls are bundled into cometchat_chat_uikit: ^6.0 — no separate calls package. Toggle on by enabling calling in the UIKit settings.
Read first: cometchat-flutter-v6-calls/SKILL.md — V6 architecture (Bloc).
---
Step 1 — Verify chat-uikit cohort
# pubspec.yaml
dependencies:
cometchat_chat_uikit: ^6.0 # V6 — calls bundledIf you see cometchat_calls_uikit separately listed, you're on V5 cohort — see cometchat-flutter-v5-calls/references/add-calls-to-existing-chat.md.
---
Step 2 — Enable calling on UIKit init
import 'package:cometchat_chat_uikit/cometchat_chat_uikit.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
final settings = UIKitSettingsBuilder()
..setAppId(APP_ID)
..setRegion(REGION)
..enableCalling(); // NEW
await CometChatUIKit.init(uiKitSettings: settings.build());
runApp(const MyApp());
}That's the entire migration. Calls init internally when chat init succeeds.
---
Step 3 — iOS + Android native config
Same as V5 (see V5 sister doc) — Info.plist + permissions + ConnectionService don't change between cohorts.
---
Step 4 — Mount CometChatIncomingCall
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Stack(
children: [
BlocProvider(
create: (_) => AuthBloc()..add(LoginRequested()),
child: const AppRoot(),
),
const CometChatIncomingCall(),
],
),
);
}
}---
Step 5 — Bloc-wrapped login + push registration
class AuthBloc extends Bloc<AuthEvent, AuthState> {
AuthBloc({required this.api}) : super(const AuthState.initial()) {
on<LoginRequested>(_onLogin);
}
Future<void> _onLogin(LoginRequested event, Emitter<AuthState> emit) async {
try {
final user = await CometChatUIKit.login(uid: event.uid, authKey: AUTH_KEY);
// No separate CometChatCalls.login in V6 — bundled
emit(AuthState.authenticated(user));
// Register VoIP push tokens after login
await context.read<CallPushBloc>().add(RegisterTokens(uid: user.uid));
} catch (e) {
emit(AuthState.failed(e.toString()));
}
}
}(See cometchat-flutter-v6-calls/references/server-push-bridge.md for the CallPushBloc pattern.)
---
Step 6 — Hangup teardown via Bloc
class CallBloc extends Bloc<CallEvent, CallState> {
CallBloc() : super(const CallState.idle()) {
on<EndCall>(_onEndCall);
}
Future<void> _onEndCall(EndCall event, Emitter<CallState> emit) async {
CometChatCalls.leaveSession();
await CometChat.endCall(event.sessionId);
// V6's bundled flow: no additional FlutterCallkitIncoming step;
// the kit handles it
emit(const CallState.ended());
}
}---
Verification checklist
- [ ]
cometchat_chat_uikit: ^6.0(or higher V6) in pubspec - [ ]
..enableCalling()chained in UIKitSettingsBuilder - [ ] Native config (iOS + Android) same as V5
- [ ]
CometChatIncomingCallsibling-overlay at root - [ ] VoIP push registration via CallPushBloc post-login
- [ ] Run
cometchat verify --calls— should pass
---
Pointers
cometchat-flutter-v5-calls/references/add-calls-to-existing-chat.md— V5 sister (separate calls package)cometchat-flutter-v6-calls/SKILL.md— V6 Bloc patternscometchat-flutter-v6-calls/references/server-push-bridge.md
Call layouts on Flutter V6 (Bloc)
Same SDK shape; Bloc-driven layout state.
Canonical docs: https://www.cometchat.com/docs/calls/flutter/call-layouts Read first: cometchat-flutter-v5-calls/references/call-layouts.md — SegmentedButton pattern + SDK API.
---
Cubit (single-state, no events overhead)
A layout switcher is a great fit for a Cubit since it has only one piece of state with simple updates.
class CallLayoutCubit extends Cubit<CallLayout> {
CallLayoutCubit() : super(CallLayout.tile);
void set(CallLayout next) {
CometChatCalls.setLayout(next);
emit(next);
}
void onSDKChanged(CallLayout next) {
emit(next); // already changed by kit's switcher; just sync
}
}Hook the SDK event:
@override
void onCallLayoutChanged(CallLayout layout) {
context.read<CallLayoutCubit>().onSDKChanged(layout);
}---
Switcher widget
class LayoutSwitcher extends StatelessWidget {
const LayoutSwitcher({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<CallLayoutCubit, CallLayout>(
builder: (context, layout) => SegmentedButton<CallLayout>(
segments: const [
ButtonSegment(value: CallLayout.tile, label: Text('Tile')),
ButtonSegment(value: CallLayout.sidebar, label: Text('Sidebar')),
ButtonSegment(value: CallLayout.spotlight, label: Text('Spotlight')),
],
selected: {layout},
onSelectionChanged: (set) {
if (set.isNotEmpty) {
context.read<CallLayoutCubit>().set(set.first);
}
},
),
);
}
}---
Provide the cubit at call start
BlocProvider<CallLayoutCubit>(
create: (_) => CallLayoutCubit(),
child: const CallScreen(),
);---
Anti-patterns
V5 sister rules apply, plus V6-specific:
1. Using a full Bloc when Cubit fits. Layout switcher is one-state-update — Cubit avoids the events boilerplate. 2. `BlocListener` for switcher rebuild. Use BlocBuilder — switcher is a UI that depends on state, not a side-effect.
---
Verification checklist
- [ ]
CallLayoutCubitprovided above the call screen - [ ]
BlocBuilderwraps the switcher - [ ]
onCallLayoutChangedsyncs the cubit - [ ] Real-device smoke: switcher cycles all 3 on Android + iOS
---
Pointers
cometchat-flutter-v5-calls/references/call-layouts.md— V5 sister (GetX)cometchat-flutter-v6-callsSKILL.md- Canonical docs: https://www.cometchat.com/docs/calls/flutter/call-layouts
Call session — joinSession with no ringing (Flutter V6 / Bloc)
Bloc-driven adaptation of the upstream sample. Same SDK underneath as V5 (cometchat_calls_sdk); the V6 kit (cometchat_chat_uikit ^6.0.0) is OPTIONAL for session-only flows.
Read first:
cometchat-flutter-v5-calls/references/call-session.md— V5 sister, canonical SDK API surface (callback shape,SessionSettingsBuilder,SessionStatusListeners)cometchat-react-calls/references/call-session.md— full cross-platform architecture
Source of truth: ~/Downloads/calls-sdk/calls-sdk-flutter-5/sample-apps/cometchat-calls-sample-app-flutter/lib/screens/call_screen.dart (the SDK version is the same; only the state-management primitive differs between v5 GetX and v6 Bloc).
---
Hard rules
All V5 rules apply (callback API, SessionSettingsBuilder, SessionStatusListeners interface, CometChatOngoingCallService.launch/abort, SizedBox.expand, no Chat SDK for session-only). The V6-specific delta is:
1. Cubit, not full Bloc. Single 1-state-update flow — Cubit is the right primitive. Don't introduce events + reducer ceremony. 2. `BlocConsumer` for auto-pop on idle transition — combines side-effect (Navigator.pop on terminated) with builder (loading / error / active UI). 3. Cubit holds the listener references, not the widget. close() calls removeSessionStatusListener + CometChatOngoingCallService.abort().
---
Cubit
import 'package:bloc/bloc.dart';
import 'package:cometchat_calls_sdk/cometchat_calls_sdk.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/widgets.dart';
import 'package:permission_handler/permission_handler.dart';
enum CallSessionStatus { idle, requesting, joining, active, leaving, error }
class CallSessionState extends Equatable {
final CallSessionStatus status;
final Widget? videoWidget;
final String? error;
const CallSessionState({this.status = CallSessionStatus.idle, this.videoWidget, this.error});
CallSessionState copyWith({
CallSessionStatus? status,
Widget? videoWidget,
String? error,
}) =>
CallSessionState(
status: status ?? this.status,
videoWidget: videoWidget ?? this.videoWidget,
error: error ?? this.error,
);
@override
List<Object?> get props => [status, videoWidget, error];
}
class CallSessionCubit extends Cubit<CallSessionState> implements SessionStatusListeners {
CallSessionCubit() : super(const CallSessionState());
CallSession? _callSession;
Future<void> join(String sessionId) async {
if (state.status != CallSessionStatus.idle) return;
emit(state.copyWith(status: CallSessionStatus.requesting));
final cam = await Permission.camera.request();
final mic = await Permission.microphone.request();
if (!cam.isGranted || !mic.isGranted) {
emit(state.copyWith(status: CallSessionStatus.error, error: 'Permission denied'));
return;
}
emit(state.copyWith(status: CallSessionStatus.joining));
final settings = (SessionSettingsBuilder()
.setTitle('CometChat Meeting')
.startVideoPaused(false)
.startAudioMuted(false))
.build();
CometChatCalls.joinSession(
sessionId: sessionId,
sessionSettings: settings,
onSuccess: (Widget? widget) {
_callSession = CallSession.getInstance();
_callSession?.addSessionStatusListener(this);
CometChatOngoingCallService.launch();
emit(state.copyWith(status: CallSessionStatus.active, videoWidget: widget));
},
onError: (CometChatCallsException error) {
emit(state.copyWith(status: CallSessionStatus.error, error: error.message ?? 'Failed'));
},
);
}
Future<void> leave() async {
if (state.status == CallSessionStatus.idle) return;
emit(state.copyWith(status: CallSessionStatus.leaving));
try {
await _callSession?.leaveSession();
} catch (_) {
// best-effort
}
await CometChatOngoingCallService.abort();
emit(const CallSessionState()); // back to idle
}
// --- SessionStatusListeners ---
@override
void onSessionJoined() {
CometChatOngoingCallService.launch();
}
// All termination events funnel through ONE guarded _handleTermination().
// The v5 Calls SDK fires onSessionLeft + onConnectionClosed in sequence on
// every hangup. Without the guard, abort() + emit() fire 3 times (wasteful
// and races with cubit close on rapid leave/rejoin).
bool _isTerminating = false;
void _handleTermination() {
if (_isTerminating) return;
_isTerminating = true;
CometChatOngoingCallService.abort();
emit(const CallSessionState());
}
@override
void onSessionLeft() => _handleTermination();
@override
void onConnectionClosed() => _handleTermination();
@override
void onSessionTimedOut() => _handleTermination();
@override
void onConnectionLost() {}
@override
void onConnectionRestored() {}
@override
Future<void> close() async {
_callSession?.removeSessionStatusListener(this);
if (state.status != CallSessionStatus.idle) {
await CometChatOngoingCallService.abort();
try {
await _callSession?.leaveSession();
} catch (_) {}
}
return super.close();
}
}---
Call room widget
class CallRoom extends StatelessWidget {
final String sessionId;
const CallRoom({super.key, required this.sessionId});
@override
Widget build(BuildContext context) {
return BlocProvider<CallSessionCubit>(
create: (_) => CallSessionCubit()..join(sessionId),
child: BlocConsumer<CallSessionCubit, CallSessionState>(
listenWhen: (prev, curr) =>
prev.status != CallSessionStatus.idle && curr.status == CallSessionStatus.idle,
listener: (context, state) => Navigator.of(context).pop(),
builder: (context, state) {
if (state.status == CallSessionStatus.error) {
return Scaffold(
backgroundColor: Colors.black,
body: Center(child: Text('Error: ${state.error}', style: const TextStyle(color: Colors.white))),
);
}
if (state.status != CallSessionStatus.active || state.videoWidget == null) {
return const Scaffold(
backgroundColor: Colors.black,
body: Center(child: CircularProgressIndicator(color: Colors.white)),
);
}
return Scaffold(
backgroundColor: Colors.black,
body: SizedBox.expand(child: state.videoWidget),
);
},
),
);
}
}---
Anti-patterns
V5 sister rules apply, plus V6-specific:
1. Storing the returned widget in the cubit's `_controller` field as if it were a controller. The Widget? IS what you render — there is no controller layer in the v5 Calls SDK. 2. Full Bloc with events for a 1-state-update flow. Cubit fits cleanly. 3. `BlocBuilder` instead of `BlocConsumer`. Need both side-effect (auto-pop) and builder (loading/error UI). 4. Forgetting `..join(sessionId)` cascade. Cubit created but never starts joining. 5. Forgetting to call `CometChatOngoingCallService.abort()` in `close()`. Service notification persists after route pops. 6. `CometChatOngoingCallController` / `CometChatOngoingCallView`. These do not exist in the v5 Calls SDK API. Use the Widget? returned by onSuccess directly.
---
Verification checklist
- [ ] Cubit implements
SessionStatusListenersand registers itself inonSuccess - [ ]
joinSessioncalled withsessionId:,sessionSettings:,onSuccess:,onError: - [ ] Settings built via
SessionSettingsBuilder(), notCallSettings - [ ]
BlocProvidercreates cubit and triggers..join(sessionId) - [ ]
BlocConsumer.listenerpops route on idle transition - [ ] Loading / error UI for non-active states
- [ ] Widget rendered via
SizedBox.expand(child: state.videoWidget) - [ ]
close()removes listener + aborts ongoing-call service + best-effort leaves - [ ] Standalone session-only: no
cometchat_chat_sdkdependency — Calls SDK alone - [ ] Real-device smoke: same as V5 sister
---
Pointers
cometchat-flutter-v5-calls/references/call-session.md— V5 sister + canonical SDK APIcometchat-flutter-v6-calls/SKILL.md— V6 Bloc patternscometchat-flutter-v6-calls/references/share-invite.md— deep-link config- Upstream sample —
~/Downloads/calls-sdk/calls-sdk-flutter-5/sample-apps/cometchat-calls-sample-app-flutter/lib/screens/call_screen.dart(SDK is the same) - Canonical docs: https://www.cometchat.com/docs/calls/flutter/join-session
Device management on Flutter V6 (Bloc)
Same SDK API as V5; Bloc-based state.
Canonical docs: https://www.cometchat.com/docs/calls/flutter/device-management Read first: cometchat-flutter-v5-calls/references/device-management.md — SDK shape + flutter_callkit_incoming integration.
---
SDK API
Same as V5. CometChatCalls.switchCamera() and CometChatCalls.setSpeakerEnabled(bool).
---
Bloc-driven device state
class DeviceState extends Equatable {
final bool speakerOn;
final bool cameraIsBack;
const DeviceState({this.speakerOn = false, this.cameraIsBack = false});
DeviceState copyWith({bool? speakerOn, bool? cameraIsBack}) =>
DeviceState(
speakerOn: speakerOn ?? this.speakerOn,
cameraIsBack: cameraIsBack ?? this.cameraIsBack,
);
@override List<Object> get props => [speakerOn, cameraIsBack];
}
abstract class DeviceEvent {}
class ToggleSpeaker extends DeviceEvent {}
class FlipCamera extends DeviceEvent {}
class DeviceBloc extends Bloc<DeviceEvent, DeviceState> {
DeviceBloc() : super(const DeviceState()) {
on<ToggleSpeaker>((event, emit) {
final next = !state.speakerOn;
CometChatCalls.setSpeakerEnabled(next);
emit(state.copyWith(speakerOn: next));
});
on<FlipCamera>((event, emit) {
CometChatCalls.switchCamera();
emit(state.copyWith(cameraIsBack: !state.cameraIsBack));
});
}
}---
Toggle widgets
class SpeakerToggle extends StatelessWidget {
const SpeakerToggle({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<DeviceBloc, DeviceState>(
buildWhen: (prev, curr) => prev.speakerOn != curr.speakerOn,
builder: (context, state) => IconButton(
icon: Icon(state.speakerOn ? Icons.volume_up : Icons.hearing),
onPressed: () => context.read<DeviceBloc>().add(ToggleSpeaker()),
tooltip: state.speakerOn ? 'Switch to earpiece' : 'Switch to speaker',
),
);
}
}buildWhen prevents re-rendering on unrelated state changes — important during a video call where rebuild thrash hurts performance.
---
Anti-patterns
Same as V5; plus V6-specific:
1. `buildWhen` omitted. Every state emit re-renders every BlocBuilder. 2. Calling SDK methods inside Bloc handlers without try/catch. Throwing inside the handler crashes the bloc.
---
Verification checklist
- [ ] DeviceBloc with
ToggleSpeaker+FlipCameraevents - [ ] BlocBuilder uses
buildWhenfor narrow rebuilds - [ ] State is
Equatablewith properprops - [ ] Real-device smoke: speaker toggle, camera flip both work
---
Pointers
cometchat-flutter-v5-calls/references/device-management.md— V5 sistercometchat-flutter-v6-callsSKILL.mdcometchat-flutter-v6-calls/references/raise-hand.md— Bloc pattern sibling- Canonical docs: https://www.cometchat.com/docs/calls/flutter/device-management
Group calls — broadcast meeting pattern (Flutter V6)
Group calls use a different signaling channel than 1:1 user calls. The Ringing flow (CometChat.initiateCall → onIncomingCallReceived on peer) is 1:1 user only. For groups, the kit broadcasts a custom message of type `"meeting"` to the group; receivers see a "Join meeting" card in CometChatMessageList (kit) or need an explicit MessageListener.onCustomMessageReceived (custom UI).
By-design kit behavior — same semantic across all CometChat kits.
Canonical docs: https://www.cometchat.com/docs/calls/flutter/group-calls
V6 specifics: Flutter V6 folds calls into cometchat_chat_uikit ^6.0.0-beta2 (no separate calls package). Bloc-based state. At v6.0.0-beta2, the outgoing-call → in-call transition is broken for 1:1 calls (vendor bug — see SKILL.md §1.7); this affects group meetings too if customers use the kit's outgoing-call UI. The custom-UI approach is more reliable on V6 today.
---
Architecture
Caller (uidA, member of groupX) CometChat Receivers (members of groupX)
│ │ │
│ CometChatCallButtons / custom code │ │
│ sends CustomMessage(GUID, GROUP, │ │
│ "meeting", { callType, sessionId }) │ │
├──────────────────────────────────────>│ │
│ │ onCustomMessageReceived │
│ ├─────────────────────────────>│
│ caller pushes OngoingCall screen │ receiver taps Join │
│ with sessionID = groupGuid │ → joinSession(GUID) │
├──────────────────────────────────────>│ <─────────────────────────── │
│ ───── WebRTC session active (sessionId = group GUID) ─────│| Channel | 1:1 user calls | Group calls |
|---|---|---|
| Signaling | CometChat.initiateCall(call, callback) | CometChat.sendCustomMessage(message, callback) (type="meeting") |
| Receiver event | CallListener.onIncomingCallReceived | MessageListener.onCustomMessageReceived |
| Session ID | server-generated unique | group's GUID (persistent) |
---
Hard rules
1. Group calls broadcast a custom message; they do NOT use the call listener. Custom UI must add a MessageListener. 2. Session ID = group GUID. Persistent. 3. V6 vendor bug applies to groups too (SKILL.md §1.7) — kit's outgoing-call screen does not reliably transition to ongoing-call surface. Prefer custom UI for groups on v6.0.0-beta2. 4. `navigatorKey: CallNavigationContext.navigatorKey` required on MaterialApp if using ANY kit-driven call UI path (group or 1:1) — same as the SKILL rule. 5. No `CometChat.endCall` for groups. Use CometChatUIKitCalls.endSession() only.
---
Caller side — kit-based
import 'package:cometchat_chat_uikit/cometchat_chat_uikit.dart';
CometChatCallButtons(
group: group,
)Tap → kit sends meeting CustomMessage + opens outgoing-call screen.
⚠️ At v6.0.0-beta2, the outgoing→in-call transition is broken (vendor — SKILL §1.7). Caller may see "Calling..." indefinitely after kit-driven group call. Workaround: use the custom-UI path below until vendor fix lands.
Caller side — custom UI (RECOMMENDED on v6.0.0-beta2)
import 'package:flutter_chat_sdk/flutter_chat_sdk.dart';
Future<void> startGroupCall(BuildContext context, String groupGuid, String callType) async {
final sessionId = groupGuid;
final customData = {'callType': callType, 'sessionId': sessionId};
final meetingMessage = CustomMessage(
receiverUid: groupGuid,
receiverType: CometChatReceiverType.group,
customData: customData,
type: 'meeting',
)
..category = MessageCategoryConstants.custom
..metadata = {
'incrementUnreadCount': true,
'pushNotification': 'meeting',
...customData,
};
await CometChat.sendCustomMessage(
meetingMessage,
onSuccess: (msg) {
// Caller navigates to YOUR OngoingCall screen (not the kit's)
Navigator.of(context).pushNamed('/ongoing-call', arguments: {
'sessionId': sessionId,
'callType': callType,
});
},
onError: (e) {/* surface error */},
);
}Then in your custom /ongoing-call screen, call CometChatUIKitCalls.startSession(callToken, callSettings, ...) against your own RTC view — same primitives the kit uses internally, but you control the navigation.
---
Receiver side — kit-based
CometChatMessageList(group: group) (or whichever message-list widget V6 ships) auto-renders the meeting message as a "Join meeting" card. Tap → kit calls generateToken + pushes the ongoing-call screen.
Same v6.0.0-beta2 caveat applies — if the kit's ongoing-call transition is broken, receivers may also see "Connecting…" indefinitely. Prefer custom join flow.
Receiver side — custom UI (RECOMMENDED)
import 'package:flutter_chat_sdk/flutter_chat_sdk.dart';
const groupMeetingListenerId = 'APP_ROOT_GROUP_MEETING_LISTENER';
class MeetingListener {
void register({required void Function(IncomingMeetingInfo) onIncoming}) {
CometChat.addMessageListener(
groupMeetingListenerId,
MessageListener(
onCustomMessageReceived: (msg) {
if (msg.category != MessageCategoryConstants.custom) return;
if (msg.type != 'meeting') return;
final customData = msg.customData ?? {};
onIncoming(IncomingMeetingInfo(
sessionId: customData['sessionId'] as String? ?? msg.receiverUid,
callType: customData['callType'] as String? ?? 'video',
fromUid: msg.sender?.uid ?? 'unknown',
groupGuid: msg.receiverUid,
));
},
),
);
}
void unregister() => CometChat.removeMessageListener(groupMeetingListenerId);
}In a Bloc-based app, dispatch an event into your CallBloc; the listener's onIncoming callback wraps the Bloc emit.
Register AFTER CometChatUIKit.login succeeds (e.g. in the auth Bloc's _onLoginSuccess state transition).
---
Edge cases
V6 vendor blocker context
This SKILL.md notes that V6 calling is partially-blocked at v6.0.0-beta2 — specifically the outgoing-call → in-call screen transition. Production Flutter customers should stay on V5 for both 1:1 and group calls until the vendor fixes the V6 BLoC handler. Group calls on V6 inherit the same blocker.
Late joining
Meeting persists in chat. Tap any time to join.
Push notifications
Meeting CustomMessage.metadata.pushNotification = "meeting" — delivered as regular FCM/APNs. To get CallKit/ConnectionService ring on iOS/Android, intercept the push and route to flutter_callkit_incoming manually.
---
Anti-patterns
1. Only `CometChat.addCallListener` registered. Won't fire for groups. 2. Relying on V6 kit's outgoing-call screen for groups on v6.0.0-beta2. Vendor blocker. Use custom navigation. 3. `CometChat.endCall(sessionId)` after group hangup. No call entity. 4. `MessageListener` registered before login. Silently dropped. 5. Skipping `navigatorKey` setup for any kit-driven call path.
---
Verification checklist
- [ ] V6 SKILL.md §1.7
navigatorKeyrequirement met (MaterialApp has CallNavigationContext.navigatorKey) - [ ] If kit caller:
CometChatCallButtons(group: group)— but verify outgoing-call transition works post-vendor-fix - [ ] If custom caller:
CometChat.sendCustomMessage(type: 'meeting', category: custom, customData: {callType, sessionId}) - [ ] If kit receiver:
CometChatMessageList(group: group)— see V6 vendor blocker caveat - [ ] If custom receiver:
CometChat.addMessageListenerregistered AFTER login with category+type filter - [ ] On hangup:
CometChatUIKitCalls.endSession()only - [ ] Late-joining tested
- [ ] Push notifications fire for offline members
---
Pointers
cometchat-flutter-v6-calls/SKILL.md§1.7 —navigatorKeyrequirement + V6 vendor blocker contextringing-integration.md— 1:1 user calls (different channel)- Cross-platform reference (semantic ground-truth):
cometchat-react-calls/references/group-calls.md - Canonical docs: https://www.cometchat.com/docs/calls/flutter/group-calls
Idle timeout on Flutter V6 (Bloc-based)
Same SDK API as V5; Bloc-based wiring. Read cometchat-flutter-v5-calls/references/idle-timeout.md first.
Canonical docs: https://www.cometchat.com/docs/calls/flutter/idle-timeout
---
SDK API + listener bridge
Same CallSettingsBuilder setters as V5. Listener via Bloc bridge (cf. cometchat-flutter-v6-calls/references/raise-hand.md):
abstract class CallEvent {}
class _SessionTimedOut extends CallEvent {}
class CallBloc extends Bloc<CallEvent, CallState> implements CometChatCallsEventsListener {
CallBloc() : super(const CallState()) {
on<_SessionTimedOut>(_onTimedOut);
CometChatCalls.addCallEventListener('call-bloc', this);
}
@override
void onSessionTimedOut() {
add(_SessionTimedOut());
}
void _onTimedOut(_SessionTimedOut event, Emitter<CallState> emit) {
emit(state.copyWith(timedOut: true));
}
@override
Future<void> close() {
CometChatCalls.removeCallEventListener('call-bloc');
return super.close();
}
}UI listens for state.timedOut:
BlocListener<CallBloc, CallState>(
listenWhen: (prev, curr) => !prev.timedOut && curr.timedOut,
listener: (context, state) async {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Call ended due to inactivity')),
);
Navigator.of(context).popUntil((route) => route.isFirst);
},
child: /* call surface */,
)BlocListener is one-shot side effects (navigation, snackbars). listenWhen ensures the listener only fires on the transition.
---
Custom prompt — same showDialog as V5
Identical code; cite V5 sister reference.
---
Verification checklist
- [ ] CallSettingsBuilder sets both idle periods
- [ ] CallBloc bridges
onSessionTimedOutto_SessionTimedOutevent - [ ] BlocListener with
listenWhentriggers nav + snackbar - [ ] Bloc
close()removes the listener - [ ] Real-device smoke: same as V5
---
Pointers
cometchat-flutter-v5-calls/references/idle-timeout.md— V5 sistercometchat-flutter-v6-callsSKILL.md — V6 hard rulescometchat-flutter-v6-calls/references/raise-hand.md— Bloc-bridge sibling pattern- Canonical docs: https://www.cometchat.com/docs/calls/flutter/idle-timeout
In-call chat on Flutter V6 (Bloc)
Same SDK shape; Bloc-driven open/unread state.
Canonical docs: https://www.cometchat.com/docs/calls/flutter/in-call-chat Read first: cometchat-flutter-v5-calls/references/in-call-chat.md — sheet pattern + group-as-session helper.
---
State + bloc
class InCallChatState extends Equatable {
final bool open;
final int unread;
final Group? group;
const InCallChatState({this.open = false, this.unread = 0, this.group});
InCallChatState copyWith({bool? open, int? unread, Group? group}) =>
InCallChatState(open: open ?? this.open, unread: unread ?? this.unread, group: group ?? this.group);
@override List<Object?> get props => [open, unread, group?.guid];
}
abstract class InCallChatEvent {}
class InitChat extends InCallChatEvent { final String sessionId; InitChat(this.sessionId); }
class OpenChat extends InCallChatEvent {}
class CloseChat extends InCallChatEvent {}
class _MessageReceived extends InCallChatEvent {}
class InCallChatBloc extends Bloc<InCallChatEvent, InCallChatState> {
InCallChatBloc() : super(const InCallChatState()) {
on<InitChat>(_onInit);
on<OpenChat>(_onOpen);
on<CloseChat>(_onClose);
on<_MessageReceived>(_onReceived);
}
Future<void> _onInit(InitChat event, Emitter<InCallChatState> emit) async {
final group = await ensureCallGroup(event.sessionId);
emit(state.copyWith(group: group));
}
void _onOpen(OpenChat event, Emitter<InCallChatState> emit) {
emit(state.copyWith(open: true, unread: 0));
CometChatCalls.setChatButtonUnreadCount(0);
}
void _onClose(OpenChat event, Emitter<InCallChatState> emit) {
emit(state.copyWith(open: false));
}
void _onReceived(_MessageReceived event, Emitter<InCallChatState> emit) {
if (state.open) return;
final next = state.unread + 1;
emit(state.copyWith(unread: next));
CometChatCalls.setChatButtonUnreadCount(next);
}
}---
Triggering open from SDK event
In your CometChatCallsEventsListener impl:
@override
void onChatButtonClicked() {
context.read<InCallChatBloc>().add(OpenChat());
}---
Sheet UI
Same showModalBottomSheet shape as V5. Use BlocSelector to react to state.open:
BlocListener<InCallChatBloc, InCallChatState>(
listenWhen: (prev, curr) => !prev.open && curr.open,
listener: (context, state) {
if (state.group == null) return;
showModalBottomSheet(
context: context,
isScrollControlled: true,
barrierColor: Colors.transparent,
builder: (_) => InCallChatPanel(group: state.group!),
).whenComplete(() {
context.read<InCallChatBloc>().add(CloseChat());
});
},
child: /* call surface */,
)---
Anti-patterns
V5 sister rules apply, plus V6-specific:
1. `BlocBuilder` instead of `BlocListener` for opening the sheet. BlocBuilder is for rebuilding UI; sheet-open is a one-shot side effect → BlocListener. 2. `listenWhen` omitted. Sheet opens on every state change.
---
Verification checklist
- [ ] InCallChatBloc with
InitChat,OpenChat,CloseChat,_MessageReceivedevents - [ ] BlocListener with
listenWhenfor the open transition - [ ]
showModalBottomSheet.whenCompletedispatchesCloseChat - [ ] Real-device smoke: same as V5
---
Pointers
cometchat-flutter-v5-calls/references/in-call-chat.md— V5 sister (sheet pattern)cometchat-flutter-v6-callsSKILL.mdcometchat-flutter-v6-calls/references/raise-hand.md— Bloc bridge sibling- Canonical docs: https://www.cometchat.com/docs/calls/flutter/in-call-chat
Raise hand on Flutter (UIKit V6, Bloc-based)
Same SDK conceptual API as V5; the difference is state management — V6 uses flutter_bloc rather than GetX. The reactive shape is RaiseHandBloc → emit(state) where state is an immutable record of localRaised + raisedParticipants.
Canonical docs: https://www.cometchat.com/docs/calls/flutter/raise-hand Read first: cometchat-flutter-v5-calls/references/raise-hand.md — UX shape is identical; this is the V6 Bloc translation.
---
SDK API (same as V5)
import 'package:cometchat_chat_uikit/cometchat_chat_uikit.dart';
import 'package:cometchat_chat_uikit/cometchat_calls_uikit.dart';
CometChatCalls.raiseHand();
CometChatCalls.lowerHand();
// In a CometChatCallsEventsListener implementation:
@override
void onParticipantHandRaised(Participant p) { /* ... */ }
@override
void onParticipantHandLowered(Participant p) { /* ... */ }
final settings = CallSettingsBuilder()..hideRaiseHandButton = true;---
Bloc setup
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:cometchat_chat_uikit/cometchat_calls_uikit.dart';
// State
class RaiseHandState extends Equatable {
final bool localRaised;
final List<RaisedParticipant> raised;
const RaiseHandState({this.localRaised = false, this.raised = const []});
RaiseHandState copyWith({bool? localRaised, List<RaisedParticipant>? raised}) =>
RaiseHandState(
localRaised: localRaised ?? this.localRaised,
raised: raised ?? this.raised,
);
@override
List<Object> get props => [localRaised, raised];
}
class RaisedParticipant extends Equatable {
final String uid;
final String name;
final DateTime raisedAt;
const RaisedParticipant({required this.uid, required this.name, required this.raisedAt});
@override
List<Object> get props => [uid, name, raisedAt];
}
// Events
abstract class RaiseHandEvent {}
class ToggleHand extends RaiseHandEvent {}
class _ParticipantRaised extends RaiseHandEvent {
final Participant participant;
_ParticipantRaised(this.participant);
}
class _ParticipantLowered extends RaiseHandEvent {
final Participant participant;
_ParticipantLowered(this.participant);
}---
Bloc implementation
class RaiseHandBloc extends Bloc<RaiseHandEvent, RaiseHandState>
implements CometChatCallsEventsListener {
RaiseHandBloc() : super(const RaiseHandState()) {
on<ToggleHand>(_onToggle);
on<_ParticipantRaised>(_onParticipantRaised);
on<_ParticipantLowered>(_onParticipantLowered);
// Self-register as the call event listener
CometChatCalls.addCallEventListener('raise-hand-bloc', this);
}
void _onToggle(ToggleHand event, Emitter<RaiseHandState> emit) {
if (state.localRaised) {
CometChatCalls.lowerHand();
} else {
CometChatCalls.raiseHand();
}
emit(state.copyWith(localRaised: !state.localRaised));
}
void _onParticipantRaised(_ParticipantRaised event, Emitter<RaiseHandState> emit) {
final entry = RaisedParticipant(
uid: event.participant.uid,
name: event.participant.name,
raisedAt: DateTime.now(),
);
final next = [...state.raised.where((r) => r.uid != entry.uid), entry];
next.sort((a, b) => a.raisedAt.compareTo(b.raisedAt));
emit(state.copyWith(raised: next));
}
void _onParticipantLowered(_ParticipantLowered event, Emitter<RaiseHandState> emit) {
final next = state.raised.where((r) => r.uid != event.participant.uid).toList();
emit(state.copyWith(raised: next));
}
// CometChatCallsEventsListener — bridge to Bloc events
@override
void onParticipantHandRaised(Participant p) {
add(_ParticipantRaised(p));
}
@override
void onParticipantHandLowered(Participant p) {
add(_ParticipantLowered(p));
}
// ... implement other CometChatCallsEventsListener methods (no-ops or pass-through)
@override
Future<void> close() {
CometChatCalls.removeCallEventListener('raise-hand-bloc');
return super.close();
}
}The bridge pattern: the bloc IS the SDK listener. SDK callbacks call add() on the bloc, which dispatches an event handler that emits new state. This keeps Bloc's "single source of truth" intact — no parallel state outside the bloc.
---
Toggle button widget
class RaiseHandButton extends StatelessWidget {
const RaiseHandButton({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<RaiseHandBloc, RaiseHandState>(
buildWhen: (prev, curr) => prev.localRaised != curr.localRaised,
builder: (context, state) => Semantics(
button: true,
selected: state.localRaised,
label: state.localRaised ? 'Lower hand' : 'Raise hand',
child: GestureDetector(
onTap: () {
context.read<RaiseHandBloc>().add(ToggleHand());
SemanticsService.announce(
state.localRaised ? 'Hand lowered' : 'Hand raised',
TextDirection.ltr,
);
},
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: state.localRaised ? const Color(0xFFFFD60A) : Colors.white24,
borderRadius: BorderRadius.circular(32),
),
child: Row(
children: [
const Text('✋'),
const SizedBox(width: 4),
Text(state.localRaised ? 'Lower' : 'Raise'),
],
),
),
),
),
);
}
}buildWhen ensures the button only rebuilds when localRaised changes — not on every roster update. Performance discipline matters in a video call.
---
Raised-hands sheet
Same DraggableScrollableSheet shape as V5; bind via BlocBuilder instead of Obx:
BlocBuilder<RaiseHandBloc, RaiseHandState>(
buildWhen: (prev, curr) => prev.raised != curr.raised,
builder: (context, state) => ListView(
children: [
Text('Raised hands (${state.raised.length})'),
for (final p in state.raised)
ListTile(leading: const Text('✋'), title: Text(p.name)),
],
),
)buildWhen again — only rebuild when the roster changes, not on toggle.
---
Wiring to the call screen
class CallScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => RaiseHandBloc(),
child: Scaffold(
body: Stack(
children: [
// ... call surface (CometChatOngoingCall)
const Positioned(bottom: 100, right: 20, child: RaiseHandButton()),
const RaisedHandsSheet(),
],
),
),
);
}
}BlocProvider(create:) scopes the bloc to the screen — disposed automatically on screen pop.
---
Anti-patterns
Web sister reference rules apply, plus V6-specific:
1. `buildWhen` omitted. Every state change rebuilds every BlocBuilder — lots of unnecessary work during a call. Add buildWhen for narrow rebuilds. 2. Mutating `state.raised` directly instead of creating a new list. Equatable props compare via ==; mutation doesn't trigger rebuild. 3. Calling `CometChatCalls.raiseHand()` outside the bloc. Bypasses the state machine; UI doesn't update. Always go through add(ToggleHand()). 4. Adding the SDK listener in `initState` instead of the bloc constructor. Component-level lifecycle ≠ Bloc lifecycle. Bloc close() is the canonical place to clean up.
---
Verification checklist
- [ ]
RaiseHandBlocextendsBloc<RaiseHandEvent, RaiseHandState> - [ ]
RaiseHandStateextendsEquatablewith properprops - [ ] Bloc registers itself as
CometChatCallsEventsListenerin constructor - [ ] Bloc
close()removes the listener - [ ]
BlocProvider(create:)scopes the bloc to the call screen - [ ]
buildWhenon every BlocBuilder narrows rebuilds - [ ]
Semantics+SemanticsService.announcefor a11y - [ ]
hideRaiseHandButton: truein CallSettings if custom UI - [ ] Real-device smoke: 3 devices (iOS + Android), 2 raise hands, host sees both
- [ ] Hot-reload smoke: trigger raise → save code → bloc state survives (bloc is created via
create:, persists across hot reload)
---
Pointers
cometchat-react-calls/references/raise-hand.md— sister reference (UX shape)cometchat-flutter-v5-calls/references/raise-hand.md— V5 sibling (GetX-based)cometchat-flutter-v6-callsSKILL.md — V6 hard rules + Bloc patternscometchat-a11y— Flutter Semantics + SemanticsService- Canonical docs: https://www.cometchat.com/docs/calls/flutter/raise-hand
Ringing — call signaling with custom UI (Flutter V6 / Bloc)
Same SDK shape; Bloc-driven state instead of GetX.
Read first:
cometchat-flutter-v5-calls/references/ringing-integration.md— V5 sister (GetX patterns)cometchat-react-calls/references/ringing-integration.md— full architecture, hard rules
---
Bloc
class CallSignalingState extends Equatable {
final Call? incoming;
final Call? outgoing;
final String? activeSessionId;
const CallSignalingState({this.incoming, this.outgoing, this.activeSessionId});
CallSignalingState copyWith({Call? incoming, Call? outgoing, String? activeSessionId}) =>
CallSignalingState(
incoming: incoming ?? this.incoming,
outgoing: outgoing ?? this.outgoing,
activeSessionId: activeSessionId ?? this.activeSessionId,
);
CallSignalingState clear() => const CallSignalingState();
@override
List<Object?> get props => [incoming?.sessionId, outgoing?.sessionId, activeSessionId];
}
abstract class CallSignalingEvent {}
class _IncomingReceived extends CallSignalingEvent { final Call call; _IncomingReceived(this.call); }
class _OutgoingAccepted extends CallSignalingEvent { final Call call; _OutgoingAccepted(this.call); }
class _OutgoingRejected extends CallSignalingEvent {}
class _IncomingCancelled extends CallSignalingEvent {}
class _CallEnded extends CallSignalingEvent {}
class InitiateCall extends CallSignalingEvent { final String receiverUid; final String type; InitiateCall(this.receiverUid, {this.type = 'video'}); }
class AcceptCall extends CallSignalingEvent { final Call call; AcceptCall(this.call); }
class RejectCall extends CallSignalingEvent { final Call call; RejectCall(this.call); }
class CallSignalingBloc extends Bloc<CallSignalingEvent, CallSignalingState> implements CallListener {
static const _listenerId = 'app';
CallSignalingBloc() : super(const CallSignalingState()) {
on<_IncomingReceived>((e, emit) => emit(state.copyWith(incoming: e.call)));
on<_OutgoingAccepted>((e, emit) => emit(state.copyWith(outgoing: null, activeSessionId: e.call.sessionId)));
on<_OutgoingRejected>((_, emit) => emit(state.copyWith(outgoing: null)));
on<_IncomingCancelled>((_, emit) => emit(state.copyWith(incoming: null)));
on<_CallEnded>((_, emit) => emit(state.clear()));
on<InitiateCall>(_onInitiate);
on<AcceptCall>(_onAccept);
on<RejectCall>(_onReject);
CometChat.addCallListener(_listenerId, this);
}
@override
void onIncomingCallReceived(Call call) => add(_IncomingReceived(call));
@override
void onOutgoingCallAccepted(Call call) => add(_OutgoingAccepted(call));
@override
void onOutgoingCallRejected(Call call) => add(_OutgoingRejected());
@override
void onIncomingCallCancelled(Call call) => add(_IncomingCancelled());
@override
void onCallEndedMessageReceived(Call call) => add(_CallEnded());
Future<void> _onInitiate(InitiateCall e, Emitter<CallSignalingState> emit) async {
final call = Call(
receiverUid: e.receiverUid,
callType: e.type == 'video' ? CometChatCallType.video : CometChatCallType.audio,
receiverType: CometChatReceiverType.user,
);
final result = await CometChat.initiateCall(call: call);
if (result is Call) emit(state.copyWith(outgoing: result));
}
Future<void> _onAccept(AcceptCall e, Emitter<CallSignalingState> emit) async {
final result = await CometChat.acceptCall(sessionId: e.call.sessionId!);
if (result is Call) emit(state.copyWith(incoming: null, activeSessionId: result.sessionId));
}
Future<void> _onReject(RejectCall e, Emitter<CallSignalingState> emit) async {
await CometChat.rejectCall(sessionId: e.call.sessionId!, status: CallStatus.rejected);
emit(state.copyWith(incoming: null));
}
@override
Future<void> close() {
CometChat.removeCallListener(_listenerId);
return super.close();
}
}---
Provide above the router
BlocProvider<CallSignalingBloc>(
create: (_) => CallSignalingBloc(),
child: MaterialApp.router(...),
);---
Incoming-call overlay
class IncomingCallOverlay extends StatelessWidget {
const IncomingCallOverlay({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<CallSignalingBloc, CallSignalingState>(
buildWhen: (prev, curr) => prev.incoming?.sessionId != curr.incoming?.sessionId,
builder: (context, state) {
final call = state.incoming;
if (call == null) return const SizedBox.shrink();
// Same UI as V5 sister; replace ctrl.accept/reject with bloc.add(...)
return Material(/* ... see V5 sister ... */);
},
);
}
}Mount in MaterialApp.builder:
MaterialApp.router(
builder: (context, child) => Stack(
children: [
child ?? const SizedBox.shrink(),
const IncomingCallOverlay(),
],
),
routerConfig: ...,
);---
Anti-patterns
V5 sister rules apply, plus V6-specific:
1. `BlocBuilder` without `buildWhen`. Rebuilds on every state change — listener events for outgoing/active also retrigger incoming UI. 2. Calling SDK methods from UI directly. Adds back to bloc — use bloc.add(InitiateCall(...)). 3. Provider scoped to a feature module. Backgrounded ring path can't find the bloc when push fires.
---
Verification checklist
- [ ]
BlocProvider<CallSignalingBloc>aboveMaterialApp - [ ]
IncomingCallOverlayinMaterialApp.builder's Stack - [ ]
BlocBuilderusesbuildWhento scope rebuilds toincomingchanges - [ ]
close()callsremoveCallListener - [ ] Real-device smoke: same as V5 sister
---
Pointers
cometchat-flutter-v5-calls/references/ringing-integration.md— V5 sister (GetX)cometchat-flutter-v6-calls/SKILL.md— V6 Bloc patternscometchat-flutter-v6-calls/references/server-push-bridge.md— backgrounded ring- Canonical docs: https://www.cometchat.com/docs/calls/flutter/ringing
Server-side push routing for Flutter V6 (iOS PushKit + Android FCM)
V6 uses the same server templates and platform plugins as V5. The only V6-specific delta is wrapping token registration in a Bloc.
Canonical docs:
- iOS:
cometchat-ios-calls/references/server-apns-pushkit.md - Android:
cometchat-android-v5-calls/references/server-fcm-voip.md - Read first:
cometchat-flutter-v5-calls/references/server-push-bridge.md— Flutter token registration patterns.
---
Bloc-style token registration
class CallPushBloc extends Bloc<CallPushEvent, CallPushState> {
CallPushBloc({required this.api}) : super(const CallPushState.idle()) {
on<RegisterTokens>(_onRegister);
}
final Api api;
Future<void> _onRegister(RegisterTokens event, Emitter<CallPushState> emit) async {
emit(const CallPushState.registering());
try {
if (Platform.isIOS) {
final voip = FlutterVoipPushKit();
voip.onTokenRefresh.listen((token) {
api.registerCallToken(uid: event.uid, platform: 'ios-voip', token: token);
});
await voip.configure();
} else if (Platform.isAndroid) {
final messaging = FirebaseMessaging.instance;
await messaging.requestPermission();
final token = await messaging.getToken();
if (token != null) {
await api.registerCallToken(uid: event.uid, platform: 'android-fcm', token: token);
}
messaging.onTokenRefresh.listen((t) {
api.registerCallToken(uid: event.uid, platform: 'android-fcm', token: t);
});
}
emit(const CallPushState.registered());
} catch (err) {
emit(CallPushState.failed(err.toString()));
}
}
}Dispatch after login completes:
BlocListener<AuthBloc, AuthState>(
listener: (context, state) {
if (state is Authenticated) {
context.read<CallPushBloc>().add(RegisterTokens(uid: state.uid));
}
},
child: const CallSurface(),
);---
Server-side: unchanged from V5
Identical to V5 — same APNs PushKit + FCM HTTP v1 templates.
---
Anti-patterns
V5 sister rules apply, plus V6-specific:
1. Registering pushes in `main()` before MultiBlocProvider. No bloc available; can't dispatch. 2. Adding `RegisterTokens` from `BlocBuilder`. BlocBuilder is for UI rebuilds. Use BlocListener for one-shot side effects.
---
Verification checklist
- [ ]
CallPushBlocprovided above the auth flow - [ ] BlocListener triggers
RegisterTokenspost-login - [ ] Server templates wired (see V5 sister)
- [ ] Real-device smoke: both iOS and Android ring
---
Pointers
cometchat-flutter-v5-calls/references/server-push-bridge.md— V5 sister (token registration patterns)cometchat-ios-calls/references/server-apns-pushkit.md— iOS canonicalcometchat-android-v5-calls/references/server-fcm-voip.md— Android canonicalcometchat-flutter-v6-callsSKILL.md
Share invite on Flutter V6 (Bloc)
Same SDK + share_plus. V6-specific: wrap share state in a Cubit so the UI can disable the button while a share is in flight.
Canonical docs: https://www.cometchat.com/docs/calls/flutter/share-invite Read first: cometchat-flutter-v5-calls/references/share-invite.md — share_plus + deep-link config.
---
Cubit
class ShareInviteCubit extends Cubit<bool> {
ShareInviteCubit() : super(false); // false = idle, true = sharing
Future<void> share(String sessionId, {Rect? origin}) async {
if (state) return; // debounce
emit(true);
try {
final url = 'https://yourapp.com/call/$sessionId';
await Share.share(
"I'm on a call — tap to join.\n$url",
subject: 'Join my call',
sharePositionOrigin: origin,
);
} finally {
emit(false);
}
}
}The boolean state is enough; we don't need a sealed-class state hierarchy here.
---
Bridge from SDK event
@override
void onShareInviteButtonClicked() {
context.read<ShareInviteCubit>().share(sessionId);
}---
App-built share button
class ShareInviteButton extends StatelessWidget {
final String sessionId;
const ShareInviteButton({super.key, required this.sessionId});
@override
Widget build(BuildContext context) {
return BlocBuilder<ShareInviteCubit, bool>(
builder: (context, sharing) => IconButton(
icon: sharing
? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.share),
onPressed: sharing
? null
: () {
final box = context.findRenderObject() as RenderBox?;
context.read<ShareInviteCubit>().share(
sessionId,
origin: box != null ? box.localToGlobal(Offset.zero) & box.size : null,
);
},
),
);
}
}---
Anti-patterns
V5 sister rules apply, plus V6-specific:
1. Full Bloc with events for a 1-state-update flow. Cubit fits cleanly. 2. Forgetting `try/finally` around `Share.share`. If share throws, button stays in disabled state forever.
---
Verification checklist
- [ ]
ShareInviteCubitprovided above call screen - [ ] Button disabled while sharing (BlocBuilder)
- [ ] iPad anchor passed via
sharePositionOrigin - [ ]
try/finallyensures cubit resets even on error - [ ] Real-device smoke: same as V5
---
Pointers
cometchat-flutter-v5-calls/references/share-invite.md— V5 sistercometchat-flutter-v6-callsSKILL.md- Canonical docs: https://www.cometchat.com/docs/calls/flutter/share-invite