Code With Bisky

Create an Interactive Chat List Screen with Typing & Read Ticks! Tutorial [24]

Key Topics covered:

  • Listening for user typing events
  • Showing New Message Indicator
  • Design Chat List Screen
  • Design Chat Message Screen Bubbles

Description:

In this tutorial, we will guide you through the step-by-step process of creating a dynamic Chat List Screen with user typing status, delivery and message read ticks, and time indicators.

We want to create ChatHeadRepositoryProvider. It is responsible for updating chat head message seen, delivered. Let's Get Started

Code Snippet(ChatHeadRepositoryProvider.dart):

  • lib/core/providers/ChatHeadRepositoryProvider.dart

import 'package:appwrite/appwrite.dart';
import 'package:appwrite/models.dart';
import 'package:chat_with_bisky/constant/strings.dart';
import 'package:chat_with_bisky/core/providers/DatabaseProvider.dart';
import 'package:chat_with_bisky/model/ChatAppwrite.dart';
import 'package:chat_with_bisky/model/MessageAppwrite.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

final chatHeadRepositoryProvider = Provider((ref) => ChatHeadRepositoryProvider(ref),);

class ChatHeadRepositoryProvider{

  final Ref _ref;
  Databases get _databases => _ref.read(databaseProvider);
  ChatHeadRepositoryProvider(this._ref);

  Future<void> updateChatHeadMessageSeen(String id) async{

    try{

      _databases.updateDocument(databaseId: Strings.databaseId,
          collectionId: Strings.collectionChatsId,
          documentId: id,
      data: {
        'read':true,
        'delivered':true
      });

    } catch(e){

      print('updateChatHeadMessageSeen Error $e');
    }
  }


  Future<void> updateChatHeadMessageDelivered(String id) async{

    try{

      _databases.updateDocument(databaseId: Strings.databaseId,
          collectionId: Strings.collectionChatsId,
          documentId: id,
          data: {
            'delivered':true
          });

    } catch(e){
      print('updateChatHeadMessageDelivered Error $e');
    }
  }

  Future<ChatAppwrite?> getChat(String id) async{

    try{

      Document document = await _databases.getDocument(databaseId: Strings.databaseId,
          collectionId: Strings.collectionChatsId,
          documentId: id);
      return ChatAppwrite.fromJson(document.data);

    } catch(e){

      print('getChat Error $e');
    }
    return null;
  }

  Future<void> updateChatMessageDelivered(MessageAppwrite message) async {
    String chatId = '${message.senderUserId??""}${message.receiverUserId??""}';
    String chatId2 = '${message.receiverUserId??""}${message.senderUserId??""}';
    ChatAppwrite? chatHead = await getChat(chatId);
    ChatAppwrite? chatHead2 = await getChat(chatId2);

    if(chatHead != null || chatHead2 != null){
      await updateChatHeadMessageDelivered(chatId);
      await updateChatHeadMessageDelivered(chatId2);
    }
  }

  Future<void> updateChatMessageRead(MessageAppwrite message) async {
    String chatId = '${message.senderUserId??""}${message.receiverUserId??""}';
    String chatId2 = '${message.receiverUserId??""}${message.senderUserId??""}';
    ChatAppwrite? chatHead = await getChat(chatId);
    ChatAppwrite? chatHead2 = await getChat(chatId2);
    if(chatHead!= null|| chatHead2!= null){
      updateChatHeadMessageSeen(chatId);
      updateChatHeadMessageSeen(chatId2);
    }
  }

}
 

We want to cheat a ChatHeadViewModel. The view model will be responsible for handling chat head business logic like listening to typing status. Let's create a ChatHeadState and then a ChatHeadViewModel. Follow these steps whenever you are working with riverpod.

Code Snippet(ChatHeadState.dart):

  • lib/model/ChatHeadState.dart

import 'package:chat_with_bisky/model/MessageAppwrite.dart';
import 'package:chat_with_bisky/model/db/ChatRealm.dart';
import 'package:chat_with_bisky/model/db/MessageRealm.dart';
import 'package:freezed_annotation/freezed_annotation.dart';

part 'ChatHeadState.freezed.dart';

@Freezed(makeCollectionsUnmodifiable:false)
class ChatHeadState with _$ChatHeadState {
  factory ChatHeadState({
    @Default('') String myUserId,
    @Default(null) ChatRealm? chat,
    @Default(false) bool myMessage,
    @Default(false) bool isTyping,
    @Default(null) String? friendUserId,

  }) = _ChatHeadState;
}

 

Code Snippet(ChatHeadViewModel.dart):

  • lib/widget/ChatHeadViewModel.dart

import 'package:chat_with_bisky/model/ChatHeadState.dart';
import 'package:chat_with_bisky/model/db/ChatRealm.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:firebase_database/firebase_database.dart' as fd;
part 'ChatHeadViewModel.g.dart';

@riverpod
class ChatHeadViewModel extends _$ChatHeadViewModel{


  fd.FirebaseDatabase database = fd.FirebaseDatabase.instance;
  @override
  ChatHeadState build(){

    // ref.keepAlive();
    return ChatHeadState();
  }

  changedChatHead(ChatRealm chat){
    state = state.copyWith(chat: chat);
  }

  changedUserId(userId){
    state = state.copyWith(myUserId: userId);
  }


  changedTypingStatus(isTyping){
    state = state.copyWith(isTyping: isTyping);
  }
  changedFriendUserId(friendUserId){
    state = state.copyWith(friendUserId: friendUserId);
  }

  Future<void> listenFriendIsTyping()async {

    await database.goOnline();
    final typingRef = database.ref().child('typing').child(state.chat?.senderUserId??"").child(state.myUserId);

    typingRef.onValue.listen((event) {

      if(event.snapshot.exists){
        Map<Object?,Object?> map = event.snapshot.value as Map<Object?,Object?>;
        Map<Object?,Object?> typedValue = map[map.keys.first] as    Map<Object?,Object?>;

        if(typedValue.containsKey('from') && typedValue['from'] != null ){
          print('from $typedValue');
          String from = typedValue['from'] as String;
          changedFriendUserId(from);
          changedTypingStatus(true);
        }

      }else{
        changedTypingStatus(false);
        changedFriendUserId(null);
      }

    });
  }
}

 

Don't forget to run the following command in your terminal to generate part files flutter packages pub run build_runner build

We need to create the ChatHeadItemWidget to display chat message.

Code Snippet(ChatHeadItemWidget.dart):

  • lib/widget/ChatHeadItemWidget.dart

import 'package:auto_route/auto_route.dart';
import 'package:chat_with_bisky/core/extensions/extensions.dart';
import 'package:chat_with_bisky/core/providers/ChatHeadRepositoryProvider.dart';
import 'package:chat_with_bisky/model/ChatHeadState.dart';
import 'package:chat_with_bisky/model/MessageAppwrite.dart';
import 'package:chat_with_bisky/model/UserAppwrite.dart';
import 'package:chat_with_bisky/model/db/ChatRealm.dart';
import 'package:chat_with_bisky/route/app_route/AppRouter.gr.dart';
import 'package:chat_with_bisky/service/LocalStorageService.dart';
import 'package:chat_with_bisky/widget/ChatHeadViewModel.dart';
import 'package:chat_with_bisky/widget/friend_image.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

class ChatHeadItemWidget extends ConsumerStatefulWidget {
  final ChatRealm chat;
  final String userId;
  const ChatHeadItemWidget({
    super.key,
    required this.chat,
    required this.userId,
  });
  @override
  _ChatHeadItemWidget createState() {

    return _ChatHeadItemWidget();
  }


}
class _ChatHeadItemWidget extends ConsumerState <ChatHeadItemWidget>{


  ChatHeadViewModel? notifier;
  ChatHeadState? model;

  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance
        .addPostFrameCallback((_) => initialization(context));
  }
  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final style = theme.textTheme;
    notifier = ref.read(chatHeadViewModelProvider.notifier);
    model = ref.watch(chatHeadViewModelProvider);
    bool isRead = widget.chat.read == true;

    return ListTile(
      leading: FriendImage(widget.chat.base64Image),
      title:  Row(
        // mainAxisAlignment: MainAxisAlignment.center,
        mainAxisSize: MainAxisSize.min,
        children: [
          Flexible(
            child: Text(widget.chat.displayName ?? "",
              maxLines: 1,
              style: style.labelSmall?.copyWith(fontSize: 13),
              overflow: TextOverflow.ellipsis,),
          ),
          if (model?.isTyping == true && widget.chat.senderUserId == model?.friendUserId)
            Text(
              '  typing...',
              style: style.labelSmall!.copyWith(
                  color: Colors.blue.shade600,
                  fontSize: 12
              ),
            ),
          const Spacer(flex: 2,),
          Text(widget.chat.sendDate!.getFormattedTime(), maxLines: 1,
            overflow: TextOverflow.ellipsis,style: style.labelSmall?.copyWith(fontSize: 10),),
        ],
      ),
      // subtitle: Text(friend.message ?? ""),
      subtitle: Row(
        children: [
          Expanded(
            child:  chatMessage(isRead),
          ),

          if(myMessage())
            Icon(widget.chat.read == true || widget.chat.delivered == true?
            Icons.done_all_rounded : Icons.done_rounded,
              size: 16,
              color: widget.chat.read == true? Colors.green.shade800:Colors.grey,),
          if (!isRead && !myMessage())
            const CircleAvatar(
              backgroundColor: Colors.orange,
              radius: 8,
              child: Text(
                '',
                style: TextStyle(
                  fontSize: 10,
                ),
              ),
            ),
        ],
      ),
      onTap: () async {
        String userId =
            await LocalStorageService.getString(
                LocalStorageService.userId) ??
                "";
        AutoRouter.of(context).push(MessageRoute(displayName: widget.chat.displayName ?? "",
            myUserId:userId,
            friendUserId:widget.chat.senderUserId ?? "",friendUser: UserAppwrite(userId: widget.chat.senderUserId,
                name: widget.chat.displayName),profilePicture: widget.chat.base64Image));
        ref.read(chatHeadRepositoryProvider).updateChatMessageRead(MessageAppwrite(receiverUserId: widget.chat.receiverUserId,senderUserId: widget.chat.senderUserId));

      },
    );
  }

  Widget chatMessage(bool isRead) {
    if (widget.chat.type == 'IMAGE') {
      return const Align(
        alignment: Alignment.topLeft,
        child: Row(
          children: [
            Icon(Icons.image,color: Colors.blue),
            Padding(
              padding: EdgeInsets.only(left: 8.0),
              child: Text("image",style: TextStyle(color: Colors.blue,fontSize: 12),),
            )
          ],
        ),
      );
    } else if (widget.chat.type == 'VIDEO') {
      return const Align(
        alignment: Alignment.topLeft,
        child: Row(
          children: [
            Icon(Icons.video_chat,color: Colors.blue,),
            Padding(
              padding: EdgeInsets.only(left: 8.0),
              child: Text("video",style: TextStyle(color: Colors.blue,fontSize: 12),),
            )
          ],
        ),
      );
    } else {
    return  Text(
        widget.chat.message ?? "",
        maxLines: 1,
        overflow: TextOverflow.ellipsis,
        style: TextStyle(
          fontSize: 12,
          fontWeight: !isRead && !myMessage() ? FontWeight.bold : null,
        ),
      );
    }
  }

  bool myMessage(){

    return widget.chat.userId == widget.userId;
  }

  initialization(BuildContext context) {

    notifier?.changedChatHead(widget.chat);
    notifier?.changedUserId(widget.userId);
    notifier?.listenFriendIsTyping();

  }
}
        
        

We need to use the new widget we created above ChatHeadItemWidget by modifying the class ChatListScreen

Code Snippet(ChatListScreen.dart):

  • lib/pages/dashboard/chat/list/ChatListScreen.dart

Declare String? userId and replace ListTile with below code


 import 'package:chat_with_bisky/widget/ChatHeadItemWidget.dart';
           String? userId;
            // replace ListTile
             return ChatHeadItemWidget(chat: friend,userId: userId?? "");
        
        

Modify getChats() to assign userId of the logged in user


           userId =  await LocalStorageService.getString(LocalStorageService.userId) ?? "";
        
        

Add method to get Message by id in MessageRepositoryProvider

Code Snippet(MessageRepositoryProvider.dart):

  • lib/core/providers/MessageRepositoryProvider.dart

import 'package:chat_with_bisky/model/MessageAppwrite.dart';
import 'package:appwrite/models.dart';
  Future<MessageAppwrite?> getMessage(String id) async{
    try{
      Document document = await _databases.getDocument(databaseId: Strings.databaseId,
          collectionId: Strings.collectionMessagesId,
          documentId: id);
      MessageAppwrite message = MessageAppwrite.fromJson(document.data);
      message.sendDate = DateTime.parse(document.$createdAt);
      return message;
    } catch(e){
      print('getMessage Error $e');
    }
    return null;
  }
        
        

We need to add some parameters we missed on ChatRealm instantiation in method mapToChatRealm

Code Snippet(RealmProvider.dart):

  • lib/core/providers/RealmProvider.dart

import 'package:chat_with_bisky/core/providers/MessageRepositoryProvider.dart';
import 'package:chat_with_bisky/core/providers/UserRepositoryProvider.dart';
import 'package:chat_with_bisky/core/util/Util.dart';
import 'package:chat_with_bisky/constant/strings.dart';
import 'package:flutter/services.dart';
            // make the method async
  Future<ChatRealm> mapToChatRealm(ChatAppwrite chat,Document document) async {

// add the code below end of is statement
     bool? delivered = chat.delivered;
    bool? read = chat.read;
    MessageAppwrite? messageUpstream = await _ref.read(messageRepositoryProvider).getMessage(chat.messageIdUpstream?? '');
    DateTime messageDate = DateTime.parse(document.$createdAt);
    if(messageUpstream != null){
      delivered= messageUpstream.delivered;
      read= messageUpstream.read;
      messageDate= messageUpstream.sendDate ?? DateTime.now();
    }else{
      final resultsMessages =
      _realm.query<MessageRealm>(r'messageIdUpstream = $0', [chat.messageIdUpstream?? '']);
      if(!resultsMessages.isEmpty){
        MessageRealm retrieved = resultsMessages.first;
        delivered= retrieved.delivered;
        read= retrieved.read;
        messageDate= retrieved.sendDate ?? DateTime.now();
      }
    }

    final user = await _ref.read(userRepositoryProvider).getUser(chat.senderUserId??"");
    String? base64;
    if(user != null && user.profilePictureStorageId != null){
      Storage storage = Storage(clientService.getClient());
      Uint8List imageBytes = await storage.getFilePreview(
        bucketId: Strings.profilePicturesBucketId,
        fileId: user.profilePictureStorageId ?? "",
      );
      base64 = uint8ListToBase64(imageBytes);
    }

    return  ChatRealm(id, senderUserId: chat.senderUserId,
        receiverUserId: chat.receiverUserId,
        message: chat.message,
        type: chat.type,
        sendDate: messageDate,
        read: read,
        displayName: chat.displayName,
        count: chat.count,
      userId: chat.userId,
      delivered: delivered,
      messageIdUpstream: chat.messageIdUpstream,
      base64Image: base64
    );

        
        

We made the method async. Add await where it is being called

Let's modify this page for performance sakeMessageScreen. We want to remove the delay and call initialization() method after build

Code Snippet(MessageScreen.dart):

  • lib/pages/dashboard/chat/MessageScreen.dart

import 'package:chat_with_bisky/widget/ChatHeadViewModel.dart';
// add this variable in MessageScreen and _MessageScreenState
 String? profilePicture;
 // FriendImage() must accept profilePicture
FriendImage(profilePicture),
// Change color of onlineStatus when someone is typing to blue
messageState?.onlineStatus.contains('typing') == true?Colors.blue.shade600:Colors.grey.shade600, fontSize: 13)

initialization(BuildContext context) {
    messageNotifier?.initializeMessages(myUserId, friendUserId);
    messageNotifier?.getUserPresenceStatus(friendUserId);
    messageNotifier?.listenFriendIsTyping();
    getMessages();
  }

            // add below code in initState
  WidgetsBinding.instance
        .addPostFrameCallback((_) => initialization(context));
        
        

Add the following dependency chat_bubbles in your pubspec.yaml. We want to customize our chat screen


dependencies:
  .........
  chat_bubbles: ^1.4.1


        
        

Let's design chatInputWidget() to


import 'package:chat_bubbles/chat_bubbles.dart';
chatInputWidget() {


    return  MessageBar(
      onSend: (message) =>  sendMessage("TEXT", message),
      onTextChanged: (value) {
        messageNotifier?.typingChanges(value);
      },
      actions: [
        InkWell(
          child: const Icon(
            Icons.add,
            color: Colors.black,
            size: 24,
          ),
          onTap: () {
            _modalBottomSheet();
          },
        ),
        Padding(
          padding: EdgeInsets.only(left: 8, right: 8),
          child: InkWell(
            child: Icon(
              Icons.camera_alt,
              color: Colors.blue,
              size: 24,
            ),
            onTap: () {

              pickImage(ImageSource.camera);
            },
          ),
        ),
      ],
    );
  }


  @override
  void dispose() {

    ref.invalidate(chatHeadViewModelProvider);  // this in dispose


  }
        
        

Lets made some changes in MessageViewModel. In typingChanges method, we need to change the set from true to {'from':state.myUserId,'to':state.friendUserId}

Code Snippet(MessageViewModel.dart):

  • lib/pages/dashboard/chat/MessageViewModel.dart

import 'package:chat_with_bisky/core/extensions/extensions.dart';
            await database.goOnline(); // add this below fd.DatabaseReference con;
            // change set of typingChanges()
            con.set({'from':state.myUserId,'to':state.friendUserId});
            // in getUserLastSeen method we need to call goOnline method below initializing final lastOnlineRef = ....
            await database.goOnline();
            // replace  String dateTime = timeago.format(date); with code below
            String dateTime = date.getFormattedLastSeenTime();
            // add this line of code on listenFriendIsTyping()
             await database.goOnline(); // first line in that method listenFriendIsTyping
        
        

We need to save friend profile picture FriendListViewModel

Code Snippet(FriendListViewModel.dart):


        import 'package:flutter/services.dart';
        import 'package:chat_with_bisky/core/providers/StorageProvider.dart';
        import 'package:chat_with_bisky/core/providers/UserRepositoryProvider.dart';
        import 'package:chat_with_bisky/core/util/Util.dart';

        Storage get _storage => ref.read(storageProvider);

        // look for todo persist profile picture and add below code

        final user = await ref.read(userRepositoryProvider).getUser(friend.mobileNumber??"");
        if(user != null && user.profilePictureStorageId != null){

        Uint8List imageBytes = await _storage.getFilePreview(
        bucketId: Strings.profilePicturesBucketId,
        fileId: user.profilePictureStorageId ?? "",
        );
        friendContactRealm.base64Image = uint8ListToBase64(imageBytes);
        }
        

We need to set friend profile picture FriendsListScreen

Code Snippet(FriendsListScreen.dart):


            // change this line FriendImage(friend.mobileNumber!) to
            FriendImage(friend.base64Image),
        

We need to load base64 image in FriendImage FriendImage

Code Snippet(FriendImage.dart):


import 'package:chat_with_bisky/core/util/Util.dart';
// the final will be this one
class FriendImage extends StatelessWidget {
  final String? base64;
  final AppWriteClientService _clientService =
  KiwiContainer().resolve<AppWriteClientService>();

  FriendImage(this.base64, {super.key});

  @override
  Widget build(BuildContext context) {
    return getProfilePicture(base64);
  }

  getProfilePicture(String? base64) {


    return base64 != null ?CircleAvatar(
        backgroundImage: MemoryImage(base64ToUint8List(base64))):
    const CircleAvatar(
      backgroundImage: NetworkImage(Strings.avatarImageUrl),
    );
  }
}

We need to modify ChatMessageItemWe need to update chat head message read inside useEffect

Code Snippet(ChatMessageItem.dart):

  • lib/widget/ChatMessageItem.dart

import 'package:chat_with_bisky/model/MessageAppwrite.dart';

// Wrap this Column inside a Row with Expanded widget in buildChatLayout() method

            // remove below code
minWidth: context.media.size.width / 2,
maxWidth: context.media.size.width * 2 / 3,

            // when type is video or image lets replace the code (Stack)
 if (type == AttachmentType.video)
                    AsyncWidget(
                      value: ref.watch(thumbnailProvider(file.path)),
                      data: (data) => data != null
                          ? Stack(
                              fit: StackFit.passthrough,
                              alignment: Alignment.center,
                              children: [
                                ClipRRect(
                                  borderRadius: BorderRadius.circular(12),
                                  child: BubbleNormalImage(
                                    onTap: (){

                                    },
                                    id: message.sendDate.toString(),
                                    image: Image.file(
                                      File(data),
                                      fit: BoxFit.cover,
                                    ),
                                    color: myMessage?const Color(0xFF1B97F3):const Color(0xFFE8E8EE),
                                    // tail: true,
                                    isSender: myMessage,
                                  ) ,
                                ),
                                const Icon(
                                  Icons.play_arrow_rounded,
                                  size: 40,
                                  color: Colors.blue,
                                ),
                              ],
                            )
                          : const SizedBox(),
                    ),
                  if (type == AttachmentType.image)
                    ClipRRect(
                      borderRadius: BorderRadius.circular(12),
                      child:  BubbleNormalImage(
                        onTap: () =>  context.openImage(file.path),
                        id: message.sendDate.toString(),
                        image: Image.file(
                          file,
                          fit: BoxFit.cover,
                        ),
                        color: myMessage?const Color(0xFF1B97F3):const Color(0xFFE8E8EE),
                        isSender: myMessage,
                      ) ,
                    )

            // for type is text
 if (messageAppwrite.type == AttachmentType.text) {

        return BubbleNormal(
          text: messageAppwrite.message ?? "",
          isSender: myMessage,
          color: myMessage?const Color(0xFF1B97F3):const Color(0xFFE8E8EE),
          tail: true,
          textStyle: TextStyle(
            fontSize: 12,
            color:  myMessage?Colors.white:Colors.blue,
          ),
        );
      }

// remove the color of BoxDecoration
// replace this line message.sendDate!.formatDateTime, >> message.sendDate!.getFormattedTime(),
            // change time color and delivered color to grey
        
        

Let's write our algorithm to format time.

Code Snippet(extensions.dart):

  • lib/core/extensions/extensions.dart

import 'package:timeago/timeago.dart' as timeago;
String get formatDateTime =>  timeago.format(toLocal());
String getFormattedTime(){
    DateFormat formatter = DateFormat('HH:mm');
    String formattedText = getFormattedText();

    if(formattedText == 'Today'){

      return formatter.format(toLocal());
    }else  if(formattedText == 'Yesterday'){

      return 'Yesterday at ${formatter.format(toLocal())}';
    }else{
      return '$formattedText at ${formatter.format(toLocal())}';
    }
  }
  String getFormattedLastSeenTime(){
    DateFormat formatter = DateFormat('HH:mm');
    String formattedText = getFormattedText();

    if(formattedText == 'Today'){

      return 'last online at ${formatter.format(toLocal())}';
    }else  if(formattedText == 'Yesterday'){

      return 'last online yesterday at ${formatter.format(toLocal())}';
    }else{
      return 'last online on $formattedText at ${formatter.format(toLocal())}';
    }
  }



  String getFormattedText() {
    DateFormat formatter = DateFormat('yyyy-MM-dd');
    final now = DateTime.now();
    if (formatter.format(now) == formatter.format(toLocal())) {
      return 'Today';
    } else if (formatter
        .format(DateTime(now.year, now.month, now.day - 1)) ==
        formatter.format(toLocal())) {
      return 'Yesterday';
    } else {
      return '${DateFormat('d').format(toLocal())} ${DateFormat('MMMM').format(toLocal())} ${DateFormat('y').format(toLocal())}';
    }
  }
        
        

Conclusion:

Congratulations on successfully implementing the Chat List Screen with user typing status, delivery and message read ticks, and time indicators! You've come a long way from the start of this tutorial, and now you have a fully functional and impressive chat interface at your fingertips.

Throughout this journey, we covered essential concepts and techniques, allowing you to create an engaging user experience. By incorporating real-time typing status, delivery and read ticks, and time indicators, you've added a layer of dynamism and interactivity to your chat application.

Remember, continuous learning is the key to becoming a proficient developer. Keep exploring and experimenting with different features and functionalities to expand your skills even further. Stay up-to-date with the latest trends and updates in the world of app development, and don't hesitate to dive into new technologies.

If you encountered any challenges during this tutorial, remember that problem-solving is an integral part of the development process. Embrace challenges as opportunities to learn and grow as a developer.

Don't forget to maintain clean and organized code for easier maintenance and future enhancements. Well-structured code is not only beneficial for you but also for other developers who might collaborate with you on the project.

Lastly, share your creations with the world! Showcase your newly developed chat application to your friends, colleagues, or even potential employers. Who knows, your project might inspire others and lead to exciting opportunities.

Thank you for joining us on this journey of learning and discovery. We hope this tutorial has been helpful in expanding your knowledge and sparking your creativity. Stay tuned for more exciting tutorials on our channel, and don't forget to like, subscribe, and hit the notification bell to stay updated with our latest content.