Skip to content

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

Next: Offline-First App Example →