Load only the section you are implementing. For the SDK patterns and gotchas behind these
blueprints, see CHAT-ADVANCED-FLUTTER.md. For client
initialization, connectUser, and the StreamChat widget, see
CHAT-FLUTTER.md / CHAT-FLUTTER-blueprints.md
(pre-built UI) or CHAT-CORE.md (custom UI).
These blueprints are package-agnostic — client is a StreamChatClient regardless of which
UI tier you use.
End-to-end FCM wiring: a top-level background handler, device registration after connect,
token-refresh re-registration, and deregistration on logout. Assumes FlutterFire is already
set up (google-services.json / GoogleService-Info.plist, APNs key) and push (v2) is
enabled in the Stream Dashboard. If it's not setup correctly let the integrator know and ask to do it.
You can create a template/placehodler files.
// main.dart
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
// Top-level — required by FCM. Runs in its own isolate with no app state, so
// connect WITHOUT a WebSocket and fetch the message by id from the payload.
@pragma('vm:entry-point')
Future<void> _onBackgroundMessage(RemoteMessage message) async {
await Firebase.initializeApp();
final client = StreamChatClient('api_key', logLevel: Level.OFF);
await client.connectUser(
User(id: 'user-id'),
'user-token',
connectWebSocket: false, // no live socket in the background isolate
);
final messageId = message.data['id'];
if (messageId != null) {
final response = await client.getMessage(messageId);
// Render with flutter_local_notifications using response.message.* and
// message.data['type'] / message.data['cid'].
}
}
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp();
FirebaseMessaging.onBackgroundMessage(_onBackgroundMessage);
final client = StreamChatClient('api_key', logLevel: Level.OFF);
await client.connectUser(User(id: 'user-id', name: 'User'), 'user-token');
await _registerDevice(client); // see helper below
runApp(MyApp(client: client));
}// push_registration.dart — register/deregister helpers
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
Future<void> _registerDevice(StreamChatClient client) async {
final settings = await FirebaseMessaging.instance.requestPermission();
if (settings.authorizationStatus == AuthorizationStatus.denied) return;
final token = await FirebaseMessaging.instance.getToken();
if (token != null) {
await client.addDevice(token, PushProvider.firebase);
}
// Re-register whenever FCM rotates the token.
FirebaseMessaging.instance.onTokenRefresh
.listen((t) => client.addDevice(t, PushProvider.firebase));
}
// On logout: deregister BEFORE disconnecting.
Future<void> logout(StreamChatClient client) async {
final token = await FirebaseMessaging.instance.getToken();
if (token != null) await client.removeDevice(token);
await client.disconnectUser();
}Wiring:
_onBackgroundMessageis top-level and annotated@pragma('vm:entry-point')— a method or closure won't survive the background isolate.- In that isolate use
connectUser(..., connectWebSocket: false)andgetMessage(id); the live app keeps its normal WebSocket connection. addDeviceruns afterconnectUser;onTokenRefreshkeeps the registration current.removeDeviceruns beforedisconnectUserso the server stops targeting a logged-out device.- Push requires channel membership and fires only for new messages; no push arrives while the app holds an active WebSocket.
Attach the persistence client before connectUser. Nothing else changes — the channel
list, message lists, and search read from the local SQLite cache transparently when offline,
and queued writes retry on reconnect.
// main.dart
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
import 'package:stream_chat_persistence/stream_chat_persistence.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
final client = StreamChatClient('api_key', logLevel: Level.OFF)
..chatPersistenceClient = StreamChatPersistenceClient(
logLevel: Level.OFF,
// .background runs the DB on an isolate, keeping heavy reads/writes
// off the UI thread. Use .regular for the simplest setup.
connectionMode: ConnectionMode.background,
);
await client.connectUser(
User(id: 'user-id', name: 'User'),
'user-token',
);
runApp(MyApp(client: client));
}
// On logout, flush this user's local DB:
Future<void> logout(StreamChatClient client) async {
await client.disconnectUser(flushChatPersistence: true);
}Wiring:
- Add
stream_chat_persistence: ^10.0.0topubspec.yaml(not a default dependency). - Assign
chatPersistenceClientwith the cascade (..) beforeconnectUser— attaching it after connect does nothing for the current session. - The DB is keyed by
userId; switching users uses a separate DB.flushChatPersistence: trueclears the cache on logout.
When chat is one section of a larger app, don't block runApp on connectUser. Kick off the
connection once, store the future, and gate the chat UI on it. Force route disposal on logout
so the WebSocket actually closes.
// chat_section.dart
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
class ChatSection extends StatefulWidget {
const ChatSection({super.key, required this.client, required this.userId, required this.token});
final StreamChatClient client;
final String userId;
final String token;
@override
State<ChatSection> createState() => _ChatSectionState();
}
class _ChatSectionState extends State<ChatSection> {
// Started once — NOT in build, so it doesn't re-fire on rebuild.
late final Future<OwnUser> _connection =
widget.client.connectUser(User(id: widget.userId), widget.token);
@override
void dispose() {
// Closes the WebSocket when the chat section leaves the tree.
widget.client.disconnectUser();
super.dispose();
}
@override
Widget build(BuildContext context) {
return FutureBuilder<OwnUser>(
future: _connection,
builder: (context, snap) {
if (snap.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snap.hasError) {
return Center(child: Text('Connection failed: ${snap.error}'));
}
return const ChannelListPage();
},
);
}
}
// On logout, force the whole stack to dispose so disconnectUser runs:
void logout(BuildContext context) {
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(builder: (_) => const LoginPage()),
(route) => false,
);
}Wiring:
_connectionis alate finalfield — created once, never insidebuild.- The
FutureBuildershows a spinner while connecting, an error view on failure, and the channel list on success. disconnectUser()indispose()closes the socket when the section unmounts;pushAndRemoveUntil(..., (r) => false)guarantees the section is actually disposed on logout so the connection doesn't linger.- Backgrounding/foregrounding is automatic — the SDK closes and reopens the WebSocket with app lifecycle; no manual handling needed.