convokit_flutter_ui 0.6.0
convokit_flutter_ui: ^0.6.0 copied to clipboard
Extensible, plug-and-play Flutter UI components for ConvoKit conversations and messaging.
ConvoKit Flutter UI #
Plug-and-play, extensible Flutter UI for the
convokit_flutter SDK. The package provides a
conversation inbox and a complete selected-conversation surface without taking
control away from the host app.
UI 0.6.0 requires convokit_flutter 0.6.x and the coordinated backend release.
Private room/app topics are discovered and refreshed
automatically by the core SDK.
Included #
- SDK-backed and controlled conversation-list components
- Inbox rows in activity order with the latest-message preview, the activity time and an unread badge computed from the caller's read position
- Cursor pagination with automatic scroll-to-load-more (offset pagination for custom page loaders)
- Search, archived, participant, predicate, and custom-sort filters
- SDK-backed and controlled conversation components
- Older-message pagination and realtime message, typing, and read events
- Text sending and structured image/file/media rendering
- Default read indicators calculated from precise participant read positions
- Read acknowledgements that target the newest rendered message and pause while the app is not in the foreground
- Theme tokens plus builders for every major state and component
- A replaceable client boundary for caching, analytics, offline state, custom authorization, or an alternative state-management layer
The package is deliberately split into two layers:
| Layer | Use when |
|---|---|
ConvoKitConversationList / ConvoKitConversation |
You want working SDK-backed UI with minimal setup. |
ConvoKitConversationListView / ConvoKitConversationView |
Your application already owns state and operations. |
Component gallery #
These are real renders of the package widgets using backend-free fixture data.
The complete, runnable implementation is in
lib/showcase/component_showcase_app.dart,
with widget coverage in
test/component_showcase_test.dart.
Standard components #

The default conversation rows with previews and unread badges, header, message
bubbles, structured file card, read receipts, and composer. This version also
demonstrates summaries, currentUserId, onRefresh, onAddAttachment,
readPositionByUserId, and reverseMessages: true.
Branded customer support #

The same controlled widgets styled as a support workspace. It replaces only
selected pieces through rowBuilder (custom rows that read the preview and
unread count from ConvoKitInboxRow), headerBuilder, mediaBlockBuilder,
readReceiptBuilder, and composerBuilder.
Compact operations #

A dense dashboard treatment using custom padding, separators, message rows,
typing indicator, and composer, with reverseMessages: false.
Run the public examples repository:
git clone https://github.com/ConvoKitApp/ConvoKit-Flutter-UI-Examples.git
cd ConvoKit-Flutter-UI-Examples
flutter run -d chrome
On Flutter web, append ?variant=standard, ?variant=branded, or
?variant=compact to open a specific configuration directly.
Install #
Add both the core SDK and UI package:
flutter pub add convokit_flutter convokit_flutter_ui
Configure and connect the core SDK before constructing an SDK-backed UI controller. The app's client secret belongs only on the token server; never put it in Flutter.
ConvoKit.configure(
clientId: 'public-client-id',
tokenProvider: (appUserId) => tokenService.issueToken(appUserId),
);
await ConvoKit.connectUser(currentAppUser.id);
The core SDK uses ConvoKit's managed https://api.convokit.app endpoint. Set
backendUrl only for local testing or a self-hosted deployment.
SDK-backed conversations render an outgoing message immediately, reconcile it with the server response and realtime echo, and restore an unchanged draft if the send fails.
Plug-and-play UI #
Use the list at the top level, then open a conversation selected by the user:
class Inbox extends StatelessWidget {
const Inbox({super.key});
@override
Widget build(BuildContext context) {
return ConvoKitConversationList(
onConversationSelected: (conversation) {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => Scaffold(
body: ConvoKitConversation(
conversationId: conversation.id,
onBack: () => Navigator.of(context).pop(),
onAttachmentTap: (context, message, attachment) {
// Open the URL with the host app's browser/download policy.
},
),
),
),
);
},
);
}
}
The list requests the next conversation page near its bottom. The conversation requests older message pages near its oldest edge. Controllers de-duplicate records and ignore stale asynchronous results.
Inbox previews and unread counts #
SDK-backed lists page GET /api/v1/inbox by opaque cursor in activity order
(the newest surviving message's time, or the room's creation time for an empty
room; edits do not move a room, deleting its newest message recomputes it).
state.summaries maps every loaded conversation id to the core SDK's
InboxSummary: latestMessage (the GET /messages row shape, at most four
media items), unreadCount (messages from other participants after the
caller's read position; own messages never count), unreadCountCapped (true
when more than 1,000 messages follow the position, so the count is a lower
bound), the caller's readPosition and lastReadAt, and activityAt.
state.currentUserId is the connected user's id while the list is bound to a
session in inbox mode; it is null without a session, with a custom pageLoader
and after disposal.
Default rows render the preview instead of the participants line when a
summary has a latest message with a body: You: prefixes the connected user's
own message (direct messages included), <name>: prefixes another
participant's message in a room with more than two participants when that
sender is still a participant with a name, and a media-only message reads
Photo, the file name (or File), Location or Contact. The trailing time
is activityAt in the device zone (HH:mm, like message rows). While
unreadCount > 0 or the count is capped, the title uses the bolder weight and
a badge shows the count, 99+ above 99 or when capped, announced to assistive
technology as <count> unread (99+ unread when capped) with the visible
digits hidden. ConvoKitUiThemeData.badgeColor styles the badge and defaults
to primaryColor (effectiveBadgeColor). Rows without a summary render as in
0.5.
Custom rows receive the same data through rowBuilder, which wins over
itemBuilder:
ConvoKitConversationList(
onConversationSelected: open,
rowBuilder: (context, row, onTap) {
final summary = row.summary; // null without inbox data
final preview = convoKitInboxPreview(
conversation: row.conversation,
summary: summary,
currentUserId: row.currentUserId,
);
return ListTile(
onTap: onTap,
title: Text(row.conversation.displayTitle),
subtitle: preview == null ? null : Text(preview),
trailing: summary == null || summary.unreadCount == 0
? null
: ConvoKitUnreadBadge(
unreadCount: summary.unreadCount,
capped: summary.unreadCountCapped,
),
);
},
);
ConvoKitInboxRow carries conversation, summary, index and
currentUserId. Controlled ConvoKitConversationListViews accept summaries
and currentUserId; without them rows render as in 0.5 and no You: prefix
appears. itemBuilder keeps its (context, conversation, index, onTap)
signature.
SDK-backed lists arrive in inbox order; server order is authoritative within a
page and the local (activityAt desc, id desc) order is applied whenever pages
are merged. Setting ConvoKitConversationFilter.comparator replaces that order
entirely. Conversation.updatedAt is never an ordering input.
Filtering and pagination #
Own a controller when filters need to change after construction:
final conversations = ConvoKitConversationListController(
pageSize: 25,
initialFilter: const ConvoKitConversationFilter(archived: false),
);
await conversations.setQuery('design');
await conversations.setFilter(
ConvoKitConversationFilter(
participantIds: const {'app_user_42'},
predicate: (conversation) => conversation.description != null,
comparator: (a, b) => a.displayTitle.compareTo(b.displayTitle),
),
);
archived is sent to the SDK. Text, participants, predicates, and ordering are
applied locally. If a local filter produces no result in the current source
page, the controller keeps paging until it finds a match or reaches the end.
pageSize (1..100, default 30) is the cursor page size; hasMore reflects the
server's nextCursor.
For server-side search or a different source, provide pageLoader. Custom
loaders keep the offset request shape, creation order and no summaries:
final conversations = ConvoKitConversationListController(
pageLoader: (request) async {
return repository.searchConversations(
query: request.filter.query,
limit: request.limit,
offset: request.offset,
);
},
);
UI customization #
The controlled widgets let the host replace only what it needs:
ConvoKitConversationView(
conversation: state.conversation,
messages: state.messages,
currentUserId: state.userId,
readPositionByUserId: state.readPositionByUserId,
readAtByUserId: state.readAtByUserId,
typingUserIds: state.typingUserIds,
onSendMessage: controller.sendText,
onLoadOlder: controller.loadOlder,
hasOlderMessages: state.hasOlder,
headerBuilder: (context, conversation, onBack, onRefresh) {
return MyConversationHeader(conversation: conversation);
},
messageBuilder: (context, message, index, isMine, sender, readers) {
return MyMessageBubble(message: message, readers: readers);
},
mediaBlockBuilder: (context, block, message, isMine) {
return block['type'] == 'poll' ? MyPoll(block: block) : null;
},
composerBuilder: (context, text, isSending, send, addAttachment) {
return MyComposer(controller: text, onSend: send);
},
);
Available replacement points include conversation rows, separators, loading, empty and error states, header, message row, individual media blocks, read receipts, typing indicator, composer, attachment taps, user-name resolution, scroll controllers, padding, thresholds, and list direction.
To style the defaults, install the theme extension:
MaterialApp(
theme: ThemeData(
extensions: const [
ConvoKitUiThemeData.light(),
],
),
home: const Inbox(),
);
Use copyWith to replace individual color and sizing tokens. badgeColor
(0.6.0) styles the unread badge on default rows and is optional: unset, the
badge uses primaryColor; the digits use outgoingTextColor.
Functional customization #
Implement ConvoKitUiClient and pass it to either controller when the UI should
use a repository, cache, offline queue, analytics wrapper, or a custom
authorization policy. DefaultConvoKitUiClient delegates directly to the
existing static ConvoKit SDK.
Externally supplied controllers remain owned by the host and must be disposed there. Controllers created internally by plug-and-play widgets are disposed by the widgets.
Optimistic outgoing rows display Sending… until the backend confirms them
through HTTP, live updates or authorized history. The server timestamp is then rendered in the viewer's local
timezone. Custom message builders can use isConvoKitPendingMessage(message)
to present the same state.
Read receipts #
ConvoKitConversationController.state.readPositionByUserId stores the newest
message each participant has confirmed reading, as the server's
(createdAt, id) cursor, seeded from conversation participants and advanced by
realtime read events; positions only move forward. state.readAtByUserId keeps
each participant's last acknowledgement time for custom "seen at" renderers.
state.readerIdsFor(message) returns the users whose read state covers that
message under one rule: the position when the participant has one, otherwise
lastReadAt >= createdAt (a legacy membership or an empty-room
acknowledgement). Equal creation times fall back to id order, matching the
backend cursor and every other ConvoKit client. The default outgoing bubble
displays one check once the server has confirmed the send (announced as "Sent"
to assistive technology) and two checks plus a reader count after another
participant has read it (announced as "Read"). Replace readReceiptBuilder for
avatars, detailed labels, or product-specific rules; it receives the same reader
set. The controlled views accept readPositionByUserId beside readAtByUserId;
either alone works.
Read acknowledgements #
SDK-backed controllers acknowledge reads through a concrete message so a delayed
request cannot mark messages that arrived later as read. markReadOnLoad
acknowledges after the first history page renders, markReadOnReceive after an
incoming message from another participant renders (a media-only row counts once
hydration shows it, and a refresh that discovers new foreign rows counts too),
and markRead() on demand. The target is always the newest non-pending row of
state.messages by (createdAt, id), never a raw realtime row; a room with
nothing rendered sends nothing. One request is in flight at a time, a follow-up
resolves its target when it is sent, and a target at or below the last
acknowledged one is skipped. Read state for the connected user is never written
from the device clock; it arrives from the server like everyone else's.
ConvoKitConversationController.setVisible(bool) defers acknowledgements while
the conversation is hidden and re-issues only a suppressed one when it becomes
visible; controllers start visible. ConvoKitConversation wires this to the app
lifecycle: AppLifecycleState.resumed is visible, paused/inactive/hidden/
detached are hidden, and an unreported initial state counts as visible. Hosts
that own a controller can call setVisible from route or tab visibility as
well. With both markReadOnLoad and markReadOnReceive false the controller
never sends a read request, including on visibility changes.
If the backend rejects a targeted read with ConvoKitException.code == 'MESSAGE_NOT_FOUND', or the in-flight/last acknowledged target is deleted, the
controller marks that id unacknowledgeable and re-issues once for the next
newest rendered row without surfacing an error. A 404 without that code means
the membership is gone; it surfaces as state.error and evicts the room like
any other access denial.
Mixed fleet: precise receipts need the sender and the reader on 0.5. A 0.4 reader keeps timestamp semantics but still parses the additive payloads.
Live inbox updates #
SDK-backed inboxes listen to two signals on the shared private app channel.
inboxChanges (room creation, membership changes, metadata changes,
deletion/cascades and every verified initial join/rejoin) triggers an immediate
authorized REST refresh. inboxActivity (a message insert or edit, or a
read-position advance anywhere in the app) is throttled: the first signal starts
a timer for activityRefreshWindowMs (default 500 ms; 0 refreshes
immediately), later signals inside the window are absorbed without extending
it, and one refresh runs when it fires. A structural change during a pending
window refreshes at once and drops the timer; disposal and session end cancel
it. Manual refresh() (pull-to-refresh) stays immediate. Notifications contain
no room contents or identifiers. Open room controllers reconcile on
inboxChanges only, so removed access clears their view and activity never
costs them a request.
Refresh walks the inbox from its head by cursor with
limit = min(100, target - consumed) where target = max(pageSize, loadedCount)
until the server has no further page or the loaded count is covered and at
least one row passes the local filter, so a fully hidden head page never
publishes an empty list while more pages exist. Pages merge by conversation id
(a later entry wins, then the window is re-sorted by activity) and rows,
summaries, cursor and hasMore swap atomically, retaining visible rows during
loading, local filters and previously loaded pages. Custom page loaders keep
their offset walk and loaded page count and receive the current filter on each
request. Bursts coalesce; a change arriving during a fetch schedules another
pass. Transient errors and 400 responses (for example an INVALID_CURSOR)
retain rows and set state.error; access denial (401/403 from either endpoint,
404 from the legacy endpoint) or session replacement clears them.
If the backend answers listInbox with 404 (the route is absent after a
rollback or on a staging deployment), the controller switches to the offset
getConversations path for the rest of its session without clearing rows,
warns once through debugPrint, re-runs the same operation, and reports an
empty summaries; loadInitial() tries the inbox endpoint again.
Reconnect and session recovery #
SDK-backed room controllers refetch persisted receipts and page through the
currently viewed history range after either private room channel joins/rejoins.
refresh() / reconcile() use the same non-destructive recovery path. Existing
messages and pending sends stay visible; state.isReconciling reports progress.
Newer live changes win over an older HTTP response, and read positions only
advance. A device-clock timestamp is never treated as a confirmed read receipt.
Raw Postgres events omit the related media table. The UI shows text immediately
and fetches the complete authorized message through getMessage(id). It retains
attachments during provisional edits, and waits for the full response before
displaying a media-only message. The server's updatedAt revision prevents old
responses from rewinding edits. Lookups are coalesced per ID and limited to eight
in flight per controller, including across reloads; this adds REST requests for
observed row changes. Retired, deleted and superseded responses are discarded.
The controller creates a UUID
clientMessageId before displaying the pending row and forwards it unchanged
to the core/backend. A matching canonical message from the same sender and room
replaces that row atomically, even before the HTTP acknowledgement. Identical
text or attachment counts never identify a send. Media-only echoes retain the
pending preview until authorized hydration finishes. If a confirmed send's HTTP
response is lost, the composer treats it as success without restoring the draft
or resurrecting a subsequently deleted message. Typing-stop remains independent
of send completion.
Continued composer input renews typing at most once per half typingTimeout
(1.5 seconds with the default 3-second timeout), not on every keystroke.
Idle input sends no keepalives; idle timeout or explicit stop clears typing.
Disconnect, disposal and session replacement cancel the timers, and failures
from older typing requests cannot reset a newer request's state.
Room controllers also consume the core SDK's private ID-only deletion stream. Deleted messages disappear without manufacturing an old message body. Deletion markers last until an explicit initial reload/session reset; delayed send responses, history pages, and older Realtime rows cannot restore those IDs. Realtime does not replay missed events, so reconnect recovery still reconciles edits/deletions within loaded history. The new inbox stream also invalidates cascade changes. If REST recovery fails, the error is exposed and a subsequent refresh or rejoin retries it; an interrupted join is not a successful recovery.
Custom ConvoKitUiClient implementations must provide a stable sessionIdentity
for one login (including app identity), return null on logout, and replace it on
every new login even when the user ID is unchanged. Close connectionEvents
when that session ends. Emit logical messages:<room> / conversation:<room>
join statuses; closed as an event may be a recoverable channel replacement,
whereas stream completion ends the session. For getMessages(before:), use the
cursor's (createdAt, id) values, offset zero, and strict descending order.
Implement onMessageDeleted() as Stream<MessageDeletedEvent> containing only
id and conversationId; onMessage() now handles inserts/updates only.
Implement getMessage(id) with the same app/room authorization as history,
returning the complete current message and authoritative related media list.
Preserve Message.updatedAt when present; it is distinct from creation time.
For 0.4.0, implement inboxChanges with mutation and verified-join
signals, forward sendMessage(clientMessageId:), and preserve that ID on
HTTP/history/live messages. These additions require the 0.4.0 core/backend
contract. No raw-DELETE fallback is used.
For 0.5.0, markConversationRead gains an optional named throughMessageId
(markConversationRead(String conversationId, {String? throughMessageId}));
Dart implements adapters must add the parameter and forward it. Reject a
missing, deleted or foreign target with a ConvoKitException whose code is
MESSAGE_NOT_FOUND so the controller retargets instead of reporting an error,
and keep a membership failure as a 404 without a code. Return participants with
readPosition and read events with readPosition when your backend provides
them; the controllers fall back to lastReadAt/readAt otherwise.
For 0.6.0, implement
Future<InboxPage> listInbox({required int limit, String? cursor, required bool archived})
and Stream<void> get inboxActivity. listInbox returns one cursor page in
(activityAt desc, id desc) order with at most limit entries, unique
non-blank conversation ids, and a nextCursor that differs from the requested
one (null on the last page); forward it to ConvoKit.listInbox or serve the
same shape from your backend. Signal an absent endpoint with a
ConvoKitException whose statusCode is 404 so the controller falls back to
getConversations without previews; a malformed cursor is a 400 with code
INVALID_CURSOR. inboxActivity emits empty signals after message inserts,
edits and read-position advances and is never synthesised on joins (that stays
with inboxChanges); return const Stream.empty() when your backend has no
such signal.
Controllers clear cached data after session replacement or history access
denial; they never silently bind to another user. Connect the intended user and
call loadInitial() again, or construct new controllers. A custom inbox
pageLoader used with a connected client shares that client's lifecycle and
inbox signals. Offline custom loaders and fully controlled widgets remain owned
by their host.
Media #
Default image and file cards tolerate missing URLs, names, and numeric/string
file sizes. onAttachmentTap intentionally delegates opening and downloading
to the host app, where authentication and platform behavior belong. Unknown
structured types receive a safe fallback; return a widget from
mediaBlockBuilder to support custom blocks such as polls, locations, contacts,
audio, or commerce cards.
Verification #
dart format --output=none --set-exit-if-changed lib test
flutter analyze
flutter test
See the public
ConvoKit-Flutter-UI-Examples
repository for runnable default, branded, compact, and SDK-backed application
configurations.