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 with LOOPCHAT_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

LoopChatNetworkErrorstatusCode (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) {}),
  ]),
)