Using it in Flutter UIs
The Flutter SDK's core package deeprespond_im is pure Dart and doesn't depend on Flutter: everything that changes is exposed as a snapshot plus a Stream. On top of that, deeprespond_im_flutter provides a few widgets commonly needed in UIs:
| Widget | Purpose |
|---|---|
LiveListBuilder | Subscribes to a LiveList (conversation list, friend list and so on) and rebuilds when the snapshot changes |
toValueListenable() | Turns a LiveList into a Flutter ValueListenable you can pass to ValueListenableBuilder |
DRImage | Shows images in messages and avatars, exchanging for download URLs and caching automatically; see Files and images |
This page explains where the client lives, how to connect lists, events and state to widgets, and what to watch for in the widget lifecycle. The examples only use Flutter's own StatefulWidget, InheritedWidget and StreamBuilder, with no state management package. With Provider, Riverpod, Bloc and the like, the approach is the same: the client is a single global object, and lists and subscriptions are created and released along with pages.
| You want to | See |
|---|---|
| Give every page access to the client | Where the client lives |
| Switch between the sign-in page and the main UI, show a connection banner | Sign-in state and connection state |
| Show conversation, friend and group member lists | Lists: LiveList and LiveListBuilder |
| Show a message list | Message lists |
| Show unread counts and typing, alert on send failures | Subscribing to events and state |
| Handle a page being covered or switched away in tabs | Lifecycle |
Where the client lives
Create the client once in main() before runApp and share it across the app; don't create it in widgets. The client lives as long as the process and never needs releasing; calling DRClient.create() again with the same AppKey returns the same client.
import 'package:deeprespond_im_flutter/deeprespond_im_flutter.dart';
import 'package:flutter/material.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized(); // 在使用 DRFlutterPlatform 之前
final im = await DRClient.create(DRClientOptions(
appKey: '1575529652#demo',
apiUrl: Uri.parse('https://im.example.com'),
platform: DRFlutterPlatform.instance,
));
runApp(IMScope(client: im, child: const MyApp()));
}
/// 在 Widget 树中提供客户端:任何子孙 Widget 用 IMScope.of(context) 取得。
class IMScope extends InheritedWidget {
const IMScope({super.key, required this.client, required super.child});
final DRClient client;
static DRClient of(BuildContext context) => context.dependOnInheritedWidgetOfExactType<IMScope>()!.client;
@override
bool updateShouldNotify(IMScope oldWidget) => !identical(client, oldWidget.client);
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) => const MaterialApp(home: Placeholder());
}You can also pass im down through constructors, as the example app does, or hand it to your dependency injection or state management package (such as Provider's Provider<DRClient>.value(value: im)). The rest of this page's examples take im as a constructor argument.
Hot reload doesn't rerun main(), so the client stays the same; hot restart creates the client again, which the SDK handles with nothing extra on your part.
Sign-in state and connection state
Show the sign-in page or the main UI depending on the sign-in state. im.auth.state is the current state and im.auth.stateStream emits later changes; when using StreamBuilder, give the current value as initialData:
import 'package:flutter/material.dart';
class Root extends StatefulWidget {
const Root({super.key, required this.im});
final DRClient im;
@override
State<Root> createState() => _RootState();
}
class _RootState extends State<Root> {
// 在 State 中保存 Stream,不要在 build 中每次重新取得
late final Stream<AuthState> _auth = widget.im.auth.stateStream;
@override
Widget build(BuildContext context) => StreamBuilder<AuthState>(
stream: _auth,
initialData: widget.im.auth.state,
builder: (context, s) => switch (s.data) {
// 换用户时用新的 key,旧用户的页面(和其中的列表)整体释放
SignedIn() || Suspended() => HomePage(key: ValueKey(widget.im.auth.currentUser?.username), im: widget.im),
_ => const LoginPage(),
},
);
}Handle the connection state the same way to show a "Connecting…" or "No network" banner:
import 'package:flutter/material.dart';
class ConnectionBanner extends StatefulWidget {
const ConnectionBanner({super.key, required this.im});
final DRClient im;
@override
State<ConnectionBanner> createState() => _ConnectionBannerState();
}
class _ConnectionBannerState extends State<ConnectionBanner> {
late final Stream<DRConnectionState> _states = widget.im.connection.stateStream;
@override
Widget build(BuildContext context) => StreamBuilder<DRConnectionState>(
stream: _states,
initialData: widget.im.connection.state,
builder: (context, s) => switch (s.data) {
ConnectionReady() || null => const SizedBox.shrink(),
ConnectionOffline() => const MaterialBanner(content: Text('网络不可用'), actions: [SizedBox.shrink()]),
_ => const MaterialBanner(content: Text('连接中…'), actions: [SizedBox.shrink()]),
},
);
}For sign-in and connection states and how to handle them, see Initialization, sign-in and connection.
Lists: LiveList and LiveListBuilder
Lists that are fully stored locally and keep updating are LiveLists:
| Method | List |
|---|---|
im.conversations.list() | Conversation list (ConversationList) |
im.friends.list() | Friends |
im.friends.blacklist.list() | Blocklist |
im.groups.list() | My groups |
im.groups.members(groupId, role: ..., muted: ...) | Group members |
Each call creates a new list. Whoever creates a list releases it: create it in a State (in initState or a late final field) and call list.dispose() in dispose(). LiveListBuilder only subscribes; it doesn't release.
import 'package:deeprespond_im_flutter/deeprespond_im_flutter.dart';
import 'package:flutter/material.dart';
class ConversationsPage extends StatefulWidget {
const ConversationsPage({super.key, required this.im, required this.onOpen});
final DRClient im;
final void Function(String conversationKey) onOpen;
@override
State<ConversationsPage> createState() => _ConversationsPageState();
}
class _ConversationsPageState extends State<ConversationsPage> {
late final ConversationList _list = widget.im.conversations.list();
@override
void dispose() {
_list.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => LiveListBuilder<ConversationView>(
list: _list,
builder: (context, snapshot) {
if (snapshot.loading && snapshot.items.isEmpty) return const Center(child: CircularProgressIndicator());
return ListView.builder(
itemCount: snapshot.items.length + (snapshot.hasMore ? 1 : 0),
itemBuilder: (context, i) {
if (i == snapshot.items.length) {
return TextButton(onPressed: () => unawaited(_list.loadMore()), child: const Text('加载更多'));
}
final c = snapshot.items[i];
return ListTile(
key: ValueKey(c.conversationKey),
title: Text(c.peer ?? c.groupId ?? c.conversationKey),
trailing: c.unreadCount > 0 ? Badge(label: Text('${c.unreadCount}')) : null,
onTap: () => widget.onOpen(c.conversationKey),
);
},
);
},
);
}- A
LiveListSnapshothasitems,hasMore,loadinganderror. Snapshots and their items can't be modified; unchanged items keep their objects, so you can compare references to decide what to rebuild. - Several changes in the same event-loop turn produce one notification; no new snapshot is emitted when nothing changed.
loadMore()loads the next page; no need to call it whenhasMoreisfalse.- When parameters change (for example the
groupIdon a group member page), release the old list and create a new one indidUpdateWidget.
Without LiveListBuilder, you can use StreamBuilder directly (stream: list.stream, initialData: list.snapshot), or turn the list into a ValueListenable with toValueListenable(). The latter is a ChangeNotifier and must be released along with the list:
import 'package:deeprespond_im_flutter/deeprespond_im_flutter.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
class FriendsPage extends StatefulWidget {
const FriendsPage({super.key, required this.im});
final DRClient im;
@override
State<FriendsPage> createState() => _FriendsPageState();
}
class _FriendsPageState extends State<FriendsPage> {
late final LiveList<Friend> _list = widget.im.friends.list();
late final ValueListenable<LiveListSnapshot<Friend>> _friends = _list.toValueListenable();
@override
void dispose() {
(_friends as ChangeNotifier).dispose(); // 先取消订阅
_list.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => ValueListenableBuilder<LiveListSnapshot<Friend>>(
valueListenable: _friends,
builder: (context, snapshot, _) => ListView(children: [for (final f in snapshot.items) ListTile(title: Text(f.username))]),
);
}Message lists
A conversation's message list MessageList (opened with im.messages.open(conversationKey)) isn't a LiveList: it pages both backward and forward and has gaps and pinned messages, so it can't go into LiveListBuilder and has no toValueListenable(). Subscribe to its stream with StreamBuilder, and again release it in the creating page's dispose():
import 'package:flutter/material.dart';
class ChatPage extends StatefulWidget {
const ChatPage({super.key, required this.im, required this.conversationKey});
final DRClient im;
final String conversationKey;
@override
State<ChatPage> createState() => _ChatPageState();
}
class _ChatPageState extends State<ChatPage> {
late final MessageList _list = widget.im.messages.open(widget.conversationKey);
@override
void dispose() {
_list.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => StreamBuilder<MessageListSnapshot>(
stream: _list.stream,
initialData: _list.snapshot,
builder: (context, s) {
final snapshot = s.data ?? _list.snapshot;
final items = snapshot.items;
return ListView.builder(
reverse: true, // 最新的在下面
itemCount: items.length,
itemBuilder: (context, i) {
final m = items[items.length - 1 - i];
if (i == items.length - 1 && snapshot.hasOlder) unawaited(_list.loadOlder()); // 滚到最上面时加载更早的
return ListTile(key: ValueKey(m.clientMsgId ?? '${m.seq}'), title: Text('${m.body?['text'] ?? '[${m.type}]'}'));
},
);
},
);
}For message list snapshots, paging, read marking and sending, see Messages.
Subscribing to events and state
Showing a value with StreamBuilder
To just show a value that changes with events (the unread total, who's typing, someone's presence), subscribe to the matching event with StreamBuilder and read the current value in builder. This one shows "typing…":
import 'package:flutter/material.dart';
class TypingHint extends StatefulWidget {
const TypingHint({super.key, required this.im, required this.conversationKey});
final DRClient im;
final String conversationKey;
@override
State<TypingHint> createState() => _TypingHintState();
}
class _TypingHintState extends State<TypingHint> {
late final Stream<TypingChanged> _changes = widget.im.on<TypingChanged>().where((e) => e.conversationKey == widget.conversationKey);
@override
Widget build(BuildContext context) => StreamBuilder<TypingChanged>(
stream: _changes,
builder: (context, _) {
final typing = widget.im.conversations.getTyping(widget.conversationKey); // 每次重建时读取当前值
return typing.isEmpty ? const SizedBox.shrink() : const Text('对方正在输入…');
},
);
}For which values can be read synchronously and which event fires when each changes, see Events and errors.
Subscribing in a State
To act when an event arrives (show a toast, navigate), listen in initState and cancel in dispose():
import 'dart:async';
import 'package:deeprespond_im_flutter/render.dart';
import 'package:flutter/material.dart';
class SendFailureListener extends StatefulWidget {
const SendFailureListener({super.key, required this.im, required this.child});
final DRClient im;
final Widget child;
@override
State<SendFailureListener> createState() => _SendFailureListenerState();
}
class _SendFailureListenerState extends State<SendFailureListener> {
late final StreamSubscription<MessageSendFailed> _sub;
@override
void initState() {
super.initState();
_sub = widget.im.on<MessageSendFailed>().listen((e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(describeError(e.error))));
});
}
@override
void dispose() {
unawaited(_sub.cancel());
super.dispose();
}
@override
Widget build(BuildContext context) => widget.child;
}For all events, see Events and errors. Chat room and call objects have their own stream, used the same way; see Chat rooms and Audio and video calls.
Showing names
Conversation titles and users' display names aren't part of the conversation object: they come from friend remarks, group nicknames, user profiles and group names, and the conversation object doesn't change when they do. conversationTitle(im, conversation) and displayName(im, username, groupId: ...) from package:deeprespond_im_flutter/render.dart pick the name in the order remark, group nickname, nickname, username. To keep names current, rebuild on UsersChanged, FriendsChanged and GroupsChanged:
import 'package:deeprespond_im_flutter/render.dart';
import 'package:flutter/material.dart';
class ConversationTitle extends StatefulWidget {
const ConversationTitle({super.key, required this.im, required this.conversation});
final DRClient im;
final ConversationView conversation;
@override
State<ConversationTitle> createState() => _ConversationTitleState();
}
class _ConversationTitleState extends State<ConversationTitle> {
late final Stream<DREvent> _names = widget.im.events.where((e) => e is UsersChanged || e is FriendsChanged || e is GroupsChanged);
@override
Widget build(BuildContext context) => StreamBuilder<DREvent>(
stream: _names,
builder: (context, _) => Text(conversationTitle(widget.im, widget.conversation)),
);
}Lifecycle
- Foreground and background.
DRFlutterPlatformlistens to the app's foreground/background switches and network changes itself; the SDK pauses or resumes the connection, reports the state and syncs when returning to the foreground. Your app doesn't need to forwardAppLifecycleState. - Releasing. Release lists,
StreamSubscriptions and objects fromtoValueListenable()inState.dispose(). A message list that's never released keeps the SDK backfilling messages for it and keeps the conversation counted as "being viewed". - Sign-out and switching users. Open lists are emptied (one empty snapshot is emitted) and released, and stop updating. Recreate pages after sign-in; the simplest way is to key the main UI by username, as above. Calling
dispose()again on a released list has no effect, and methods such asloadMore()reject withinvalid_state(disposed). - Message lists that aren't visible. The SDK only considers the user to be viewing a conversation when the app is in the foreground and the list is shown and at the newest message (with
autoMarkReadon, it marks messages read based on that). Going to the background is handled by the SDK. When the page is covered by a new route, or switched away in aTabBarViewwithout being released, calllist.setViewing(visible: false), and set it back totruewhen it returns; when the user scrolls up through history, calllist.setViewing(atBottom: false).
To report when a page is covered by a new route, use Flutter's RouteAware:
import 'package:flutter/material.dart';
// MaterialApp(navigatorObservers: [routeObserver], ...) 中登记同一个 routeObserver
class ChatView extends StatefulWidget {
const ChatView({super.key, required this.im, required this.conversationKey});
final DRClient im;
final String conversationKey;
@override
State<ChatView> createState() => _ChatViewState();
}
class _ChatViewState extends State<ChatView> with RouteAware {
late final MessageList _list = widget.im.messages.open(widget.conversationKey);
@override
void didChangeDependencies() {
super.didChangeDependencies();
final route = ModalRoute.of(context);
if (route != null) routeObserver.subscribe(this, route);
}
@override
void didPushNext() => _list.setViewing(visible: false); // 被新的页面盖住
@override
void didPopNext() => _list.setViewing(visible: true); // 回到这个页面
@override
void dispose() {
routeObserver.unsubscribe(this);
_list.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => const SizedBox();
}Things to watch
- Don't create lists in build. Each call to
im.conversations.list(),im.messages.open()and the like creates a new list; create them in aStateand release them indispose(). - Keep Streams in the State.
im.on<T>(),im.auth.stateStreamandlist.streamreturn a newStreamobject on every read, andStreamBuilderresubscribes when itsstreamchanges. Writing them directly inbuildstill displays correctly (data from before is kept after resubscribing, and a list'sstreamemits the current snapshot on subscribe), but frequent parent rebuilds cause repeated subscriptions, so keep them inStatefields as this page's examples do. - Main isolate only.
DRClient, its lists and events live in the main isolate. Push and incoming-call background handlers run in a background isolate and must useDRBackgroundClient; see Offline push. - Long lists. The conversation list holds up to
conversationWindowitems (1000 by default), and message lists grow as the user pages back, so use lists built on demand such asListView.builder, keyed byconversationKey,clientMsgIdorseq. - Images. For images in messages and group file thumbnails, use
DRImage. Don't pass file URLs straight toImage.network(they can't be loaded directly), and don't store the download URLs you exchange for; see Files and images. - One platform implementation per process.
DRFlutterPlatform.instanceis created on first use, so callWidgetsFlutterBinding.ensureInitialized()before it.
API reference
LiveListBuilder
A StatelessWidget from deeprespond_im_flutter. Subscribes to a LiveList and calls builder to rebuild when the snapshot changes; doesn't release the list.
| Parameter | Type | Required | Description |
|---|---|---|---|
list | LiveList<T> | Yes (named) | The list to subscribe to |
builder | Widget Function(BuildContext context, LiveListSnapshot<T> snapshot) | Yes (named) | Builds the UI from the snapshot; on first build, it's the list's current snapshot |
key | Key? | No (named) |
LiveList.toValueListenable()
An extension method on LiveList from deeprespond_im_flutter, available after importing package:deeprespond_im_flutter/deeprespond_im_flutter.dart.
Returns: ValueListenable<LiveListSnapshot<T>>, whose value is the list's current snapshot, notifying when the snapshot changes. It's also a ChangeNotifier; call dispose() when you're done to unsubscribe from the list (this doesn't release the list).
Types
LiveList
| Member | Type | Description |
|---|---|---|
snapshot | LiveListSnapshot<T> | The current snapshot |
stream | Stream<LiveListSnapshot<T>> | Emits the current snapshot on subscribe, then a new one on every change; closes when the list is released |
loadMore() | Future<void> | Loads the next page |
dispose() | void | Releases the list, which then stops updating; safe to call more than once |
LiveListSnapshot
| Field | Type | Description |
|---|---|---|
items | List<T> | The list's items; can't be modified |
hasMore | bool | Whether there's another page |
loading | bool | Whether a load is in progress (true when first opened) |
error | DRException? | The error from the most recent load |
Channel video widgets
deeprespond_im_call provides ChannelVideoView, and channel handles also have localView(), remoteView(sessionId), switchCamera() and setSpeakerphoneOn().
- Remote video is bound by the track's
sessionId(a string); don't use usernames or numeric IDs. - Removing a video widget only stops displaying it. When the page exits, cancel the snapshot and track subscriptions and
await channel.leave(). - Subscribing to
streamandtracksfirst delivers the current value.
For an example, see Standalone channels.
