convokit_flutter_ui 0.7.0 copy "convokit_flutter_ui: ^0.7.0" to clipboard
convokit_flutter_ui: ^0.7.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.7.0 requires convokit_flutter 0.7.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
  • A private "mark unread" marker per room: markUnread / clearUnread on the list controller, a numberless dot on default rows, and acknowledgements that clear the marker only while the version captured at open is current
  • 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.

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 #

Standard ConvoKit conversation list and chat 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 #

Branded ConvoKit customer support interface

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 #

Compact ConvoKit operations interface

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, isUnread (unreadCount > 0 || unreadCountCapped || unreadMarkedAt != null), the caller's private "mark unread" state unreadMarkedAt and privateStateVersion (0.7.0, see Mark unread), 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 isUnread, unreadCount > 0 or the count is capped, the title uses the bolder weight. A count or a capped count shows a badge with the count, 99+ above 99 or when capped (even with a marker), announced to assistive technology as <count> unread (99+ unread when capped) with the visible digits hidden. A marker without a count (isUnread true, unreadCount 0, not capped) shows ConvoKitUnreadDot, an 8-point numberless dot announced as Unread; no count is invented, so 0 unread is never announced. ConvoKitUiThemeData.badgeColor styles both 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
          ? null
          : summary.unreadCount > 0 || summary.unreadCountCapped
              ? ConvoKitUnreadBadge(
                  unreadCount: summary.unreadCount,
                  capped: summary.unreadCountCapped,
                )
              : summary.isUnread
                  ? const ConvoKitUnreadDot()
                  : null,
    );
  },
);

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.

Mark unread #

The connected user can mark a room unread for themselves only. The marker is private membership state: it never appears in participant DTOs, other members' lists, webhooks or read events, and unreadCount is never inflated by it. The list controller exposes it:

// A custom row action, menu item or swipe: no default gesture is built in.
await conversations.markUnread(conversation.id);

// Remove the marker without acknowledging any message. `ifVersion` makes the
// clear conditional on the version the caller last saw.
final cleared = await conversations.clearUnread(
  conversation.id,
  ifVersion: summary.privateStateVersion,
);

markUnread calls the adapter's markConversationUnread (POST /api/v1/conversations/:id/unread); clearUnread calls clearConversationUnread (DELETE /api/v1/conversations/:id/unread, with ?privateStateVersion= when ifVersion is given) and returns the response's cleared: whether this request removed the marker. false is a normal answer (nothing was marked, or the version no longer matched), not an error. On any 200 the response's unreadMarkedAt and privateStateVersion replace the current summary's as one unit and isUnread is recomputed from the stored counts and the new marker, but only when the response version is at least the stored one: privateStateVersion increases on every mark (also a repeat mark) and every effective clear, so a delayed response can never resurrect a marker a newer action removed. Failures are recorded in state.error without evicting rows. With a custom pageLoader (no summaries) the adapter is still called but nothing is patched. The caller's other devices learn of a mark or clear through the activity signal below, which refetches the inbox.

Default rows render the marker as the dot described above when the room has no unread messages, and keep the numeric badge when it has. Custom rows read row.summary.isUnread, unreadMarkedAt and privateStateVersion.

Opening the room clears the marker the way reading does, version-guarded: see Read acknowledgements. Mixed fleet: a 0.6 list ignores isUnread and shows no dot; a room against a 0.6 backend receives no membership, sends no version and never clears the marker.

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.

Capture at open (0.7.0): the room controller reads Conversation.membership.privateStateVersion (and whether unreadMarkedAt is set) from the first conversation DTO of the loaded session, on loadInitial or on the first successful reconcile after a transient first-load failure (which then also applies markReadOnLoad), and never replaces it from a later reconcile; loadInitial(), session end and disposal reset it. Every targeted acknowledgement of that open sends the captured privateStateVersion beside throughMessageId, so the server clears the connected user's own marker only while that version is still current; a repeat mark or a mark from another device made after the capture survives a delayed acknowledgement, and the position rule is unchanged. A DTO without membership (a 0.6 backend) sends no version. An empty room has nothing to acknowledge, so when it opened with the marker set the controller calls clearConversationUnread(conversationId, ifVersion: captured) once per open, under the same triggers (markReadOnLoad after hydration, or an explicit markRead()) and the same visibility gating (deferred while hidden, issued on setVisible(true)); never once a row is rendered, since the targeted acknowledgement clears it; cleared: false is not an error and a failure takes the acknowledgement's fail path. Room controllers still never send an untargeted acknowledgement and never write the marker locally; the list learns of the clear through the activity signal.

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, a read-position advance, or a change to the connected user's own "mark unread" marker 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. For 0.7.0, markConversationRead gains an optional named privateStateVersion (markConversationRead(String conversationId, {String? throughMessageId, int? privateStateVersion})) and the interface gains Future<ConversationPrivateState> markConversationUnread(String conversationId) and Future<ClearUnreadResult> clearConversationUnread(String conversationId, {int? ifVersion}); Dart implements adapters must add all three. Forward the version unchanged (an adapter that drops it never clears the marker from a room), return the private state (unreadMarkedAt, privateStateVersion) from the mark and clear responses, and answer a no-op clear (nothing marked, or a version mismatch) with cleared: false rather than an error. Return the caller's own membership as Conversation.membership from getConversation when your backend provides it; without it the room sends no version. inboxActivity should also follow the connected user's own marker changes so other devices refresh.

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.

0
likes
0
points
919
downloads

Publisher

verified publisherconvokit.app

Weekly Downloads

Extensible, plug-and-play Flutter UI components for ConvoKit conversations and messaging.

Homepage
Repository (GitHub)
View/report issues

Topics

#chat #messaging #realtime #ui

License

unknown (license)

Dependencies

convokit_flutter, flutter

More

Packages that depend on convokit_flutter_ui