flutter_chat_pro 1.0.0
flutter_chat_pro: ^1.0.0 copied to clipboard
Backend-agnostic chat kit for Flutter: typed models, built-in SQLite cache, offline outbox, controllers, and a fully customizable chat room and inbox UI.
flutter_chat_pro #
A complete chat for Flutter that works with any backend. You write one class that talks to your server (Firebase, Supabase, REST, WebSocket, ...). The kit does everything else: the inbox and chat room screens, an offline cache, a send queue, media, voice messages, read receipts, and styling.
Platforms: Android, iOS, Linux, macOS, Web, Windows
Requires: Flutter >=3.44.0
| Inbox and styling | Chat room | Custom messages and offline |
|---|---|---|
![]() |
![]() |
![]() |
Watch the full demo video (73 s)
🎯 Try the live demo in your browser → #
The example app on a fake backend, inside a phone frame: pick a device, switch presets and languages, go offline, turn on incoming messages.
Using an AI agent? Run
npx skills add fodilfliti/flutter_chat_kit, then ask: "Add a chat to this app with flutter_chat_pro." See Let your AI agent build it.
Contents #
- Features
- Before you start: what to install and write
- Quick start: a working chat in 3 steps
- How the pieces fit
- Connect your backend
- Media, sync and push
- Style the chat
- Builders, text and custom messages
- Example app
- Screen size and theme kits
- Troubleshooting
- Let your AI agent build it
Features #
- Direct and group chats: inbox with search, pinning, muting, unread badges, typing and online status; rooms with author names and avatars.
- Several chat lists: "Chats" and "Groups" tabs, labels, or an unread list, each with its own paging and unread count.
- Mix backends: data from a REST API with realtime from Firebase, Supabase or a WebSocket, or REST alone with built-in polling.
- Profiles and businesses: one account chats as several profiles (personal, business pages); staff answer for a business together.
- Offline first: rooms and messages are cached in SQLite. Messages sent offline show at once and are sent when the connection returns, even after a restart.
- Rich messages: text with links, images, video, voice with waveform, files, system messages and your own custom types. Replies, reactions, edit, delete, multi-select, read receipts.
- Media stored locally: each file is downloaded once and kept on disk. Photos are shrunk before upload, videos get a poster, and you can plug in a video compressor.
- Stays in sync: catches up when the app comes back, reconnects dropped live streams, pauses sending on an expired token, and works with APIs that page by number.
- Smooth at any size: the message list never jumps when pages or new messages arrive, and can jump to any old message.
- Easy to style: one
ChatStylewidget with presets (WhatsApp, Telegram, ...), simple options and screen scaling that works with any scale package.
Before you start: what to install and write #
Do these three things once. Everything after this section builds on them.
Requirements: Flutter >=3.44.0 (Dart ^3.12.0).
1. Add the packages #
dependencies:
flutter_chat_pro: ^1.0.0
lemsa_core_kit: ^1.1.0 # the AppFailure errors your ChatSource throws
import 'package:flutter_chat_pro/flutter_chat_pro.dart';
import 'package:lemsa_core_kit/lemsa_core_kit.dart'; // in your ChatSource file
The kit brings its own SQLite cache, media cache, pickers, voice recorder
and players. You do not add a backend SDK to the kit: your app already
has Firebase, Supabase or an HTTP client, and only your ChatSource uses it.
2. Platform setup #
Only the platforms you ship need it.
Android (android/app/src/main/AndroidManifest.xml):
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
iOS (ios/Runner/Info.plist):
<key>NSMicrophoneUsageDescription</key>
<string>Record voice messages.</string>
<key>NSCameraUsageDescription</key>
<string>Take photos and videos to send.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Send photos and videos from your library.</string>
macOS: the same microphone and camera keys in Info.plist, and these
entitlements in both DebugProfile.entitlements and
Release.entitlements: com.apple.security.network.client,
com.apple.security.device.audio-input,
com.apple.security.device.camera,
com.apple.security.files.user-selected.read-write.
Web. The SQLite cache runs in a web worker and needs two files in your
app's web/ folder (sqlite3.wasm and drift_worker.js). One command
downloads the versions that match your pubspec.lock:
dart run flutter_chat_pro:web_setup
Run it from your app folder after flutter pub get, and again after
upgrading drift or sqlite3. Commit the two files, or run the command in CI
before flutter build web.
WebAssembly builds (flutter build web --wasm) work too. There, videos and
voice notes play in the browser's own media elements and images load with
NetworkImage; JavaScript builds keep video_player, audioplayers and
cached_network_image.
Linux: voice playback uses GStreamer
(libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev).
Windows and Linux: video_player has no official desktop
implementation; add one (for example video_player_win or fvp) for
in-app video there.
3. The classes you write #
| Class | Needed? | What it does |
|---|---|---|
ChatSource |
Always | Reads and writes rooms and messages on your backend, and sends live events. See Connect your backend. Until it's ready, use the built-in InMemoryChatSource. |
ChatUploader |
For photos, video, files and voice | Uploads one file to your storage and returns its URL. Without it the attach and mic buttons are hidden. To hide just one of them, see Text only, voice or attachments. |
ChatUserResolver |
Optional | Looks up names and avatars your backend did not send. Not needed when your API returns them with rooms and messages, see Names and avatars. |
class MyUploader implements ChatUploader {
@override
Stream<UploadProgress> upload(Attachment attachment,
{required String roomId, required String localId}) async* {
// Read the file with attachment.openRead() (a stream, best for videos)
// or attachment.readAsBytes(). Both work on web, where File(localPath)
// fails. Upload it to Firebase Storage, Supabase Storage, S3...
yield const UploadRunning(0.5); // optional progress, 0..1
yield UploadDone(remoteUrl: downloadUrl); // always end with this
}
}
class MyUserResolver implements ChatUserResolver {
@override
Future<List<ChatUser>> resolve(Set<String> ids) async {
// One request for many ids; missing ids are simply left out.
final rows = await api.getUsers(ids);
return [for (final row in rows) ChatUser.fromJson(row)];
}
}
Firestore,
Supabase and
REST + WebSocket guides contain a full
ChatSource and ChatUploader for each backend, ready to copy.
Quick start: a working chat in 3 steps #
0. No backend yet? Start on fake data #
InMemoryChatSource is a complete backend kept in memory: sample chats and
people, paging, sending, read receipts, typing and auto replies. Use it to
build and style your screens before writing any server code:
final kit = ChatKit(
currentUserId: 'me',
source: InMemoryChatSource.sample(currentUserId: 'me'),
);
Follow the steps below with it, then replace it with your own ChatSource
(see Connect your backend). Nothing else changes.
1. Create the kit and put it above your app #
ChatKit is the chat engine for the signed-in user. Create it after login
and open it:
final kit = ChatKit(
currentUserId: user.id,
source: MyChatSource(), // your backend, see "Connect your backend"
uploader: MyUploader(), // optional: enables photos, files and voice
users: MyUserResolver(), // optional: names your API did not send
);
await kit.open();
runApp(
MaterialApp(
builder: (context, child) => ChatKitScope(kit: kit, child: child!),
home: const InboxPage(),
),
);
ChatKitScope lets any page find the kit with ChatKitScope.of(context).
2. The inbox page #
A controller belongs to the page that creates it: create it in State,
dispose it in dispose.
class InboxPage extends StatefulWidget {
const InboxPage({super.key});
@override
State<InboxPage> createState() => _InboxPageState();
}
class _InboxPageState extends State<InboxPage> {
late final InboxController inbox = ChatKitScope.of(context).inbox();
@override
void dispose() {
inbox.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Chats')),
body: InboxView(
controller: inbox,
// Tapping a chat opens this page.
roomBuilder: (context, room) => RoomPage(roomId: room.id),
),
);
}
}
3. The room page #
ChatRoomView is a full screen: app bar, messages and message box.
class RoomPage extends StatefulWidget {
const RoomPage({required this.roomId, super.key});
final String roomId;
@override
State<RoomPage> createState() => _RoomPageState();
}
class _RoomPageState extends State<RoomPage> {
late final ChatRoomController room =
ChatKitScope.of(context).room(widget.roomId);
@override
void dispose() {
room.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => ChatRoomView(controller: room);
}
That's a working chat. It already follows your app's colors, fonts and
dark mode. On sign-out call await kit.close() then
await kit.clearUserData(), and create a new ChatKit for the next user.
Text only, voice or attachments #
Start with text only and add media later. Without a ChatUploader the
kit hides the attach and mic buttons. Once you pass an uploader, both
show up. To keep one of them hidden:
ChatRoomView(
controller: room,
enableVoice: false, // no mic button
enableAttachments: false, // no attach button
);
How the pieces fit #
| Piece | What it does |
|---|---|
ChatSource |
The only class you write: talks to your backend. |
ChatKit |
The engine: cache, send queue, media. One per signed-in user. |
InboxController / ChatRoomController |
Data for one list / one room. Made by kit.inbox() / kit.room(id). |
InboxView / ChatRoomView |
The ready-made screens. |
ChatStyle |
Changes how everything below it looks, and its size. |
ChatStrings |
Every text the chat shows, English by default. |
Connect your backend #
You write one class, ChatSource, that turns your backend's data into kit
models. The kit never talks to your server itself.
Pick your path #
| Your backend | Start here |
|---|---|
| Nothing yet, or still in design | InMemoryChatSource (step 0), then build your API on the kit's JSON |
| Firebase (Firestore) | Firestore guide: schema, rules, realtime, Storage uploads |
| Supabase | Supabase guide: tables, RLS, realtime, Storage uploads |
| A new REST API (you choose the JSON) | REST + WebSocket guide with the JSON contract |
| An existing API with its own JSON | Plug in an existing API: keys, camelCase, mappers, polling |
| REST data, realtime elsewhere, or no realtime | Mixing backends: ComposedChatSource, PollingRealtime |
| One account with several profiles | Profiles and business accounts |
The JSON the kit reads #
The smallest message is four fields:
{ "id": "m1", "author_id": "u2", "created_at": "2026-09-30T12:00:00Z", "text": "Hi" }
Decoding forgives what real APIs send:
- Ids can be numbers.
- Dates can be ISO-8601, epoch milliseconds or epoch seconds.
room_idcan be left out: pass it withMessage.fromJson(json, roomId: id).- An attachment can be just a URL (
"attachment": "https://.../a.jpg"). Its type is guessed from the extension, or from the message type. - A room without
updated_attakes the time of its last message. - Members can be plain user ids.
status: "read"is understood, in any case.- Unknown types become a
CustomMessageyou can draw.
Every field, every type, and what you lose without each optional one: Backend JSON.
Your field names, camelCase, or a mapper #
Rename only what differs:
const keys = ChatJsonKeys(
authorId: 'sender_id',
createdAt: 'sent_at',
typeAliases: {'photo': 'image', 'voice': 'audio'},
attachmentKeys: AttachmentJsonKeys(remoteUrl: 'file_url'),
);
final message = Message.fromJson(json, keys: keys, roomId: roomId);
final room = ChatRoom.fromJson(json, keys: keys);
camelCase API? One line:
final message = Message.fromJson(json, keys: ChatJsonKeys.camelCase, roomId: roomId);
Nested JSON ("sender": {"id": 7}, a files array)? Reshape the map, then
let Message.fromJson handle dates, ids and media types:
Message toMessage(Map<String, Object?> m, String roomId) => Message.fromJson({
'id': m['messageId'],
'local_id': m['clientId'],
'author_id': (m['sender']! as Map)['id'],
'created_at': m['sentAt'],
'type': m['kind'] == 'photo' ? 'image' : 'text',
'text': m['body'],
'attachments': [for (final f in m['files'] as List? ?? []) (f as Map)['url']],
}, roomId: roomId);
Then check a saved real response once in a test. ChatJsonCheck names
each field it can't read, or reads with a guess, and the fix:
expect(ChatJsonCheck.message(json, keys: keys, roomId: 'r1'), isEmpty);
The ChatSource #
Mix in ChatSourceDefaults and only seven methods are left; reactions,
pin, mute, typing and jump-to-message become optional:
class MyChatSource with ChatSourceDefaults {
MyChatSource(this.api);
final MyApi api; // your HTTP client, Firebase, Supabase, ...
@override
Future<ChatPage<ChatRoom>> fetchRooms({RoomCursor? after, int limit = 20,
String? search, RoomFilter filter = RoomFilter.all}) async {
final res = await api.get('/rooms', {'after': after?.updatedAt, 'limit': limit, 'q': search});
return ChatPage(
items: [for (final r in res['items']) ChatRoom.fromJson(r)],
hasMore: res['has_more'] == true,
users: [for (final u in res['users'] ?? []) ChatUser.fromJson(u)],
);
}
@override
Future<ChatPage<Message>> fetchMessages(String roomId,
{MessageCursor? before, MessageCursor? after, int limit = 30}) async {
final res = await api.get('/rooms/$roomId/messages', {
'before': before?.createdAt, 'after': after?.createdAt, 'limit': limit,
});
return ChatPage(
items: [for (final m in res['items']) Message.fromJson(m, roomId: roomId)],
hasMore: res['has_more'] == true,
);
}
@override
Future<Message> send(Message pending) async => Message.fromJson(
await api.put('/rooms/${pending.roomId}/messages/${pending.localId}', pending.toJson()),
);
@override
Future<Message> edit(Message message) async => Message.fromJson(
await api.patch('/rooms/${message.roomId}/messages/${message.id}', message.toJson()),
);
@override
Future<void> delete(String roomId, String messageId) =>
api.delete('/rooms/$roomId/messages/$messageId');
@override
Future<void> markRead(String roomId, MessageCursor upTo) =>
api.post('/rooms/$roomId/read', upTo.toJson());
@override
Stream<ChatEvent> events({String? roomId}) => api.socketEvents(roomId);
}
Four rules keep the cache correct:
- Throw
AppFailures fromlemsa_core_kit, never vendor exceptions.NetworkFailureandTimeoutFailuremake the send queue retry. - Message pages are newest first. Cursors are exclusive bounds on
(createdAt, id). sendmust be idempotent onlocalId(use it as the document id or a unique key), so a retry never creates a duplicate. Return the saved message with the samelocal_id.eventsalso delivers your own messages; the kit removes duplicates. No realtime service? UsePollingRealtime(mixing backends).
Call kit.setOnline(online: ...) from your connectivity check: going
online fills gaps, refreshes lists and sends queued messages.
Names and avatars #
Most REST APIs and Supabase queries already return the author's name and avatar with the rooms and messages. Give them to the kit: it saves them in its cache, shows them in the inbox and rooms, and replaces them when they change (a user renames, uploads a new photo, a business changes its logo).
1. Send them with each page (the usual way). Put the people of the page
in ChatPage.users, in fetchRooms, fetchMessages and fetchAround:
// REST: { "messages": [...], "users": [{"id", "name", "avatar_url"}], "has_more": true }
final json = await api.get('/rooms/$roomId/messages', query: {...});
return ChatPage(
items: [for (final m in json['messages']) Message.fromJson(m)],
hasMore: json['has_more'] as bool,
users: [for (final u in json['users']) ChatUser.fromJson(u)],
);
// Supabase: embed the author's profile in the same query.
final rows = await supabase
.from('messages')
.select('*, author:profiles!author_id(id, name, avatar_url)')
.eq('room_id', roomId)
.order('created_at', ascending: false)
.limit(limit + 1);
return ChatPage(
items: [for (final r in rows.take(limit)) Message.fromJson(r)],
hasMore: rows.length > limit,
users: [
for (final r in rows)
if (r['author'] case final Map<String, dynamic> author)
ChatUser.fromJson(author),
],
);
Every time a page loads, the kit compares the users with the saved ones and updates the screen if a name or avatar is different. Duplicates in the list are fine.
2. Push changes as they happen (optional). When your realtime service
says a profile changed, emit UsersChanged from events():
// in events(): a "user.updated" WebSocket message, a Supabase change on profiles, ...
out.add(UsersChanged([ChatUser.fromJson(payload)]));
or call the kit from anywhere in your app, for example right after the user edits their own profile:
await kit.updateUsers([ChatUser(id: me.id, name: newName, avatarUrl: newUrl)]);
3. A resolver for the rest (optional). If some ids still have no name
(for example the peer of a room whose API returns only ids), the kit asks
your ChatUserResolver for them, many ids in one call. It asks again after
ChatConfig.userCacheTtl (12 hours by default) to pick up changes. Users
that came with pages or events count as fresh, so the resolver is not asked
for them.
How it behaves:
- The newest value wins, wherever it came from.
- Open inboxes and rooms update at once; nothing to refresh by hand.
- Names are saved in SQLite, so they show offline and right after a restart.
PollingRealtime(REST without realtime) compares the users of each poll and emitsUsersChangedfor you.ChatUser.metadatakeeps extra fields (role, verified badge, ...) and is compared too.
Several chat lists (tabs) #
Make one controller per list. Each has its own filter, paging, search and unread count, so three tabs work on their own and all stay live:
late final chats = kit.inbox(filter: RoomFilter.direct);
late final groups = kit.inbox(filter: RoomFilter.groups);
late final work = kit.inbox(filter: const RoomFilter(labels: {'work'}));
// Badge on a tab: rebuilds when the unread count changes.
ListenableBuilder(
listenable: chats,
builder: (context, _) => Badge.count(
count: chats.totalUnread,
isLabelVisible: chats.totalUnread > 0,
child: const Text('Chats'),
),
);
Reading a message in any tab updates the others, because they share one
cache. The filter is sent to fetchRooms, and the kit also filters its
cache, so a backend that ignores filters still works.
Mixing backends #
ChatSource is two halves: data (reads and writes) and realtime (events
and typing). Take each half from a different service:
final source = ComposedChatSource(
data: MyRestApi(), // implements ChatDataSource
realtime: SupabaseChatSource(supabase), // or Firestore, a WebSocket, ...
// realtime: PollingRealtime(api), // REST only, no realtime service
);
Profiles and business accounts #
When one account owns several profiles, ChatProfileSwitcher keeps one kit
per profile, each with its own database:
final switcher = ChatProfileSwitcher(
profiles: [
ChatProfile(id: uid, name: 'Ali'),
ChatProfile(id: shopId, name: 'Lemsa Shop',
kind: ChatProfileKind.business, agentId: uid),
],
createKit: (profile) => ChatKit(
currentUserId: profile.id,
agentId: profile.agentId, // the staff member who sends
source: MyChatSource(actingAs: profile.id),
),
);
await switcher.open();
MaterialApp(
builder: (context, child) =>
ChatProfileScope(switcher: switcher, child: child!),
// Put ChatProfileMenuButton() in the inbox app bar to switch profiles.
);
Staff see which colleague sent each message; customers only see the business. See the profiles guide.
Media, sync and push #
Photos and videos #
Photos picked with the built-in picker are scaled so their longest side is
at most 1920 px and saved as JPEG at quality 80; iPhone HEIC photos become
JPEG on the way. A camera shot of several MB usually ends up a few hundred
KB. Change or turn it off in ChatConfig:
const ChatConfig(
imageMaxDimension: 2560, // null sends the original size
imageQuality: 90, // null keeps the original encoding
maxAttachmentBytes: 50 * 1024 * 1024,
);
Files picked with "File" are sent untouched, which is also the way to send
a GIF that must stay animated on every Android version. If you replace the
picker (onAttachmentPick), shrink photos the same way, for example with
image_picker's maxWidth, maxHeight and imageQuality.
Videos picked on Android, iOS and the web get a poster at once: the kit
takes the first frame (480 px) and the bubble shows it while the video
uploads. If your ChatUploader returns no thumbnailUrl, the kit uploads
the poster through it too (local id <localId>_thumb) and sends its URL,
so the other side sees a poster. Desktop shows a placeholder.
Compress videos before upload. Phones record about 100 MB a minute. The kit has no compressor built in (it would add a large native library to every app), but it runs yours. With video_compress:
ChatConfig(
compressVideo: (video) async {
final info = await VideoCompress.compressVideo(
video.path,
quality: VideoQuality.Res1280x720Quality,
);
final path = info?.path;
if (path == null) return video; // keep the original
return XFile(path, mimeType: 'video/mp4');
},
);
The bubble shows "Compressing" meanwhile (ChatStrings.compressing). If
the function throws, the original is sent. maxAttachmentBytes is checked
on the compressed file, so a long video can be picked and still fit.
Staying in sync #
The kit keeps the chat current on its own:
- Back from the background (after
ChatConfig.resumeResyncAfter, 5 s): it reconnects dropped live streams, fetches what open rooms and inbox lists missed, and sends what is queued. Turn it off withresyncOnResume: false. - A live stream that fails or ends is subscribed again with backoff,
and the room fetches what it missed. Errors arrive on
kit.syncErrors. - Opening a room after a long time: the newest page shows first, then the kit walks back until it meets the cache, at most 5 requests even for 1000 new messages. The rest loads as the user scrolls.
- Back online: call
kit.setOnline(online: true)from your connectivity listener, orawait kit.refresh()for pull to refresh. - Expired token: throw
AuthFailure(AuthReason.expired)from your source or uploader. Sending pauses (nothing turns red) andonAuthExpiredis called; refresh, thenkit.retryPending(). See Troubleshooting. - A wrong phone clock: new messages are stamped with
kit.serverNow(), the device clock corrected from your server'screated_at, so they don't sort above replies that arrive meanwhile.
kit.syncErrors.listen((failure) => log('chat sync: $failure'));
Push notifications #
The kit doesn't send pushes, but gives you what a push flow needs:
kit.activeRoomId (skip the notification for the room on screen),
InboxController.totalUnread for the badge, and kit.refresh(). The
push guide covers Firebase Messaging end to end: opening the
room from a tap after a cold start, foreground notifications, and why iOS
needs a visible alert.
APIs that page by number #
If your messages endpoint takes ?page=3 or ?offset=60 instead of a
cursor, PagedMessages adapts it to fetchMessages, dropping the
duplicates that new messages cause:
final paged = PagedMessages(
fetchPage: (roomId, {required page, required size}) =>
api.getMessages(roomId, page: page, size: size), // raw JSON list
decode: (json, roomId) => Message.fromJson(json, roomId: roomId),
);
@override
Future<ChatPage<Message>> fetchMessages(String roomId,
{MessageCursor? before, MessageCursor? after, int limit = 30}) =>
paged.fetch(roomId, before: before, after: after, limit: limit);
Style the chat #
Step 0: do nothing #
With no styling at all, the chat uses your ThemeData: its colors, fonts
and dark mode. Change your app theme and the chat follows.
Step 1: wrap it in ChatStyle #
ChatStyle changes the look of every chat widget below it. Put it
around the inbox; rooms opened from the inbox keep the same style:
ChatStyle(
preset: ChatPreset.whatsApp, // a ready look (optional)
bubbleRadius: 12, // then change what you want
tiles: ChatTiles.divided,
child: InboxView(
controller: inbox,
roomBuilder: (context, room) => RoomPage(roomId: room.id),
),
)
All options are optional. Write sizes as design numbers (the size on your design); scaling is done for you (see Screen size).
| Option | What it changes | Example |
|---|---|---|
preset |
A complete look | ChatPreset.telegram |
seedColor |
Chat colors from one color (light and dark) | Colors.teal |
bubbleRadius |
Bubble corners | 8 |
bubbleShadows |
Soft shadow under bubbles | true |
bubbleBorder |
Thin outline around bubbles | true |
messageStyle |
Message text (merged) | TextStyle(fontSize: 15) |
fontFamily |
Font of every chat text | 'Inter' |
tiles |
Inbox rows: plain, divided or cards | ChatTiles.cards |
tileRadius |
Corners of card rows | 20 |
squareAvatars |
Rounded squares instead of circles | true |
wallpaper |
Behind the messages | BoxDecoration(color: ...) |
customize |
Anything else (see below) | (theme) => ... |
scale |
Size for the current screen | (context) => ChatScale(1.w, text: 1.sp) |
Presets: ChatPreset.classic, whatsApp, whatsAppNew, telegram,
iMessage, messenger, minimal, cards and glass (all in
ChatPreset.values, handy for a settings screen). A preset only gives
defaults: any option you pass wins.
| Preset | Look |
|---|---|
whatsApp |
Classic WhatsApp: green and white bubbles with tails, beige wallpaper, lined rows |
whatsAppNew |
Today's WhatsApp: pill bubbles without tails, frameless photos, a composer floating on the wallpaper |
telegram |
Gradient outgoing bubbles over a gradient background |
iMessage |
Blue and grey bubbles, plain background, outlined input |
messenger |
Blue to pink gradient bubbles, light grey incoming ones |
minimal |
Flat bordered bubbles, grey text, square avatars |
cards |
Round bubbles with shadows, card rows |
glass |
Frosted see-through bubbles and input over an aurora gradient |
Step 2: change anything with customize #
ChatTheme has one style per part of the chat (bubbles, message list,
composer, app bar, avatars, inbox rows, badges, ...). customize receives
the theme after the options and returns your changed copy:
ChatStyle(
preset: ChatPreset.minimal,
customize: (theme) => theme.copyWith(
incomingBubble: theme.incomingBubble.copyWith(color: Colors.white),
outgoingBubble: theme.outgoingBubble.copyWith(
gradient: const LinearGradient(colors: [Colors.indigo, Colors.purple]),
),
),
child: ...,
)
Helpers: theme.mapBubbles((b) => b.copyWith(...)) changes both bubbles,
theme.withMessageText(style) the message text, theme.mapText(...) every
text style.
Where to put it #
-
Only the chat part of your app: wrap the inbox page (as above). The rest of your app is untouched.
-
Every chat in the app: wrap once in
MaterialApp.builder:builder: (context, child) => ChatKitScope( kit: kit, child: ChatStyle(preset: ChatPreset.telegram, child: child!), ), -
One screen different: put another
ChatStyleinside. It changes only what you pass and keeps the rest (and never scales twice):ChatStyle(bubbleRadius: 4, child: SupportRoomPage())
Opening rooms and other pages #
-
InboxView(roomBuilder: ...)opens the room and keeps the style. This is the easy way. -
Your own navigation (go_router, auto_route, ...): use
onRoomTapand wrap the page withChatStyle.carry:onRoomTap: (room) { final keepStyle = ChatStyle.carry(context); Navigator.of(context).push(MaterialPageRoute( builder: (_) => keepStyle(RoomPage(roomId: room.id)), )); }, -
Any other chat page:
ChatStyle.push(context, (context) => MyPage()).
Sheets, dialogs and the media viewer opened from the chat keep the style automatically. Pages opened this way also follow later changes (for example a style settings screen).
Use the chat look in your own widgets #
final chat = context.chatTheme;
Container(
color: chat.incomingBubble.color,
padding: EdgeInsets.all(chat.size(8)), // 8, scaled like the chat
child: Text('Hi', style: TextStyle(fontSize: chat.fontSize(14))),
);
Advanced #
- Register a
ChatThemeinThemeData.extensionsto make it the app default;ChatStylewith no preset starts from it. InboxView(theme: ...)andChatRoomView(theme: ...)style one view only (still scaled by theChatStyleabove it).
See the customization cookbook for many recipes.
Builders, text and custom messages #
ChatRoomView(
controller: room,
strings: const ChatStrings(typeMessage: 'Écrire un message', send: 'Envoyer'),
appBar: ChatAppBarOptions(actions: [callButton]),
builders: ChatBuilders(
bubbleBuilder: (context, m, defaultChild) => defaultChild,
customBuilders: {'offer': (context, m) => OfferCard(message: m)},
messageActions: (context, m, defaults) => [...defaults, reportAction(m)],
),
extraAttachmentOptions: [locationOption],
onForward: (messages) => pickRoomAndForward(messages),
)
- Builders:
ChatBuilders(room) andInboxBuilders(inbox). Each builder receives the default widget, so wrapping is one line. - Custom messages: one builder per type (as many as you want, see
below), and
bubbledCustomTypesto draw them inside the normal bubble. - Text: every string is in
ChatStrings, English by default. Fill it from slang, intl or any localization tool. The example app is translated with slang into French and Arabic (right to left), with a language button; copyexample/lib/i18n/. Dates followMaterialApp.locale. See Text and translation. - Formats:
ChatFormattersfor times, dates, durations and sizes. - Behavior:
ChatConfigfor page sizes, grouping, reactions, auto-download and limits.
Several custom message types #
customBuilders is a map: add one entry per type, as many as you
need. The key is the customType you send, the value draws it:
ChatRoomView(
controller: room,
builders: ChatBuilders(
customBuilders: {
'offer': (context, m) => OfferCard(message: m),
'location': (context, m) => LocationCard(message: m),
'order': (context, m) => OrderCard(message: m),
},
// These are drawn inside the normal bubble; the others stand alone.
bubbledCustomTypes: const {'location', 'order'},
),
// Buttons in the attach sheet that send them.
extraAttachmentOptions: [
AttachmentOption(
icon: Icons.local_offer_outlined,
label: 'Offer',
onSelected: () => room.sendCustom('offer', {'price': 120, 'item': 'Sofa'}),
),
AttachmentOption(
icon: Icons.place_outlined,
label: 'Location',
onSelected: () => room.sendCustom('location', {'lat': 36.75, 'lng': 3.06}),
),
],
)
Inside a builder, m.message is a CustomMessage; read what you sent from
its data:
class LocationCard extends StatelessWidget {
const LocationCard({required this.message, super.key});
final MessageContext message;
@override
Widget build(BuildContext context) {
final data = (message.message as CustomMessage).data;
return ListTile(
leading: const Icon(Icons.place),
title: Text('${data['lat']}, ${data['lng']}'),
subtitle: Text(message.isMine ? 'You shared a location' : 'Location'),
);
}
}
Give each type a one-line text for the inbox and reply previews:
ChatStrings(
customPreview: (type, data) => switch (type) {
'offer' => 'Offer: ${data['item']}',
'location' => 'Location',
'order' => 'Order #${data['id']}',
_ => null, // falls back to strings.unsupportedMessage
},
)
Many types, or types decided at runtime? Use customBuilder as a catch-all
for every type that is not in the map (return null for the "unsupported"
view):
ChatBuilders(
customBuilders: {'offer': (context, m) => OfferCard(message: m)},
customBuilder: (context, m) {
final data = (m.message as CustomMessage).data;
return data['card'] is Map ? GenericCard(data['card']! as Map) : null;
},
)
Example app #
example/ is a complete app on a fake in-memory backend (no
accounts or keys): inbox, direct and group rooms, a 5 000-message room,
custom offer and booking messages, a business profile with staff, an
offline switch, and a live style sheet with every ChatStyle option,
presets, zoom and a flutter_scale_kit switch.
cd example
flutter run
Screen size and theme kits #
This part explains how the chat gets its size on each screen, and how to use it with flutter_scale_kit, flutter_scale_theme_kit or any other package. The chat does not depend on any of them.
The idea: one number for sizes, one for text #
Every size in the chat (paddings, radii, avatars, icons) is a design number, for example "bubble radius 18". To fit a screen, the chat multiplies them all by one factor, and every font by one text factor:
ChatStyle(
scale: (context) => ChatScale(1.2), // everything 20% bigger
// scale: (context) => ChatScale(1.1, text: 1.3), // text grows more
child: ...,
)
scale is a function, not a value: ChatStyle calls it again every time
the screen size changes (window resize, rotation, split screen), so the
chat always has the current size.
With flutter_scale_kit #
import 'package:flutter_scale_kit/flutter_scale_kit.dart';
void main() => runApp(
ScaleKitBuilder(
designWidth: 375,
designHeight: 812,
child: MaterialApp(
builder: (context, child) => ChatKitScope(
kit: kit,
child: ChatStyle(
scale: (context) => ChatScale(1.w, text: 1.sp),
child: child!,
),
),
home: const InboxPage(),
),
),
);
That one line is all. Why it is correct:
1.wis the factor. In flutter_scale_kit,14.wis14 × (width factor), and1.wis that factor. It is a plain multiplication with no rounding, so14.w == 14 × 1.wexactly. The chat does18 × 1.wfor its bubble radius, which is the same as writing18.wyourself.1.spis the text factor.16.sp == 16 × 1.sp, so a 16 message font becomes exactly16.sp, like your ownTextwidgets.- Rotation. flutter_scale_kit swaps the design width and height when
the device turns, and makes landscape phones a bit bigger (its
landscape boost).
ScaleKitBuilderupdates first, thenChatStylerebuilds (it listens to the screen size) and reads the new1.w, so the chat matches your widgets in portrait and landscape. - Height (
.h). The chat does not use.h. Chat content scrolls vertically, so heights come from the content; only widths, paddings, radii and fonts are scaled. Use.hin your own layouts as usual. - Radius. flutter_scale_kit's
.ruses its own formula. The chat scales radii like other sizes, so a chat radius of 18 equals18.w, not18.r.
This is tested, not guessed: example/test/scale_kit_test.dart runs the
real flutter_scale_kit and checks that chat.size(14) == 14.w,
chat.fontSize(14) == 14.sp, bubble radius == 18.w, avatar == 52.w
and message font == 16.sp on a phone, a rotated phone, a small phone and
a tablet, and after rotating and resizing.
Rules to avoid double scaling:
- Give the chat design numbers:
bubbleRadius: 12,messageStyle: TextStyle(fontSize: 15). Not12.wor15.sp: the chat scales them. - Scale in one place. Use
ChatStyle(scale: ...), and don't also call.scaled()on aChatThemeyou register. - Keep your
ThemeDatatext sizes as design sizes. flutter_scale_kit'screateResponsiveTextThemeis safe on a text theme without written font sizes, like Flutter's default and the one flutter_scale_theme_kit builds (tested too). If you wrote already-scaled sizes into your text theme (fontSize: 15.sp), put the chat under aThemewith the unscaled text theme. - Inside chat builders,
8.wandcontext.chatTheme.size(8)give the same number, so use whichever you like.
If you turn ScaleKitBuilder's enabled on and off at runtime, add
ScaleKitScope.watch(context); as the first line of the scale function,
so the chat also rebuilds on that switch.
With flutter_screenutil or another package #
Any package with a "1.w"-style factor works the same way:
ScreenUtilInit(
designSize: const Size(375, 812),
builder: (context, child) => MaterialApp(
builder: (context, child) => ChatStyle(
scale: (context) => ChatScale(1.w, text: 1.sp),
child: child!,
),
home: const InboxPage(),
),
);
Without a package #
ChatStyle(scale: ChatScale.byScreen(), child: ...) // by screen width
ChatStyle(scale: ChatScale.byScreen(designWidth: 390, max: 1.5), child: ...)
ChatStyle(scale: ChatScale.fixed(1.15), child: ...) // always 15% bigger
byScreen uses the screen's shortest side, so the size stays the same when
the phone rotates.
A zoom setting for users multiplies with the screen scale:
scale: (context) => ChatScale(1.w, text: 1.sp) * ChatScale(settings.zoom),
With flutter_scale_theme_kit (colors and tokens) #
flutter_scale_theme_kit builds your ThemeData from design tokens. The
chat reads colors from ThemeData, so it follows your theme and dark
mode with no extra code:
ScaleKitBuilder(
designWidth: 375,
designHeight: 812,
child: STThemeModeScope(
builder: (context, mode) => MaterialApp(
theme: appST.light, // appST is your STTheme
darkTheme: appST.dark,
themeMode: mode.mode,
builder: (context, child) => ChatKitScope(
kit: kit,
child: ChatStyle(
scale: (context) => ChatScale(1.w, text: 1.sp),
child: child!,
),
),
home: const InboxPage(),
),
),
);
To use your tokens for exact chat parts, read them with context.st and
pass them to ChatStyle. Token radii are design numbers, exactly what the
chat wants:
class ChatArea extends StatelessWidget {
const ChatArea({required this.child, super.key});
final Widget child;
@override
Widget build(BuildContext context) {
final st = context.st; // light or dark, follows the mode
return ChatStyle(
bubbleRadius: st.radius.lgValue,
tiles: ChatTiles.divided,
customize: (chat) => chat.copyWith(
outgoingBubble: chat.outgoingBubble.copyWith(color: st.primary),
incomingBubble: chat.incomingBubble.copyWith(
color: st.surface,
border: BorderSide(color: st.border),
),
),
scale: (context) => ChatScale(1.w, text: 1.sp),
child: child,
);
}
}
If you register a ChatTheme in the theme instead, keep the theme kit's
extensions:
appST.light.copyWith(extensions: [...appST.light.extensions.values, myChatTheme]).
Troubleshooting #
Most problems come from the JSON. Run ChatJsonCheck on a real response
first (how): it names
the field and the fix.
Images or videos show as file cards.
The attachment has no mime_type and its URL has no extension, inside a
file message (or a type the kit maps to file). Send mime_type, or use
the image / video message type: an extension-less URL in an image
message is shown as an image.
Times are off by a few hours.
Your dates have no time zone (2026-09-30T12:00:00), so each phone reads
them in its own zone. End them with Z or an offset (+01:00).
Dates show in 1970, or thousands of years ahead.
The value is not epoch seconds or milliseconds (both work): it's 0, a
duration, microseconds or nanoseconds. Send milliseconds or ISO-8601.
My message appears twice after sending.
The server created a second message on a retry, or doesn't return
local_id. Make send idempotent on localId and echo local_id in the
response and in realtime events
(Sending).
A room doesn't move to the top on a new message.
The inbox sorts by updated_at. Bump it on every new message, and emit
RoomChanged(Updated(room)) from events() (the inbox stream, roomId == null), or use PollingRealtime.
Nothing updates live.
events() returns an empty stream or never emits for this room. Emit
MessageChanged for room streams and RoomChanged for the inbox, or
plug in PollingRealtime. After being offline, call
kit.setOnline(online: true).
FormatException: Message "...": missing "room_id".
Your messages endpoint leaves the room out. Pass it:
Message.fromJson(json, roomId: roomId). Other "missing" errors name the
ChatJsonKeys field to set.
Names are empty and avatars are blank.
The kit has no ChatUser for those ids. Return users with your pages,
or pass a ChatUserResolver (Names and avatars).
Avatars and images are blank on the web only.
The image host doesn't send CORS headers, so the browser blocks the
pixels. Serve them with Access-Control-Allow-Origin (Firebase Storage
needs a CORS config on the bucket), or through your own domain.
The chat stays empty or loading on the web.
sqlite3.wasm or drift_worker.js is missing from web/. Run
dart run flutter_chat_pro:web_setup.
No attach or mic button.
ChatKit has no uploader. Pass a ChatUploader
(The classes you write).
After the login token expires, every queued message turns red.
Your source throws something other than AuthFailure on a 401. Throw
AuthFailure(AuthReason.expired): the kit then pauses sending (messages
stay "sending") and calls onAuthExpired. Refresh the session there and
resume:
late final ChatKit kit;
kit = ChatKit(
currentUserId: uid,
source: source,
onAuthExpired: () async {
await auth.refreshSession();
await kit.retryPending();
},
);
Retrying a photo on the web says the file is no longer available. It was picked before the page reloaded; browsers forget picked files on reload. Delete the message and pick the file again.
Let your AI agent build it #
This package ships an Agent Skill that teaches
your AI agent (Cursor, Claude Code, GitHub Copilot, Codex, Windsurf, Gemini
CLI, ...) how to use the kit correctly: the ChatSource rules, controller
lifetimes, ChatStyle, scaling without double scaling, and the backend
guides.
npx skills add fodilfliti/flutter_chat_kit
Or install the whole Lemsa family (chat, scale kit, theme kit):
npx skills add fodilfliti/lemsa-skills
Then ask your agent, for example:
- "Add a chat to this app with flutter_chat_pro. Backend: Firestore. Inbox with Chats and Groups tabs, WhatsApp style."
- "Write a ChatSource for our REST API (docs in
api.md) with realtime from our WebSocket atwss://...." - "Make the chat follow flutter_scale_kit and our theme kit tokens."
- "Add a custom 'order' message with a card and a Pay button."



