flutter_chat_sdk
A backend-agnostic Flutter chat SDK with offline-first support. Bring your own backend — implement one interface and everything else works.
Why this package?
Most Flutter chat packages are either tied to a specific vendor (Firebase, Stream, Sendbird) or provide only UI with no offline logic.
This package gives you:
- Bring your own backend — implement
ChatAdapterto connect any REST API, WebSocket server, GraphQL subscription, or MQTT broker - Offline-first message queue — messages are stored locally and sent when online; the queue survives app restarts and retries with exponential backoff
- Zero vendor lock-in — the domain model has no dependency on any backend service
Features
ChatAdapterinterface — one contract, any backendOutboundQueue— SQLite-persisted queue with exponential backoff retry and crash recoverySyncEngine— initial, incremental, and per-conversation syncChatDatabase— Drift/SQLite with optional SQLCipher encryptionChatIdentityProvider— initialize before user login for instant startup- Flutter widgets —
ChatProvider,ConversationsBuilder,MessagesBuilder,ConnectionStateBuilder - Testing utilities —
MockChatAdapterandStaticIdentityProviderinpackage:flutter_chat_sdk/testing.dart
Quick Start
No backend needed. This example runs entirely in memory using MockChatAdapter.
// pubspec.yaml
dependencies:
flutter_chat_sdk: ^0.2.1
// main.dart
import 'package:flutter_chat_sdk/flutter_chat_sdk.dart';
import 'package:flutter_chat_sdk/testing.dart';
Future<void> main() async {
final chat = await Chat.create(
databasePath: 'chat.db',
adapter: MockChatAdapter(),
identityProvider: const StaticIdentityProvider('user-1'),
);
await chat.connect();
chat.watchConversations().listen(print);
final conversation = await chat.createConversation(
mode: ConversationMode.standard,
name: 'General',
);
await chat.sendMessage(
conversationId: conversation.id,
content: 'Hello!',
);
await chat.dispose();
}
Installation
Add to your pubspec.yaml:
dependencies:
flutter_chat_sdk: ^0.2.1
For database encryption (optional):
dependencies:
sqlcipher_flutter_libs: ^0.6.8
Run:
flutter pub get
Usage
Implementing a ChatAdapter
ChatAdapter is the single interface between the SDK and your backend. Implementing it is the only thing needed to connect any backend.
import 'package:flutter_chat_sdk/flutter_chat_sdk.dart';
class MyBackendAdapter implements ChatAdapter {
@override
String get name => 'MyBackend';
@override
bool get isConnected => _connected;
@override
Stream<ChatConnectionState> get connectionState => _connectionStateController.stream;
@override
Stream<ChatEvent> get eventStream => _eventController.stream;
@override
Future<void> initialize() async {
// Connect to your backend, set up auth headers, etc.
}
@override
Future<void> connect() async {
// Establish WebSocket connection or start polling.
}
@override
Future<SyncResult> initialSync() async {
// Fetch rooms + messages from your API.
return SyncResult(conversations: [...], messages: [...]);
}
@override
Future<Message> sendMessage(SendMessageParams params) async {
// POST to your API.
}
// ...implement remaining ChatRepository methods
}
HttpChatAdapter and SocketChatAdapter are optional base classes that provide sensible no-ops for the transport they don't handle. Extend HttpChatAdapter for REST-only backends or SocketChatAdapter for WebSocket-only backends:
class MyHttpAdapter extends HttpChatAdapter {
MyHttpAdapter(this._api);
final MyApiClient _api;
@override
String get name => 'MyHttpAdapter';
@override
Future<void> initialize() async {
// Set up auth headers, base URLs, etc.
}
@override
Future<SyncResult> initialSync() async {
final response = await _api.getInitialSync();
return SyncResult(
conversations: response.conversations.map(Conversation.fromJson).toList(),
messages: response.messages.map(Message.fromJson).toList(),
nextSyncToken: response.syncToken,
);
}
@override
Future<SyncResult> incrementalSync(String sinceToken) async {
final response = await _api.getIncrementalSync(sinceToken);
return SyncResult(
conversations: response.conversations.map(Conversation.fromJson).toList(),
messages: response.messages.map(Message.fromJson).toList(),
nextSyncToken: response.syncToken,
);
}
@override
Future<Message> sendMessage(SendMessageParams params) async {
return await _api.sendMessage(
conversationId: params.conversationId,
content: params.content,
type: params.type,
);
}
}
For backends with both REST and WebSocket, use ChatRegistry.withHttpAndSocket:
final registry = ChatRegistry.withHttpAndSocket(
config: config,
httpAdapter: MyHttpAdapter(api),
socketAdapter: MySocketAdapter(socket),
identityProvider: identityProvider,
);
The SDK automatically routes sync and CRUD operations to HTTP, and real-time events to the socket.
Initializing the SDK
The recommended approach initializes the SDK at app startup — before the user logs in — so local data is available immediately:
// At app startup (before login).
final chat = await Chat.create(
databasePath: 'chat.db',
adapter: MyHttpAdapter(api),
identityProvider: MyIdentityProvider(storage),
autoConnect: false, // Connect after login.
);
// After login: update the identity provider so it emits the user ID,
// then connect.
await storage.write('user_id', 'user-123');
await chat.connect();
// On logout: clear the user ID and disconnect.
await storage.delete('user_id');
await chat.disconnect();
await chat.clearSyncState(); // Ensures the next login does a full sync.
ChatIdentityProvider bridges your auth layer and the SDK:
class MyIdentityProvider implements ChatIdentityProvider {
MyIdentityProvider(this._storage);
final StorageService _storage;
@override
Future<String?> getCurrentUserId() => _storage.read('user_id');
@override
Stream<String?> get userIdChanges => _storage.events
.where((e) => e.key == 'user_id')
.map((e) => e.value as String?);
}
For manual control, use ChatRegistry directly:
final registry = ChatRegistry.withAdapter(
config: ChatConfig(databasePath: 'chat.db', enableLogging: kDebugMode),
adapter: MyHttpAdapter(api),
identityProvider: identityProvider,
);
final chat = Chat(registry);
await chat.initialize();
await chat.connect();
Wrap your app with ChatProvider to make the Chat instance available throughout the widget tree:
ChatProvider(
chat: chat,
child: MyApp(),
)
Access the instance anywhere with context.chat (from the package:flutter_chat_sdk/flutter_chat_sdk.dart extension).
Watching conversations
// Reactive stream — rebuilds whenever conversations change.
chat.watchConversations().listen((conversations) {
for (final c in conversations) {
print('${c.displayName}: ${c.unreadCount} unread');
}
});
// With a filter.
chat.watchConversations(
filter: ConversationFilter(
mode: ConversationMode.ephemeral,
status: ConversationStatus.active,
),
).listen((conversations) { ... });
// As a Flutter widget.
ConversationsBuilder(
builder: (context, conversations, isLoading) {
if (isLoading) return const CircularProgressIndicator();
return ListView.builder(
itemCount: conversations.length,
itemBuilder: (context, i) => ListTile(
title: Text(conversations[i].displayName),
trailing: conversations[i].hasUnread
? Badge(label: Text('${conversations[i].unreadCount}'))
: null,
),
);
},
)
Sending messages
Messages are written to the local database first and returned immediately. The SDK sends them to the backend in the background, retrying with exponential backoff if offline.
// Simple text message.
await chat.sendMessage(
conversationId: conversation.id,
content: 'Hello!',
);
// Reply.
await chat.sendMessage(
conversationId: conversation.id,
content: 'Agreed.',
replyToId: originalMessage.id,
);
// Image message.
await chat.sendMessage(
conversationId: conversation.id,
content: '[Image]',
type: MessageType.image,
);
Watch messages in a conversation:
// Reactive stream.
chat.watchMessages(conversationId).listen((messages) {
for (final m in messages) {
print('${m.senderId}: ${m.content.displayText}');
}
});
// As a Flutter widget.
MessagesBuilder(
conversationId: conversationId,
builder: (context, messages, isLoading) {
if (isLoading) return const CircularProgressIndicator();
return ListView.builder(
reverse: true,
itemCount: messages.length,
itemBuilder: (context, i) => Text(messages[i].content.displayText),
);
},
)
Load older messages (pagination):
final result = await chat.loadMoreMessages(conversationId);
if (result.hasMore) {
// More history available — call loadMoreMessages again to go further back.
}
Message features
// Star / unstar.
final starId = await chat.starMessage(conversationId, messageId);
await chat.unstarMessage(starId);
// Pin / unpin.
await chat.pinMessage(conversationId, messageId);
await chat.pinMessage(conversationId, messageId, duration: Duration(hours: 1));
await chat.unpinMessage(conversationId, messageId);
// Reactions.
await chat.addReaction(messageId, '👍');
await chat.removeReaction(messageId, '👍'); // pass emoji or reaction ID
// Watch starred / pinned.
chat.watchStarredMessages().listen((messages) { ... });
chat.watchPinnedMessages(conversationId).listen((messages) { ... });
// Real-time events.
chat.onEvent.listen((event) {
print('Event: ${event.runtimeType}');
});
// Typed events.
chat.on<TypingEvent>().listen((event) {
if (event.isTyping) {
print('${event.userName} is typing...');
}
});
chat.on<PresenceEvent>().listen((event) {
print('${event.userId} is ${event.isOnline ? 'online' : 'offline'}');
});
// Send typing indicator.
await chat.sendTyping(conversationId, isTyping: true);
// Mark as read (clears unread badge immediately).
await chat.markAsRead(conversationId, lastMessageId);
Connection state as a Flutter widget:
ConnectionStateBuilder(
builder: (context, state) {
return switch (state) {
ChatConnectionState.connected =>
const Icon(Icons.cloud_done, color: Colors.green),
ChatConnectionState.connecting =>
const Icon(Icons.cloud_sync, color: Colors.orange),
_ => const Icon(Icons.cloud_off, color: Colors.grey),
};
},
)
Testing with MockChatAdapter
Import package:flutter_chat_sdk/testing.dart in tests and example apps — it never hits the network.
import 'package:flutter_chat_sdk/flutter_chat_sdk.dart';
import 'package:flutter_chat_sdk/testing.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
late MockChatAdapter adapter;
late Chat chat;
setUp(() async {
adapter = MockChatAdapter();
chat = await Chat.create(
databasePath: ':memory:',
adapter: adapter,
identityProvider: StaticIdentityProvider('user-1'),
autoConnect: true,
);
});
tearDown(() => chat.dispose());
test('sendMessage enqueues optimistic message', () async {
final conversation = await chat.createConversation(
mode: ConversationMode.standard,
);
final message = await chat.sendMessage(
conversationId: conversation.id,
content: 'Test message',
);
expect(message.content.displayText, 'Test message');
expect(message.conversationId, conversation.id);
});
test('simulateIncomingMessage triggers watchMessages', () async {
final conversation = await chat.createConversation(
mode: ConversationMode.standard,
);
final messages = <List<Message>>[];
chat.watchMessages(conversation.id).listen(messages.add);
adapter.simulateIncomingMessage(
conversationId: conversation.id,
senderId: 'user-2',
content: 'Hello!',
);
await Future<void>.delayed(Duration.zero);
expect(messages.last.last.content.displayText, 'Hello!');
});
}
Architecture
┌─────────────────────────────────────────────────────────────────────┐
│ APP LAYER │
│ ┌──────────────────┐ ┌──────────────────┐ ┌────────────────┐ │
│ │ ChatService │ │ Adapter │ │ UI │ │
│ │ (Integration) │───▶│ Implementation │◀───│ (Pages/Wgt) │ │
│ └──────────────────┘ └──────────────────┘ └────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ CHAT PACKAGE │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ Chat (Facade) │ │
│ │ - Conversation Operations - Message Operations │ │
│ │ - State Notifiers - Event Streams │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌──────────────┐ │
│ │ Database │ │SyncEngine │ │ Queue │ │ EventBus │ │
│ │ (Drift) │ │ │ │ │ │ │ │
│ └───────────┘ └───────────┘ └───────────┘ └──────────────┘ │
│ │ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ ChatAdapter Interface │ │
│ │ (Contract for Backend Communication) │ │
│ └────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
Core components
| Component | Responsibility |
|---|---|
Chat |
Public facade. All operations go through here. |
ChatAdapter |
Backend contract. Implement this for your backend. |
SyncEngine |
Runs initial sync on first launch, incremental sync on reconnect, per-conversation sync on demand. |
OutboundQueue |
Persists pending operations to SQLite; retries with exponential backoff; crash-safe. |
ChatDatabase |
Drift/SQLite abstraction. Reactive queries via streams. Optional SQLCipher encryption. |
ChatEventBus |
Internal pub/sub. Adapter events → sync engine → event bus → public onEvent stream. |
ChatIdentityProvider |
Decouples user identity from SDK initialization. |
Package structure
lib/
├── flutter_chat_sdk.dart # Public API
├── testing.dart # Testing utilities
└── src/
├── chat.dart # Chat facade
├── adapters/
│ └── chat_adapter.dart # ChatAdapter, HttpChatAdapter, SocketChatAdapter
├── config/
│ ├── chat_config.dart
│ ├── chat_identity_provider.dart
│ └── chat_registry.dart
├── core/
│ ├── database/ # Drift/SQLite
│ ├── queue/ # OutboundQueue
│ ├── sync/ # SyncEngine
│ ├── event_bus/ # ChatEventBus
│ ├── encryption/ # EncryptionService
│ └── security/ # DatabaseEncryptionConfig
├── domain/
│ ├── entities/ # Conversation, Message, Participant, ...
│ ├── enums/ # ConversationMode, ConversationType, MessageStatus, ...
│ └── repositories/ # ChatRepository interface
├── flutter/
│ ├── chat_provider.dart
│ └── widgets/ # ConversationsBuilder, MessagesBuilder, ...
├── extensions/ # context.chat, conversation helpers
├── exceptions/ # Typed exceptions
└── testing/ # MockChatAdapter, StaticIdentityProvider
Configuration
ChatConfig
final config = ChatConfig(
databasePath: 'chat.db', // Required. Use ':memory:' for tests.
enableLogging: kDebugMode, // Default: false
maxRetryAttempts: 10, // Default: 10
syncInterval: Duration(minutes: 5), // Default: 5 minutes
heartbeatInterval: Duration(seconds: 30), // Default: 30 seconds
);
ChatRegistry
// Single adapter (HTTP or Socket).
final registry = ChatRegistry.withAdapter(
config: config,
adapter: MyAdapter(),
identityProvider: identityProvider,
);
// HTTP + Socket (composite).
final registry = ChatRegistry.withHttpAndSocket(
config: config,
httpAdapter: MyHttpAdapter(),
socketAdapter: MySocketAdapter(),
identityProvider: identityProvider,
);
// Custom services (bring your own database or encryption).
final registry = ChatRegistry.custom(
config: config,
adapter: MyAdapter(),
identityProvider: identityProvider,
encryption: MyEncryptionService(),
database: MyCustomDatabase(),
);
Exception handling
try {
await chat.sendMessage(conversationId: conversationId, content: 'Hello!');
} on UserIdNotSetException {
// User not logged in yet.
} on ChatNotInitializedException {
// initialize() not called.
} on ChatOperationException catch (e) {
print('Operation failed: ${e.message}');
} on ChatException catch (e) {
print('Chat error: ${e.message}');
}
| Exception | When thrown |
|---|---|
ChatNotInitializedException |
Methods called before initialize() |
UserIdNotSetException |
User-scoped operation called before identity is available |
ChatOperationException |
General operation failure |
ChatSyncException |
Synchronization failure |
ChatDatabaseException |
Database operation failure |
ChatAdapterException |
Adapter operation failure |
ChatValidationException |
Input validation failure |
OutboundQueue retry config
final queue = OutboundQueueImpl(
adapter: adapter,
database: database,
retryConfig: QueueRetryConfig(
maxRetries: 5, // Default: 3
initialDelayMs: 2000, // Default: 1000 ms
maxDelayMs: 60000, // Default: 30 000 ms
backoffMultiplier: 2.0, // Default: 2.0
),
maxQueueSize: 500, // Default: 1000
);
Database Encryption
The SDK uses sqlite3mc via the sqlite3 package hook for transparent SQLite encryption, with no additional dependencies required for basic use.
For full SQLCipher compatibility:
-
Add the optional dependency:
dependencies: sqlcipher_flutter_libs: ^0.6.8 -
Pass an encryption config:
final registry = ChatRegistry.withAdapter( config: config, adapter: adapter, identityProvider: identityProvider, databaseEncryptionConfig: DatabaseEncryptionConfig( encryptionKey: 'your-64-character-hex-key', // 256-bit key cipherPageSize: 4096, // Default: 4096 kdfIterations: 64000, // Default: 64000 (OWASP recommends 100000+) ), );
Key management is outside the SDK's scope. Generate and store keys at the app level:
import 'dart:math';
import 'package:convert/convert.dart';
final random = Random.secure();
final keyBytes = List<int>.generate(32, (_) => random.nextInt(256));
final keyHex = hex.encode(keyBytes); // 64 hex characters
Store the key with flutter_secure_storage, iOS Keychain, or Android Keystore.
Contributing
See CONTRIBUTING.md.
License
Libraries
- flutter_chat_sdk
- Chat Package
- testing
- Testing utilities for the chat package.