Flutter SDK (loopchat_flutter)
Dart client + chat widgets for TheLoopChat. This was the first client SDK built and serves as the behavioral reference for the JS SDKs — the two are kept feature-equivalent to the v1 contract.
- Repo:
flutter-sdk/— Dart ≥ 3.4, Flutter ≥ 3.22. - Dependencies:
http,web_socket_channel. - Tests:
flutter test(unit + widget); live e2e withLOOPCHAT_API_KEY=… LOOPCHAT_API_SECRET=… flutter test --tags live test/live_e2e_test.dart.
import 'package:loopchat_flutter/loopchat_flutter.dart';
final client = LoopChatClient('lck_yourApiKey', baseUrl: 'https://chat.example.com');
// Token comes from YOUR backend (signed with the app's api_secret).
// client.devToken('user-id') works for apps with dev mode enabled.
await client.connectUser(User(id: 'thierry', name: 'Thierry'), token);
final channel = client.channel('messaging', id: 'pool-abc', extraData: {'name': 'Pool ABC'});
await channel.watch(); // state + live events; 403 => not a member
await channel.sendMessage(Message(text: 'hello!'));
final channels = await client.queryChannels(
filter: Filter.in_('members', [client.state.currentUser!.id]),
channelStateSort: const [SortOption('last_message_at')],
paginationParams: const PaginationParams(limit: 30),
);
client.wsConnectionStatusStream.listen((s) => print(s));
await client.addDevice(fcmToken, PushProvider.firebase);
LoopChatClient
LoopChatClient(String apiKey, { String baseUrl = 'http://localhost:8080', logger, http.Client? httpClientOverride })
| Member | Description |
|---|---|
connectUser(User user, String token): Future<User> |
Opens the WebSocket; completes on the first health.check (carries connection_id + unread state). Best-effort profile sync via PATCH /users/me when name/image/extraData are set. |
disconnectUser() / close() |
Disconnect and reset state / tear the client down. |
devToken(String userId): Token |
Dev token (Token.rawValue is the JWT string). Accepted only for dev-mode apps. |
wsConnectionStatusStream / wsConnectionStatus / connectionId |
Stream<ConnectionStatus> (disconnected/connecting/connected) + current values. |
channel(String type, {required String id, Map extraData}) |
Cached Channel reference; extraData is used as creation data when watch() lazily creates the channel. |
queryChannels({filter, channelStateSort, paginationParams, state = true, watch = false, messageLimit = 25}) |
GET /channels; returns Future<List<Channel>> with state applied. PaginationParams(limit: 30, offset: 0). |
updateUser(User user): Future<User> |
PATCH /users/me — own name/image/custom. |
addDevice(token, PushProvider.firebase) / removeDevice(token) / getDevices() |
Device registration for FCM push. |
on([String? eventType]): Stream<Event> |
All events, or only those matching eventType. |
state: ClientState |
currentUser (+ currentUserStream), totalUnreadCount (+ totalUnreadCountStream), unreadCountFor(cid). |
Channel
Obtained via client.channel(...). Properties: type, id, cid, extraData, name, state.
| Method | Description |
|---|---|
watch({int messageLimit = 50}): Future<ChannelClientState> |
Loads channel state and subscribes this connection to live events; lazily creates the channel from extraData (name/members lifted out, rest sent as custom). Throws LoopChatNetworkError with statusCode 403 when not a member. |
stopWatching() |
Unsubscribes this connection. |
sendMessage(Message message): Future<Message> |
Server assigns the ULID id and echoes message.new; REST response and WS echo are deduped by id. |
updateMessage(Message) / deleteMessage(Message, {hard}) |
Own messages only (server-enforced). |
query({String? before, int limit = 50}) |
Loads older history and prepends it. |
markRead() |
Marks read up to the newest message and zeroes local unread state. |
on([String? eventType]): Stream<Event> |
Events scoped to this channel. |
ChannelClientState
Live state with broadcast streams: messages / messagesStream, members / membersStream, unreadCount / unreadCountStream, plus channelModel. Mutators dedupe by message id and keep ULID order; events (message.*, member.*) are applied automatically, and own messages never bump the unread count — the exact behavior mirrored by core's ChannelState.
Models
| Class | Notes |
|---|---|
User |
id required; name, image, role, extraData (wire custom), lastActiveAt, createdAt. Equality by id. |
Message |
Construct with just text to send (ids are server-assigned ULIDs). id, text, user, userId, attachments, extraData, timestamps, deletedAt / isDeleted. |
Attachment |
type, title, imageUrl, assetUrl, extraData. |
Member |
userId, user?, role (default member), createdAt. |
Read |
userId, lastReadMessageId, unreadCount. |
ChannelModel |
cid, id, type, name, createdById, frozen, memberCount, extraData, lastMessageAt, createdAt. |
Device |
token, provider. PushProvider enum: firebase. |
Event mirrors the protocol's WS events (type, cid, channelId, channelType, connectionId, message, member, user, me, unreadCount, eventId, createdAt); EventType holds the string constants including the local connectionError.
Filters and errors
Filter.in_('members', [userId])
Filter.equal('type', 'messaging')
Filter.and([f1, f2]) // shallow merge — v1 filters are conjunctive by shape
SortOption('last_message_at', direction: -1) // -1 desc (default), 1 asc
LoopChatNetworkError — statusCode (HTTP), code (protocol code: 16 = channel not found, 17 = not allowed/not a member), message. Thrown for non-2xx REST responses and WS auth failures.
WebSocket behavior
LoopChatWebSocket mirrors the JS core implementation: connects to /connect?api_key=…&token=…[&last_event_id=…], completes on the first health.check, sends a health.check keepalive every 25s, auto-reconnects with exponential backoff + jitter (500ms base, 30s cap), and passes last_event_id for replay. See API — WebSocket.
Widgets
| Widget | Description |
|---|---|
LoopChannel |
InheritedWidget providing a Channel to descendants (LoopChannel.of(context)). |
LoopMessageListView |
Scrollable message list: own-right/other-left bubbles, day separators, sender avatars, pagination, marks read while visible. |
LoopMessageInput |
Composer with send button, sending state, and SnackBar error reporting. Accepts common composer options (focusNode, onMessageSent, disableAttachments, showCommandsButton, hintText, enabled) — the attachment/command flags are accepted but currently no-ops (those features are v2). |
LoopChannel(
channel: channel,
child: Column(children: [
Expanded(child: LoopMessageListView()),
LoopMessageInput(onMessageSent: (m) {}),
]),
)