convokit_flutter 0.7.0
convokit_flutter: ^0.7.0 copied to clipboard
Flutter SDK for ConvoKit - real-time messaging integration.
ConvoKit Flutter SDK #
Flutter SDK for adding ConvoKit conversations, messages, realtime events, and media uploads to a Flutter app.
The SDK talks to your ConvoKit backend over REST and uses Supabase Realtime for live messaging events. Your ConvoKit client secret should stay on your server; mobile and web clients should only receive short-lived user tokens from your own token endpoint.
Features #
Mark unread (0.7.0) #
ConvoKit.markConversationUnread(id) sets a private "mark unread" marker on
the caller's own membership and clearConversationUnread(id, ifVersion: v)
removes it, both returning the caller's private state (unreadMarkedAt,
privateStateVersion). Inbox entries gain isUnread, unreadMarkedAt and
privateStateVersion; a marked room is isUnread with its real unreadCount
(render a numberless dot when the count is 0). getConversation exposes the
caller's own row as Conversation.membership; capture its
privateStateVersion when a room opens and pass it to
markConversationRead(id, throughMessageId:, privateStateVersion:) so only
acknowledgements of that open clear the marker. See Mark unread.
Other members, participant DTOs, webhooks and read events never carry the
marker; the caller's other devices refetch on onInboxActivity. This requires
the coordinated 0.7 backend release: an older backend answers the /unread
routes with 404, sends no membership sibling (the field is null) and no new
inbox fields (isUnread is derived, privateStateVersion is 0).
Inbox previews and unread counts (0.6.0) #
ConvoKit.listInbox() returns the caller's rooms ordered by activity with the
latest message preview, an accurate unread count and the caller's own read
state per entry, paged by an opaque cursor (default 30, at most 100 per page).
ConvoKit.realtime.onInboxActivity(appId) signals message sends, edits and
read-position advances anywhere in the app so an inbox screen can refetch
previews and counts; onInboxChanged keeps its structural semantics. See
Inbox. This requires the coordinated 0.6 backend release: an older
backend answers GET /api/v1/inbox with 404 (ConvoKitNotFoundException) and
never broadcasts inbox_activity. getConversations is unchanged.
Precise read positions (0.5.0) #
ConvoKit.markConversationRead(id, throughMessageId: messageId) acknowledges a
concrete message instead of "everything the server has now", so a delayed
request can no longer mark a message that arrived after the screen rendered as
read. The backend stores the message's (createdAt, id) cursor as a
ReadPosition that only advances; Participant.readPosition and
ReadEvent.readPosition expose it beside the acknowledgement time, and the
top-level readThrough(...) helper applies the same rule as every other
ConvoKit client. See Read receipts. This requires the
coordinated backend release: an older backend ignores the target and returns no
positions, and both fields stay null. ConvoKitException.code carries the
backend's machine-readable error code when its body includes one.
In development: live inbox and send correlation #
These additions require the upcoming coordinated backend/SDK/UI release; they are not included in published 0.3.x packages yet.
ConvoKit.realtime.onInboxChanged(appId) emits an empty signal on room,
membership, profile and cascade changes, and on every verified join/rejoin.
Use the connected app ID from ConvoKit.lastTokenClaims['app_id'] and refetch
authorized conversations in a custom UI. The matching UI package handles this
automatically. This replaces the unimplemented onConversationUpdate() stub.
Every send carries a UUID clientMessageId, returned on Message through REST,
history and Realtime. The SDK generates it when omitted. Custom optimistic UIs
can call createClientMessageId() before showing the pending message and pass
it to ConvoKit.sendMessage(clientMessageId: id, ...). Match by ID, room and
sender, never by similar text or attachments. Reuse the ID only to retry the
same send; identical retries return the existing message, conflicts return 409.
Published baseline #
- Configure a ConvoKit app client and connect an app user.
- Create, fetch, archive, unarchive, and leave conversations.
- Send and paginate messages with text, image, and file media; fetch a single message by id.
- Look up app user profiles, including their last-seen snapshot.
- Subscribe to realtime message (insert/update), typing, read receipt, and presence events.
- Upload user avatars, conversation images, and message attachments through short-lived R2 upload URLs, then verify each object with the backend.
- Session auto-refresh: the SDK refreshes tokens proactively ahead of expiry and reactively on a 401, transparently to callers.
- Typed errors (
ConvoKitAuthException,ConvoKitNotFoundException, etc.) so callers can distinguish transient failures from permanent ones. - Use typed Dart models for
Conversation,Message,Participant, andAppUser.
Installation #
Version 0.3 requires Flutter 3.19 or later (Dart 3.3+) and
supabase_flutter >=2.14.0 <3.0.0. Earlier pre-release versions are unsupported
after the coordinated backend cutover. The SDK discovers
the Supabase URL and publishable key automatically. Customers configure neither.
Add the package to your Flutter app:
flutter pub add convokit_flutter
Then import it:
import 'package:convokit_flutter/convokit_flutter.dart';
Setup #
Configure ConvoKit once when your app starts. The tokenProvider must call your
own backend and return a ConvoKit JWT for the current app user. Do not embed
your ConvoKit client secret in a Flutter app.
import 'dart:convert';
import 'package:convokit_flutter/convokit_flutter.dart';
import 'package:http/http.dart' as http;
Future<void> bootstrapConvoKit() async {
ConvoKit.configure(
clientId: 'your-convokit-client-id',
tokenProvider: (appUserId) async {
final response = await http.post(
Uri.parse('https://your-api.example.com/convokit/token'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'appUserId': appUserId}),
);
if (response.statusCode != 200) {
throw Exception('Failed to fetch ConvoKit token');
}
final body = jsonDecode(response.body) as Map<String, dynamic>;
return body['token'] as String;
},
);
}
ConvoKit uses the managed https://api.convokit.app endpoint automatically.
For local development, testing, or a self-hosted deployment only, pass
backendUrl to ConvoKit.configure; ConvoKit.defaultBackendUrl exposes the
managed default.
Call connectUser after your app has identified the current user:
await ConvoKit.connectUser('app-user-123');
When the user signs out, disconnect realtime subscriptions:
await ConvoKit.disconnectUser();
ConvoKit owns a dedicated Supabase client; a host app may use its own
Supabase.instance independently. Switching users or reconfiguring retires the
previous session, closes its streams and rejects pending operations. Capture a
new ConvoKit.realtime after connecting the replacement user.
Renewal uses the earlier expiration of the REST and Realtime JWTs, starts before expiry, and is shared across concurrent requests. Temporary renewal failures use bounded backoff; an independent deadline closes an expired session even if your token provider hangs. The provider must issue fresh, extended credentials.
Conversations #
final conversations = await ConvoKit.getConversations(limit: 20);
final conversation = await ConvoKit.createConversation(
participants: ['app-user-123', 'app-user-456'],
title: 'Product support',
);
await ConvoKit.updateConversationTitle(
conversationId: conversation.id,
title: 'Billing support',
);
await ConvoKit.archiveConversation(conversation.id);
await ConvoKit.unarchiveConversation(conversation.id);
await ConvoKit.leaveConversation(conversation.id);
getConversations lists rooms in creation order with offset paging and is
unchanged. Inbox screens should use listInbox below.
Inbox #
listInbox returns one InboxEntry per living membership, newest activity
first, with the latest message preview and the caller's unread count:
final page = await ConvoKit.listInbox(limit: 30);
for (final entry in page.entries) {
final Conversation conversation = entry.conversation;
final Message? preview = entry.latestMessage; // null for an empty room
final int unread = entry.unreadCount;
final bool capped = entry.unreadCountCapped; // true: render "99+"
final bool isUnread = entry.isUnread; // count, cap or a private marker
final DateTime? marked = entry.unreadMarkedAt; // the caller's own marker
final int version = entry.privateStateVersion; // see "Mark unread"
final ReadPosition? mine = entry.readPosition; // the caller's own position
final DateTime activityAt = entry.activityAt; // the ordering key
}
// Next page: pass the cursor unchanged; null means this was the last page.
if (page.nextCursor != null) {
final more = await ConvoKit.listInbox(cursor: page.nextCursor);
}
// Archived memberships instead of active ones.
final archived = await ConvoKit.listInbox(archived: true);
Each entry is an InboxEntry with conversation and an InboxSummary
(entry.summary: latestMessage, unreadCount, unreadCountCapped,
readPosition, lastReadAt, isUnread, unreadMarkedAt,
privateStateVersion, activityAt); the summary fields are also available
directly on the entry. conversation has the getConversations
shape, except that participants is bounded to at most ten members (the
caller, the latest sender when still a member, then active members by id);
fetch getConversation(id) for the full roster. latestMessage has the
getMessages row shape with at most the first four media items.
Ordering: activityAt is the newest surviving message's creation time, or the
room's creation time for an empty room, with ties broken by conversation id
descending. Editing a message changes the preview but never moves the room;
deleting the newest message recalculates both. Server order is authoritative
within a page; when merging pages after live updates, dedupe by conversation
id and let the later entry win.
Unread counts follow the same readThrough rule as read receipts: messages
from other participants after the caller's read position (or after
lastReadAt for a membership without a position; everything from others for a
fresh membership). Own messages never count. The backend counts over a bounded
window: when more than 1,000 messages follow the position, unreadCount is a
lower bound over the first 1,000 and unreadCountCapped is true.
isUnread is unreadCount > 0 || unreadCountCapped || unreadMarkedAt != null:
the count plus the caller's own mark unread marker. Render the
numeric badge (or "99+") when the count is positive or capped, and a numberless
dot (accessible name "Unread") when isUnread is true with a zero count; never
invent a number for a marker. A 0.6 backend sends none of the three fields:
isUnread is then derived from the count, unreadMarkedAt is null and
privateStateVersion is 0. An InboxSummary built locally derives isUnread
the same way unless it is passed explicitly.
Paging: limit must be an integer in 1..100 (default 30); any other value
throws ArgumentError before a request is sent. cursor is opaque; pass a
previous page's nextCursor unchanged. It stays valid after its room moves or
is deleted. A malformed cursor fails with ConvoKitValidationException
carrying code == 'INVALID_CURSOR'; other rejected parameters carry
INVALID_ARGUMENT. Restart from the head (no cursor) on INVALID_CURSOR, do
not evict the session. getConversations and its offset paging are unchanged.
Live updates: subscribe to both app-hub streams. onInboxChanged still fires
for room, membership, profile, deletion and cascade changes and on every
verified join/rejoin; onInboxActivity fires after a message insert or edit,
after any member's read-position advance and after the caller's own unread
marker changes (a mark, a clear, or an acknowledgement that clears it), and is
never synthesised on a join. Refetch listInbox from the head on either
signal, throttling activity signals in busy apps:
final appId = ConvoKit.lastTokenClaims['app_id'] as String;
final structuralSub =
ConvoKit.realtime.onInboxChanged(appId).listen((_) => reload());
final activitySub =
ConvoKit.realtime.onInboxActivity(appId).listen((_) => reload());
This requires the coordinated 0.6 backend release.
Messages #
final message = await ConvoKit.sendMessage(
conversationId: conversation.id,
text: 'Hey, can you take a look?',
);
final messages = await ConvoKit.getMessages(
conversationId: conversation.id,
limit: 50,
);
final single = await ConvoKit.getMessage(message.id);
For older-history paging in 0.3+, pass beforeCreatedAt: messages.last.createdAt
and beforeId: messages.last.id together (only when the page is nonempty).
Keep offset at zero. The backend orders by timestamp then message ID descending;
the cursor still works if its original row has been deleted. This requires the
coordinated 0.3 backend cursor release.
To send media, upload the bytes first and pass the returned URL in the message payload:
final url = await ConvoKit.uploadMessageMedia(
bytes: imageBytes,
fileName: 'screenshot.png',
conversationId: conversation.id,
);
await ConvoKit.sendMessage(
conversationId: conversation.id,
text: 'Screenshot attached',
media: [
{
'type': 'image',
'url': url,
'name': 'screenshot.png',
},
],
);
The SDK completes each successful R2 upload with the ConvoKit backend before returning its media URL. This lets the backend verify the stored object and record its authoritative size before it is attached to a message.
Users #
final user = await ConvoKit.getUser('app-user-456');
// user.name, user.imageUrl, user.lastSeenAt
final users = await ConvoKit.getUsers(limit: 50);
getUser/getUsers return AppUser — named to avoid colliding with
supabase_flutter's own User type.
Realtime #
Subscribe through ConvoKit.realtime after connectUser completes.
final messageSub = ConvoKit.realtime
.onMessage(conversation.id)
.listen((event) {
switch (event.type) {
case MessageChangeType.insert:
// Render the new message: event.message
break;
case MessageChangeType.update:
// Replace the edited message in place: event.message
break;
}
});
// Deletion events contain IDs only, not a full old Message.
final deletionSub = ConvoKit.realtime
.onMessageDeleted(conversation.id)
.listen((event) {
// Remove event.id from event.conversationId in your local message cache.
// Retain a deletion marker so an older HTTP/Realtime row cannot restore it.
});
final typingSub = ConvoKit.realtime
.onTyping(conversation.id)
.listen((event) {
// event.userId and event.isTyping
});
final readSub = ConvoKit.realtime
.onReadReceipt(conversation.id)
.listen((event) {
// event.userId, event.readAt and event.readPosition (null from a 0.4 backend)
});
Send typing indicators and read receipts with the REST helpers:
await ConvoKit.sendTyping(
conversationId: conversation.id,
isTyping: true,
);
await ConvoKit.markConversationRead(
conversation.id,
throughMessageId: newestRenderedMessage.id,
);
Presence events are scoped to the app ID in the issued JWT. Read the scope from the connected user's claims (the managed API currently also uses it as the client ID):
final presenceSub = ConvoKit.realtime
.onPresence(ConvoKit.lastTokenClaims['app_id'] as String)
.listen((event) {
// event.userId, event.isOnline, event.lastSeenAt
});
await ConvoKit.updatePresence(isOnline: true);
Cancel stream subscriptions from your widget or state manager when they are no longer needed.
Typing, read-receipt, and deletion listeners share one private room channel. Cancelling one
does not replace or disconnect the other; cancelling the last listener releases
that channel. Presence, onInboxChanged and onInboxActivity share the private
app hub the same way. All SDK channels are private and authenticated before subscription.
Observe ConvoKit.errors for background session failures and
ConvoKit.realtime.errors for sanitized Realtime failures. Attach an onError
handler to event subscriptions too. ConvoKitSessionException.code distinguishes
session changes, expiry and connection errors without exposing provider responses.
ConvoKit.realtime.connectionEvents emits a topic and
RealtimeConnectionStatus.subscribed, interrupted, or closed. A subsequent
subscribed event lets your UI refetch REST history after a reconnect; Realtime
does not replay missed messages. A join acknowledgement is not a delivery receipt
or proof that asynchronous replication setup succeeded—continue observing errors.
Postgres Changes does not apply SELECT RLS to removed rows. This release does not
subscribe to raw DELETE events or assume a full deleted Message is available.
The onMessageDeleted() stream decodes the backend's private
message_deleted notification as MessageDeletedEvent(id, conversationId).
It validates that the payload belongs to the subscribed room. Replace the old
MessageChangeType.delete branch with this separate stream; the enum now has
only insert and update. This requires the coordinated backend/SDK release.
Notifications are best-effort, do not replay, and currently cover explicit
administrator message deletion, not user/room/app cascades. Refetch REST history
on rejoin to reconcile missed deletions. Published 0.3.x requires refresh/reopen
to discover cascade changes and new rooms; the upcoming onInboxChanged()
contract described above removes that limitation.
Private room/app topics are discovered and rotated automatically on membership
loss. Remaining listeners rejoin without changing customer configuration; removed
users cannot receive subsequent activity on a retired topic. Events already sent
while access was valid may still be in flight.
See Postgres Changes limitations.
Read receipts #
Acknowledge the newest message the user has actually seen. Order candidates by
(createdAt, id), not by list index, and never target a pending (unsent) row:
await ConvoKit.markConversationRead(
conversation.id,
throughMessageId: newestRenderedMessage.id,
);
The backend resolves that message's (createdAt, id) position and stores it
only when it is newer than the stored one, so an older target after a newer one
is a harmless no-op and positions never move backwards for a living membership
(leaving or being removed discards it). Calling without throughMessageId
keeps the legacy behaviour: the backend targets its newest message at request
time, which can race with messages that arrived after the screen rendered.
lastReadAt still records the acknowledgement time on every accepted call.
Pass the privateStateVersion captured when the room opened so the
acknowledgement also clears the caller's own unread marker; see
Mark unread.
A targeted call whose message no longer exists, or belongs to another room or
app, throws ConvoKitNotFoundException with code == 'MESSAGE_NOT_FOUND';
re-resolve the newest rendered message excluding that id and retry once. A
membership failure is also a 404 but carries no code. A blank target throws
ConvoKitValidationException before any request is sent.
Compute receipts with the shared rule instead of comparing timestamps:
final participant = conversation.participants
.firstWhere((p) => p.appUserId == readerId);
final seen = readThrough(
message,
readPosition: participant.readPosition,
lastReadAt: participant.lastReadAt,
);
// Live updates: a `read` event is broadcast only when the position advanced.
ConvoKit.realtime.onReadReceipt(conversation.id).listen((event) {
final seenNow = readThrough(
message,
readPosition: event.readPosition,
lastReadAt: event.readAt,
);
});
readThrough prefers the position (ReadPosition.covers(message): a later
createdAt, or an equal createdAt and an id that compares greater or equal by
code units) and falls back to lastReadAt >= message.createdAt only when no
position exists yet. Positions can be null while lastReadAt is set: an
acknowledgement in an empty room, a membership recorded before the backend
migration, or a 0.4 backend. Never fabricate the local user's own position from
the device clock; take it from participant responses and read events.
Mixed fleets: precise receipts need both the sender and the reader on 0.5. Readers on 0.4 keep timestamp semantics and still parse the additive payloads.
Mark unread #
A user can mark a room unread for themselves only. The marker lives on the
caller's own membership row: other members, participant DTOs, the
conversation.updated webhook and read events never carry it.
// Mark: sets the marker and bumps the private state version.
final ConversationPrivateState state =
await ConvoKit.markConversationUnread(conversation.id);
// state.unreadMarkedAt (non-null), state.privateStateVersion (>= 1)
// Clear without acknowledging a message, only while the version still matches.
final ClearUnreadResult result = await ConvoKit.clearConversationUnread(
conversation.id,
ifVersion: state.privateStateVersion,
);
// result.cleared is false when nothing was marked or the version moved on;
// result.unreadMarkedAt and result.privateStateVersion are the current state.
markConversationUnread bumps privateStateVersion on every call, also on a
repeat mark, so an acknowledgement that captured the older version can no
longer clear the marker: the user's latest intention wins. unreadCount is not
inflated; the room's inbox entry becomes isUnread and a zero-count room shows
a dot rather than a number. Any living membership may mark, including a
read-only role; a membership failure is a 404 without a code.
Capture at open: getConversation(id) returns the caller's own row as
conversation.membership (role, lastReadAt, readPosition,
unreadMarkedAt, privateStateVersion). Read membership?.privateStateVersion
the first time a room screen loads its conversation and send that captured
value with every acknowledgement of that open:
final conversation = await ConvoKit.getConversation(id);
final int? captured = conversation.membership?.privateStateVersion;
await ConvoKit.markConversationRead(
id,
throughMessageId: newestRenderedMessage.id,
privateStateVersion: captured, // omitted from the body when null
);
The backend clears the marker only when the sent version equals the current
one; a mismatch (a repeat mark, or a mark from another device after the
capture) advances the read position as usual and keeps the marker. A call
without privateStateVersion (the legacy body) never clears it. Keep the value
captured at open across later refreshes of the same screen; do not re-read it
from a refreshed conversation, or a mark made while the room was open would be
cleared by an acknowledgement that predates it. membership is null against a
0.6 backend: send no version then.
Empty rooms: there is no message to acknowledge, so a room opened with
membership.unreadMarkedAt set and nothing to render should call
clearConversationUnread(id, ifVersion: captured) once instead. A version-only
markConversationRead(id, privateStateVersion: captured) also clears the marker
in an empty room; in a non-empty one it still clears the marker but also acknowledges through the newest stored message, like the legacy form.
Lists: apply a mark or clear response to a cached entry only when
response.privateStateVersion >= entry.privateStateVersion (replace
unreadMarkedAt and privateStateVersion together and recompute isUnread
from the stored counts and the new marker); ignore a lower version, so a delayed
response cannot resurrect a marker a newer action removed. Other devices learn
of marker changes through onInboxActivity and refetch listInbox.
Validation: privateStateVersion and ifVersion must be integers in
0..2147483647; any other value throws ArgumentError before a request is sent.
A version the backend rejects surfaces as ConvoKitValidationException with
code == 'INVALID_ARGUMENT'. Leaving a room keeps the row and its marker;
rejoining clears the marker and bumps the version; removal deletes the row.
The /unread routes require the coordinated 0.7 backend release (an older
backend answers 404).
Media Helpers #
final avatarUrl = await ConvoKit.uploadUserAvatar(
userId: ConvoKit.currentUserId,
imageBytes: avatarBytes,
fileName: 'avatar.png',
);
final conversationImageUrl = await ConvoKit.uploadConversationImage(
conversationId: conversation.id,
imageBytes: imageBytes,
fileName: 'group.png',
);
await ConvoKit.deleteUserAvatar(userId: ConvoKit.currentUserId);
await ConvoKit.deleteConversationImage(conversationId: conversation.id);
Error Handling #
REST helpers throw ConvoKitException when the backend returns a non-success
response. statusCode carries the HTTP status and code the backend's
machine-readable error code when the JSON body includes one (for example
MESSAGE_NOT_FOUND); it is null otherwise.
Only an explicit HTTP 401 can refresh and retry a REST request once. HTTP 403, 5xx and network failures do not automatically replay writes: a lost response may follow a successful mutation. Storage PUTs never receive ConvoKit credentials and are not retried automatically. An upload interrupted by logout may leave bytes at storage, but cannot complete or attach them under a replacement user.
try {
await ConvoKit.sendMessage(
conversationId: conversation.id,
text: 'Hello',
);
} on ConvoKitException catch (error) {
// Show a retry state or log error.message.
}
Testing #
Offline SDK regression and acceptance-helper tests do not contact a backend:
flutter test --coverage test/convokit_flutter_test.dart test/live_security_checks_test.dart test/client_session_test.dart test/session_http_test.dart test/realtime_protocol_test.dart
flutter analyze
CI runs these checks on Flutter 3.19.6 at the Supabase Flutter 2.14.0 floor
(flutter pub upgrade, then flutter pub downgrade supabase_flutter supabase realtime_client) and
Flutter 3.38.5 with the newest compatible dependencies (flutter pub upgrade).
This checks the Supabase/Realtime provider floors too; other transitive packages
use compatible resolutions, not every historical minimum. The publishing
workflow requires the same checks. Neither CI job uses
live credentials or counts as staging acceptance.
The separate live smoke suite requires the matching security-cutover backend and Supabase configuration, an existing staging app user with active room membership, and an existing message that user may read. Store this JSON outside the repository, replacing the placeholders with staging-only values:
{
"BACKEND_URL": "https://staging-api.example.invalid",
"CLIENT_ID": "STAGING_CLIENT_ID",
"CLIENT_SECRET": "STAGING_CLIENT_SECRET",
"APP_USER_ID": "EXISTING_ACTIVE_MEMBER",
"MESSAGE_ID": "EXISTING_READABLE_MESSAGE"
}
flutter test test/integration_test.dart \
--dart-define-from-file=/absolute/private/convokit-staging.json
Missing values fail before connecting; there is no implicit production endpoint,
fallback user, or silent skip. A plain flutter test includes the live file and
therefore also requires this configuration. This runner simulates a trusted
server's token exchange: never embed its client secret or define file in a
customer Flutter app. It does not create/delete fixture records or broadcast
messages, but authentication and reads can produce backend usage records.
The live suite checks generation/app-bound tokens, an exact REST-verified Message row through PostgREST, explicit PostgreSQL permission denials for 11 sensitive tables (including Conversation), and private app/room/message channel joins. Supabase URL and publishable key are discovered by the SDK; they are not test configuration or customer setup parameters. Private channel authorization and row visibility are separate controls. See Realtime authorization.
A successful join is not evidence of event delivery. Release acceptance still requires the two-app/two-member/non-member/departed-member matrix, direct-write denial, messages/typing/receipts/presence/files, reconnects, token expiry and already-joined access revocation. Run actual event assertions against staging; offline tests and this read-only smoke suite do not replace that matrix. In particular, verify DELETE behavior separately under the deployed RLS configuration; see Postgres Changes limitations.
Publishing #
Publishing to pub.dev is automated through GitHub Actions when a version tag is pushed, after both compatibility test jobs pass. A tag must match the package version; never reuse an already published version for changed source.
Before the workflow can publish, the package must already exist on pub.dev. If
this is the first release, publish it manually once with dart pub publish or
flutter pub publish.
Enable automated publishing from the pub.dev package admin page with:
- Repository:
ConvoKitApp/ConvoKit-Flutter-SDK - Tag pattern:
v{{version}} - GitHub Actions environment:
pub.dev
To publish a new version:
- Update
versioninpubspec.yaml. - Update
CHANGELOG.md. - Commit and push the changes.
- Push a matching version tag:
git tag v<new-version>
git push origin v<new-version>
License #
ConvoKit Flutter SDK is licensed under the Apache License, Version 2.0. See
LICENSE for details.