convokit_flutter_ui 0.4.0
convokit_flutter_ui: ^0.4.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.4.0 requires convokit_flutter 0.4.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
- Offset pagination with automatic scroll-to-load-more
- 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 participant read positions
- 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, header, message bubbles, structured file card,
read receipts, and composer. This version also demonstrates onRefresh,
onAddAttachment, readAtByUserId, and reverseMessages: true.
Branded customer support #

The same controlled widgets styled as a support workspace. It replaces only
selected pieces through itemBuilder, 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.
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) => b.updatedAt.compareTo(a.updatedAt),
),
);
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.
For server-side search or cursor translation, provide pageLoader:
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,
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.
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.readAtByUserId stores the latest known
read position from conversation participants and realtime read events.
state.readerIdsFor(message) returns the users whose read timestamp includes
that message. The default outgoing bubble displays one check when delivered and
two checks plus a reader count after another participant has read it. Replace
readReceiptBuilder for avatars, detailed labels, or product-specific rules.
Live inbox updates #
SDK-backed inboxes listen to inboxChanges on the shared private app channel.
Room creation, membership changes, metadata changes and deletion/cascades trigger
an authorized REST refresh; each verified initial join/rejoin also refreshes the
list. Notifications contain no room contents or identifiers. Open room
controllers reconcile metadata/history too, so removed access clears their view.
Refresh replaces the loaded window atomically, retaining visible rows during loading, local filters and previously loaded pages. The default backend's stable creation-time/ID order preserves the old loaded boundary when new rooms are inserted. Custom page loaders preserve their loaded page count and receive the current filter on each request. Bursts coalesce; a change arriving during a fetch schedules another pass. Transient errors retain rows; access denial or session replacement clears them.
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.
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.