Real-time Chat Application¶
This example demonstrates how to build a complete real-time chat application using LDK's real-time features, including WebSocket connections, live message updates, and user presence indicators.
Overview¶
We'll build a chat app with these features: - Real-time message sending and receiving - User presence indicators (online/offline) - Typing indicators - Message history with pagination - Offline message queuing - Push notifications
Project Setup¶
1. Dependencies¶
Add these dependencies to your pubspec.yaml:
dependencies:
flutter:
sdk: flutter
ldk: ^2.0.0
flutter_local_notifications: ^17.0.0
permission_handler: ^11.0.0
2. Initialize LDK¶
// main.dart
import 'package:flutter/material.dart';
import 'package:ldk/ldk.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await LDK.initialize(
baseUrl: 'https://your-strapi-server.com',
enableRealtime: true,
enableOfflineStorage: true,
enableIntelligentCache: true,
enableEnhancedAuth: true,
);
runApp(ChatApp());
}
class ChatApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'LDK Chat',
theme: ThemeData(primarySwatch: Colors.blue),
home: AuthWrapper(),
);
}
}
Strapi Backend Setup¶
Content Types¶
Create these content types in your Strapi admin:
1. Chat Room (chat-rooms)¶
{
"kind": "collectionType",
"collectionName": "chat_rooms",
"info": {
"singularName": "chat-room",
"pluralName": "chat-rooms",
"displayName": "Chat Room"
},
"attributes": {
"name": {
"type": "string",
"required": true
},
"description": {
"type": "text"
},
"isPrivate": {
"type": "boolean",
"default": false
},
"participants": {
"type": "relation",
"relation": "manyToMany",
"target": "plugin::users-permissions.user"
},
"messages": {
"type": "relation",
"relation": "oneToMany",
"target": "api::message.message"
}
}
}
2. Message (messages)¶
{
"kind": "collectionType",
"collectionName": "messages",
"info": {
"singularName": "message",
"pluralName": "messages",
"displayName": "Message"
},
"attributes": {
"content": {
"type": "text",
"required": true
},
"messageType": {
"type": "enumeration",
"enum": ["text", "image", "file", "system"],
"default": "text"
},
"sender": {
"type": "relation",
"relation": "manyToOne",
"target": "plugin::users-permissions.user"
},
"chatRoom": {
"type": "relation",
"relation": "manyToOne",
"target": "api::chat-room.chat-room"
},
"readBy": {
"type": "json",
"default": []
},
"attachments": {
"type": "media",
"multiple": true
}
}
}
3. User Presence (user-presences)¶
{
"kind": "collectionType",
"collectionName": "user_presences",
"info": {
"singularName": "user-presence",
"pluralName": "user-presences",
"displayName": "User Presence"
},
"attributes": {
"user": {
"type": "relation",
"relation": "oneToOne",
"target": "plugin::users-permissions.user"
},
"status": {
"type": "enumeration",
"enum": ["online", "away", "busy", "offline"],
"default": "offline"
},
"lastSeen": {
"type": "datetime"
},
"currentRoom": {
"type": "relation",
"relation": "manyToOne",
"target": "api::chat-room.chat-room"
},
"isTyping": {
"type": "boolean",
"default": false
}
}
}
Authentication Wrapper¶
// auth_wrapper.dart
import 'package:flutter/material.dart';
import 'package:ldk/ldk.dart';
class AuthWrapper extends StatelessWidget {
@override
Widget build(BuildContext context) {
return StreamBuilder<bool>(
stream: LDK.instance.auth.authStateStream,
initialData: LDK.instance.auth.isAuthenticated,
builder: (context, snapshot) {
final isAuthenticated = snapshot.data ?? false;
if (isAuthenticated) {
return ChatHomeScreen();
} else {
return LoginScreen();
}
},
);
}
}
class LoginScreen extends StatefulWidget {
@override
_LoginScreenState createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
bool _isLoading = false;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Chat Login')),
body: Padding(
padding: EdgeInsets.all(16),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
TextField(
controller: _emailController,
decoration: InputDecoration(
labelText: 'Email',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.emailAddress,
),
SizedBox(height: 16),
TextField(
controller: _passwordController,
decoration: InputDecoration(
labelText: 'Password',
border: OutlineInputBorder(),
),
obscureText: true,
),
SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _isLoading ? null : _signIn,
child: _isLoading
? CircularProgressIndicator()
: Text('Sign In'),
),
),
],
),
),
);
}
Future<void> _signIn() async {
setState(() => _isLoading = true);
try {
await LDK.instance.auth.signIn(
email: _emailController.text,
password: _passwordController.text,
);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Login failed: $e')),
);
} finally {
setState(() => _isLoading = false);
}
}
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
}
Chat Service¶
// chat_service.dart
import 'dart:async';
import 'package:ldk/ldk.dart';
class ChatService {
static final ChatService _instance = ChatService._internal();
factory ChatService() => _instance;
ChatService._internal();
StreamSubscription? _messageSubscription;
StreamSubscription? _presenceSubscription;
StreamSubscription? _typingSubscription;
final StreamController<Message> _messageController = StreamController.broadcast();
final StreamController<UserPresence> _presenceController = StreamController.broadcast();
final StreamController<TypingIndicator> _typingController = StreamController.broadcast();
Stream<Message> get messageStream => _messageController.stream;
Stream<UserPresence> get presenceStream => _presenceController.stream;
Stream<TypingIndicator> get typingStream => _typingController.stream;
Future<void> initialize() async {
await _subscribeToMessages();
await _subscribeToPresence();
await _subscribeToTyping();
await _updateUserPresence('online');
}
Future<void> _subscribeToMessages() async {
try {
final messagesCollection = LDK.collection('messages') as LDKEnhancedCollection;
_messageSubscription = messagesCollection.subscribeToUpdates().listen((event) {
if (event.type == CollectionEventType.created) {
final message = Message.fromJson(event.data);
_messageController.add(message);
_showNotification(message);
}
});
} catch (e) {
print('Failed to subscribe to messages: $e');
}
}
Future<void> _subscribeToPresence() async {
try {
final presenceCollection = LDK.collection('user-presences') as LDKEnhancedCollection;
_presenceSubscription = presenceCollection.subscribeToUpdates().listen((event) {
if (event.type == CollectionEventType.updated) {
final presence = UserPresence.fromJson(event.data);
_presenceController.add(presence);
}
});
} catch (e) {
print('Failed to subscribe to presence: $e');
}
}
Future<void> _subscribeToTyping() async {
try {
// Custom GraphQL subscription for typing indicators
final subscription = '''
subscription OnTypingIndicator {
typingIndicator {
userId
roomId
isTyping
timestamp
}
}
''';
final stream = LDK.instance.graphql.subscribe(subscription);
_typingSubscription = stream.listen((data) {
final indicator = TypingIndicator.fromJson(data['typingIndicator']);
_typingController.add(indicator);
});
} catch (e) {
print('Failed to subscribe to typing indicators: $e');
}
}
Future<List<ChatRoom>> getChatRooms() async {
try {
final roomsCollection = LDK.collection('chat-rooms');
final query = LDKQueryBuilder()
.populate(['participants'])
.sort(['-updatedAt']);
final response = await roomsCollection.get(query: query);
return response.data.map((json) => ChatRoom.fromJson(json)).toList();
} catch (e) {
print('Failed to get chat rooms: $e');
return [];
}
}
Future<List<Message>> getMessages(String roomId, {int page = 1, int limit = 50}) async {
try {
final messagesCollection = LDK.collection('messages');
final query = LDKQueryBuilder()
.where('chatRoom', eq: roomId)
.populate(['sender'])
.sort(['-createdAt'])
.limit(limit)
.offset((page - 1) * limit);
final response = await messagesCollection.get(query: query);
return response.data.map((json) => Message.fromJson(json)).toList();
} catch (e) {
print('Failed to get messages: $e');
return [];
}
}
Future<Message> sendMessage({
required String roomId,
required String content,
String type = 'text',
List<String>? attachments,
}) async {
try {
final messagesCollection = LDK.collection('messages') as LDKEnhancedCollection;
final messageData = {
'content': content,
'messageType': type,
'chatRoom': roomId,
'sender': LDK.instance.auth.currentUser?.id,
if (attachments != null) 'attachments': attachments,
};
// Send with optimistic updates
final response = await messagesCollection.create(
messageData,
optimistic: true,
);
return Message.fromJson(response);
} catch (e) {
print('Failed to send message: $e');
rethrow;
}
}
Future<void> markMessageAsRead(String messageId) async {
try {
final messagesCollection = LDK.collection('messages');
final currentUserId = LDK.instance.auth.currentUser?.id;
if (currentUserId != null) {
await messagesCollection.update(messageId, {
'readBy': [currentUserId], // Simplified - in real app, append to existing array
});
}
} catch (e) {
print('Failed to mark message as read: $e');
}
}
Future<void> updateTypingStatus(String roomId, bool isTyping) async {
try {
final presenceCollection = LDK.collection('user-presences');
final currentUserId = LDK.instance.auth.currentUser?.id;
if (currentUserId != null) {
// Find user's presence record
final query = LDKQueryBuilder().where('user', eq: currentUserId);
final response = await presenceCollection.get(query: query);
if (response.data.isNotEmpty) {
final presenceId = response.data.first['id'];
await presenceCollection.update(presenceId, {
'isTyping': isTyping,
'currentRoom': roomId,
});
}
}
} catch (e) {
print('Failed to update typing status: $e');
}
}
Future<void> _updateUserPresence(String status) async {
try {
final presenceCollection = LDK.collection('user-presences');
final currentUserId = LDK.instance.auth.currentUser?.id;
if (currentUserId != null) {
// Try to find existing presence record
final query = LDKQueryBuilder().where('user', eq: currentUserId);
final response = await presenceCollection.get(query: query);
if (response.data.isNotEmpty) {
// Update existing
final presenceId = response.data.first['id'];
await presenceCollection.update(presenceId, {
'status': status,
'lastSeen': DateTime.now().toIso8601String(),
});
} else {
// Create new
await presenceCollection.create({
'user': currentUserId,
'status': status,
'lastSeen': DateTime.now().toIso8601String(),
});
}
}
} catch (e) {
print('Failed to update user presence: $e');
}
}
Future<void> _showNotification(Message message) async {
// Implement push notifications here
// This is a simplified version
print('New message from ${message.sender?.username}: ${message.content}');
}
void dispose() {
_messageSubscription?.cancel();
_presenceSubscription?.cancel();
_typingSubscription?.cancel();
_messageController.close();
_presenceController.close();
_typingController.close();
}
}
Data Models¶
// models.dart
class ChatRoom {
final String id;
final String name;
final String? description;
final bool isPrivate;
final List<User> participants;
final List<Message> messages;
final DateTime createdAt;
final DateTime updatedAt;
ChatRoom({
required this.id,
required this.name,
this.description,
required this.isPrivate,
required this.participants,
required this.messages,
required this.createdAt,
required this.updatedAt,
});
factory ChatRoom.fromJson(Map<String, dynamic> json) {
final attributes = json['attributes'] as Map<String, dynamic>;
return ChatRoom(
id: json['id'].toString(),
name: attributes['name'] ?? '',
description: attributes['description'],
isPrivate: attributes['isPrivate'] ?? false,
participants: _parseUsers(attributes['participants']),
messages: _parseMessages(attributes['messages']),
createdAt: DateTime.parse(attributes['createdAt']),
updatedAt: DateTime.parse(attributes['updatedAt']),
);
}
static List<User> _parseUsers(dynamic data) {
if (data == null) return [];
if (data is List) {
return data.map((item) => User.fromJson(item)).toList();
}
return [];
}
static List<Message> _parseMessages(dynamic data) {
if (data == null) return [];
if (data is List) {
return data.map((item) => Message.fromJson(item)).toList();
}
return [];
}
}
class Message {
final String id;
final String content;
final String messageType;
final User? sender;
final String chatRoomId;
final List<String> readBy;
final List<String> attachments;
final DateTime createdAt;
final bool isOptimistic;
Message({
required this.id,
required this.content,
required this.messageType,
this.sender,
required this.chatRoomId,
required this.readBy,
required this.attachments,
required this.createdAt,
this.isOptimistic = false,
});
factory Message.fromJson(Map<String, dynamic> json) {
final attributes = json['attributes'] as Map<String, dynamic>? ?? json;
return Message(
id: json['id']?.toString() ?? '',
content: attributes['content'] ?? '',
messageType: attributes['messageType'] ?? 'text',
sender: attributes['sender'] != null ? User.fromJson(attributes['sender']) : null,
chatRoomId: attributes['chatRoom']?.toString() ?? '',
readBy: List<String>.from(attributes['readBy'] ?? []),
attachments: List<String>.from(attributes['attachments'] ?? []),
createdAt: DateTime.parse(attributes['createdAt'] ?? DateTime.now().toIso8601String()),
isOptimistic: attributes['_isOptimistic'] ?? false,
);
}
bool get isRead => readBy.contains(LDK.instance.auth.currentUser?.id);
bool get isMine => sender?.id == LDK.instance.auth.currentUser?.id;
}
class User {
final String id;
final String email;
final String? username;
final String? firstName;
final String? lastName;
final String? avatar;
User({
required this.id,
required this.email,
this.username,
this.firstName,
this.lastName,
this.avatar,
});
factory User.fromJson(Map<String, dynamic> json) {
return User(
id: json['id'].toString(),
email: json['email'] ?? '',
username: json['username'],
firstName: json['firstName'],
lastName: json['lastName'],
avatar: json['avatar'],
);
}
String get displayName {
if (firstName != null && lastName != null) {
return '$firstName $lastName';
}
return username ?? email;
}
}
class UserPresence {
final String id;
final User user;
final String status;
final DateTime lastSeen;
final String? currentRoom;
final bool isTyping;
UserPresence({
required this.id,
required this.user,
required this.status,
required this.lastSeen,
this.currentRoom,
required this.isTyping,
});
factory UserPresence.fromJson(Map<String, dynamic> json) {
final attributes = json['attributes'] as Map<String, dynamic>;
return UserPresence(
id: json['id'].toString(),
user: User.fromJson(attributes['user']),
status: attributes['status'] ?? 'offline',
lastSeen: DateTime.parse(attributes['lastSeen']),
currentRoom: attributes['currentRoom']?.toString(),
isTyping: attributes['isTyping'] ?? false,
);
}
bool get isOnline => status == 'online';
}
class TypingIndicator {
final String userId;
final String roomId;
final bool isTyping;
final DateTime timestamp;
TypingIndicator({
required this.userId,
required this.roomId,
required this.isTyping,
required this.timestamp,
});
factory TypingIndicator.fromJson(Map<String, dynamic> json) {
return TypingIndicator(
userId: json['userId'].toString(),
roomId: json['roomId'].toString(),
isTyping: json['isTyping'] ?? false,
timestamp: DateTime.parse(json['timestamp']),
);
}
}
Chat Home Screen¶
// chat_home_screen.dart
import 'package:flutter/material.dart';
class ChatHomeScreen extends StatefulWidget {
@override
_ChatHomeScreenState createState() => _ChatHomeScreenState();
}
class _ChatHomeScreenState extends State<ChatHomeScreen> {
final ChatService _chatService = ChatService();
List<ChatRoom> _chatRooms = [];
bool _isLoading = true;
@override
void initState() {
super.initState();
_initializeChat();
}
Future<void> _initializeChat() async {
await _chatService.initialize();
await _loadChatRooms();
}
Future<void> _loadChatRooms() async {
setState(() => _isLoading = true);
try {
final rooms = await _chatService.getChatRooms();
setState(() {
_chatRooms = rooms;
_isLoading = false;
});
} catch (e) {
setState(() => _isLoading = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to load chat rooms: $e')),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Chat Rooms'),
actions: [
IconButton(
icon: Icon(Icons.add),
onPressed: _createChatRoom,
),
IconButton(
icon: Icon(Icons.logout),
onPressed: _signOut,
),
],
),
body: _buildBody(),
);
}
Widget _buildBody() {
if (_isLoading) {
return Center(child: CircularProgressIndicator());
}
if (_chatRooms.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.chat_bubble_outline, size: 64, color: Colors.grey),
SizedBox(height: 16),
Text('No chat rooms yet'),
SizedBox(height: 16),
ElevatedButton(
onPressed: _createChatRoom,
child: Text('Create Chat Room'),
),
],
),
);
}
return RefreshIndicator(
onRefresh: _loadChatRooms,
child: ListView.builder(
itemCount: _chatRooms.length,
itemBuilder: (context, index) {
final room = _chatRooms[index];
return ChatRoomTile(
room: room,
onTap: () => _openChatRoom(room),
);
},
),
);
}
void _openChatRoom(ChatRoom room) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ChatRoomScreen(room: room),
),
);
}
void _createChatRoom() {
// Implement chat room creation
showDialog(
context: context,
builder: (context) => CreateChatRoomDialog(),
);
}
Future<void> _signOut() async {
await LDK.instance.auth.signOut();
_chatService.dispose();
}
@override
void dispose() {
_chatService.dispose();
super.dispose();
}
}
class ChatRoomTile extends StatelessWidget {
final ChatRoom room;
final VoidCallback onTap;
const ChatRoomTile({
Key? key,
required this.room,
required this.onTap,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final lastMessage = room.messages.isNotEmpty ? room.messages.first : null;
return ListTile(
leading: CircleAvatar(
child: Text(room.name[0].toUpperCase()),
),
title: Text(room.name),
subtitle: lastMessage != null
? Text(
'${lastMessage.sender?.displayName}: ${lastMessage.content}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
)
: Text('No messages yet'),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
_formatTime(room.updatedAt),
style: TextStyle(fontSize: 12, color: Colors.grey),
),
if (room.participants.length > 2)
Container(
margin: EdgeInsets.only(top: 4),
padding: EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.blue,
borderRadius: BorderRadius.circular(10),
),
child: Text(
'${room.participants.length}',
style: TextStyle(color: Colors.white, fontSize: 10),
),
),
],
),
onTap: onTap,
);
}
String _formatTime(DateTime dateTime) {
final now = DateTime.now();
final difference = now.difference(dateTime);
if (difference.inDays > 0) {
return '${difference.inDays}d';
} else if (difference.inHours > 0) {
return '${difference.inHours}h';
} else if (difference.inMinutes > 0) {
return '${difference.inMinutes}m';
} else {
return 'now';
}
}
}
Chat Room Screen¶
// chat_room_screen.dart
import 'package:flutter/material.dart';
import 'dart:async';
class ChatRoomScreen extends StatefulWidget {
final ChatRoom room;
const ChatRoomScreen({Key? key, required this.room}) : super(key: key);
@override
_ChatRoomScreenState createState() => _ChatRoomScreenState();
}
class _ChatRoomScreenState extends State<ChatRoomScreen> {
final ChatService _chatService = ChatService();
final TextEditingController _messageController = TextEditingController();
final ScrollController _scrollController = ScrollController();
List<Message> _messages = [];
List<TypingIndicator> _typingIndicators = [];
bool _isLoading = true;
bool _isLoadingMore = false;
Timer? _typingTimer;
StreamSubscription? _messageSubscription;
StreamSubscription? _typingSubscription;
@override
void initState() {
super.initState();
_loadMessages();
_subscribeToUpdates();
_scrollController.addListener(_scrollListener);
}
Future<void> _loadMessages() async {
setState(() => _isLoading = true);
try {
final messages = await _chatService.getMessages(widget.room.id);
setState(() {
_messages = messages.reversed.toList(); // Reverse for chat order
_isLoading = false;
});
_scrollToBottom();
} catch (e) {
setState(() => _isLoading = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to load messages: $e')),
);
}
}
void _subscribeToUpdates() {
// Subscribe to new messages
_messageSubscription = _chatService.messageStream.listen((message) {
if (message.chatRoomId == widget.room.id) {
setState(() {
_messages.add(message);
});
_scrollToBottom();
// Mark as read if not from current user
if (!message.isMine) {
_chatService.markMessageAsRead(message.id);
}
}
});
// Subscribe to typing indicators
_typingSubscription = _chatService.typingStream.listen((indicator) {
if (indicator.roomId == widget.room.id) {
setState(() {
_typingIndicators.removeWhere((t) => t.userId == indicator.userId);
if (indicator.isTyping) {
_typingIndicators.add(indicator);
}
});
}
});
}
void _scrollListener() {
if (_scrollController.position.pixels == _scrollController.position.maxScrollExtent) {
_loadMoreMessages();
}
}
Future<void> _loadMoreMessages() async {
if (_isLoadingMore) return;
setState(() => _isLoadingMore = true);
try {
final page = (_messages.length / 50).ceil() + 1;
final moreMessages = await _chatService.getMessages(widget.room.id, page: page);
setState(() {
_messages.insertAll(0, moreMessages.reversed);
_isLoadingMore = false;
});
} catch (e) {
setState(() => _isLoadingMore = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(widget.room.name),
_buildSubtitle(),
],
),
actions: [
IconButton(
icon: Icon(Icons.info),
onPressed: _showRoomInfo,
),
],
),
body: Column(
children: [
Expanded(child: _buildMessageList()),
_buildTypingIndicators(),
_buildMessageInput(),
],
),
);
}
Widget _buildSubtitle() {
final onlineCount = widget.room.participants.length; // Simplified
return Text(
'$onlineCount participants',
style: TextStyle(fontSize: 12, color: Colors.white70),
);
}
Widget _buildMessageList() {
if (_isLoading) {
return Center(child: CircularProgressIndicator());
}
return ListView.builder(
controller: _scrollController,
itemCount: _messages.length + (_isLoadingMore ? 1 : 0),
itemBuilder: (context, index) {
if (index == 0 && _isLoadingMore) {
return Center(
child: Padding(
padding: EdgeInsets.all(16),
child: CircularProgressIndicator(),
),
);
}
final messageIndex = _isLoadingMore ? index - 1 : index;
final message = _messages[messageIndex];
return MessageBubble(
message: message,
showSender: _shouldShowSender(messageIndex),
);
},
);
}
bool _shouldShowSender(int index) {
if (index == 0) return true;
final currentMessage = _messages[index];
final previousMessage = _messages[index - 1];
return currentMessage.sender?.id != previousMessage.sender?.id;
}
Widget _buildTypingIndicators() {
if (_typingIndicators.isEmpty) return SizedBox.shrink();
final currentUserId = LDK.instance.auth.currentUser?.id;
final relevantIndicators = _typingIndicators
.where((indicator) => indicator.userId != currentUserId)
.toList();
if (relevantIndicators.isEmpty) return SizedBox.shrink();
return Container(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
SizedBox(width: 8),
Text(
'${relevantIndicators.length} ${relevantIndicators.length == 1 ? 'person is' : 'people are'} typing...',
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
fontStyle: FontStyle.italic,
),
),
],
),
);
}
Widget _buildMessageInput() {
return Container(
padding: EdgeInsets.all(16),
child: Row(
children: [
Expanded(
child: TextField(
controller: _messageController,
decoration: InputDecoration(
hintText: 'Type a message...',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(25),
),
contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
onChanged: _onTyping,
onSubmitted: (_) => _sendMessage(),
),
),
SizedBox(width: 8),
FloatingActionButton(
mini: true,
onPressed: _sendMessage,
child: Icon(Icons.send),
),
],
),
);
}
void _onTyping(String text) {
_chatService.updateTypingStatus(widget.room.id, text.isNotEmpty);
// Stop typing indicator after 3 seconds of inactivity
_typingTimer?.cancel();
_typingTimer = Timer(Duration(seconds: 3), () {
_chatService.updateTypingStatus(widget.room.id, false);
});
}
Future<void> _sendMessage() async {
final content = _messageController.text.trim();
if (content.isEmpty) return;
_messageController.clear();
_chatService.updateTypingStatus(widget.room.id, false);
try {
await _chatService.sendMessage(
roomId: widget.room.id,
content: content,
);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to send message: $e')),
);
}
}
void _scrollToBottom() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
});
}
void _showRoomInfo() {
showDialog(
context: context,
builder: (context) => ChatRoomInfoDialog(room: widget.room),
);
}
@override
void dispose() {
_messageSubscription?.cancel();
_typingSubscription?.cancel();
_messageController.dispose();
_scrollController.dispose();
_typingTimer?.cancel();
super.dispose();
}
}
class MessageBubble extends StatelessWidget {
final Message message;
final bool showSender;
const MessageBubble({
Key? key,
required this.message,
required this.showSender,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final isMine = message.isMine;
return Container(
margin: EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Column(
crossAxisAlignment: isMine ? CrossAxisAlignment.end : CrossAxisAlignment.start,
children: [
if (showSender && !isMine)
Padding(
padding: EdgeInsets.only(left: 12, bottom: 4),
child: Text(
message.sender?.displayName ?? 'Unknown',
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
fontWeight: FontWeight.bold,
),
),
),
Row(
mainAxisAlignment: isMine ? MainAxisAlignment.end : MainAxisAlignment.start,
children: [
if (!isMine) ...[
CircleAvatar(
radius: 16,
child: Text(
(message.sender?.displayName ?? 'U')[0].toUpperCase(),
style: TextStyle(fontSize: 12),
),
),
SizedBox(width: 8),
],
Flexible(
child: Container(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration(
color: isMine ? Colors.blue : Colors.grey[300],
borderRadius: BorderRadius.circular(18),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
message.content,
style: TextStyle(
color: isMine ? Colors.white : Colors.black87,
),
),
SizedBox(height: 4),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_formatTime(message.createdAt),
style: TextStyle(
fontSize: 10,
color: isMine ? Colors.white70 : Colors.grey[600],
),
),
if (message.isOptimistic) ...[
SizedBox(width: 4),
Icon(
Icons.schedule,
size: 12,
color: isMine ? Colors.white70 : Colors.grey[600],
),
],
if (isMine && message.isRead) ...[
SizedBox(width: 4),
Icon(
Icons.done_all,
size: 12,
color: Colors.blue[200],
),
],
],
),
],
),
),
),
],
),
],
),
);
}
String _formatTime(DateTime dateTime) {
return '${dateTime.hour.toString().padLeft(2, '0')}:${dateTime.minute.toString().padLeft(2, '0')}';
}
}
Key Features Demonstrated¶
1. Real-time Message Updates¶
- WebSocket subscriptions for instant message delivery
- Optimistic updates for immediate UI feedback
- Automatic message ordering and display
2. User Presence & Typing Indicators¶
- Real-time presence status updates
- Typing indicators with automatic timeout
- Online/offline status tracking
3. Offline Support¶
- Messages queued when offline
- Automatic sync when connection restored
- Optimistic updates with rollback on failure
4. Performance Optimizations¶
- Message pagination for large chat histories
- Intelligent caching of chat rooms and messages
- Efficient list rendering with proper disposal
5. Enhanced UX¶
- Pull-to-refresh for message history
- Smooth scrolling to new messages
- Visual indicators for message status (sent, delivered, read)
Next Steps¶
This example provides a solid foundation for a real-time chat application. You can extend it with:
- File Attachments: Image and file sharing
- Push Notifications: Background message notifications
- Message Reactions: Emoji reactions to messages
- Voice Messages: Audio message support
- Group Management: Add/remove participants
- Message Search: Full-text search across messages
- Message Threading: Reply to specific messages
Related Documentation¶
- Real-time Features - Deep dive into WebSocket features
- Offline Support - Building offline-first applications
- Authentication - User management and security
- Caching Strategies - Optimizing performance