Enhancing your Flutter Chat App with Awesome Features

Enhancing your Flutter Chat App with Awesome Features

To give your Flutter chat app that extra wow factor, use QuickBlox Flutter chat SDK to add fun features like polls, stickers, and message reactions.

·

16 min read

With hundreds of chat apps already on the market, developers are looking for ways to integrate additional fun features to make their apps stand out from the rest. The QuickBlox Flutter SDK enables you to build a powerful chat application in no time at all. Furthermore, you can extend the functionalities of the QuickBlox SDK to add all of these exciting features.

In a previous series of tutorials, we provide a step-by-step guide to adding polls and surveys, reaction-to-messages, and gifs and stickers. In the following article, we provide a summary of these how-to guides and then reflect on the key lessons learned through the process of building a Flutter chat app with awesome features.

Adding Polls, Messages Reactions, and Stickers to your Chat App

Polls in chat apps can be used for a variety of purposes, such as gathering opinions on a topic, making decisions as a group, or just for fun. This feature is designed to add a level of interaction and engagement to chats and make conversations more dynamic.

Message reactions usually involve clicking on an emoji or a symbol that corresponds to a specific reaction, such as a heart for "like", a laughing face for "haha", or a thumbs up for "approval". The reaction is then displayed next to the message and other users in the chat can see how many people have reacted. This feature is designed to add more context and emotions to conversations, making them more engaging and interactive.

Stickers and GIFs in chat apps are a way for users to add emotions, expressions, and humor to their conversations. Stickers are usually static images or illustrations that express an emotion or convey a message, while GIFs are short, animated images that play in a loop.

All three features can be used to add a fun or lighthearted touch to conversations. They are designed to make chats more engaging and interactive, and they provide a way for users to express themselves beyond just text. Many chat apps have a library of stickers and GIFs to choose from, and some also allow users to create and share their own. These kinds of features can be essential if you are wanting to ensure high app retention rates.

Using QuickBlox Flutter SDK

Building a Flutter chat app with exciting features from scratch takes a lot of development time and money. But there are effective ways you can save on resources without compromising on quality and performance.

The QuickBlox Flutter SDK is a software development kit for building real-time communication applications in the Flutter framework. It provides a set of APIs for integrating various communication features such as instant messaging, video and audio calling, and push notifications into a Flutter app. Furthermore, you can extend its functionalities to enable users to use polls, message reactions and stickers in their chat messaging. We provided detailed tutorials on how to do this in a series of earlier blogs:

Read on for a summary of these tutorials and to discover valuable lessons learned when attempting to add this functionality.

Create Fun Polls and Surveys in your Flutter Chat App

In our previous tutorial, we developed poll and survey capability, which allows a user within a group chat to generate a poll, which all members can then cast their votes on. The voting results are updated instantly and the percentage of votes received by each option is displayed.

For this functionality, we used 3 data models: PollActionCreate, PollActionVote, and PollMessage. The first two models are used to create and vote on the polls. The third model is used to combine and convert the received message and votes into one single entity.

We utilized a custom object when transmitting the poll to construct a message for holding the poll votes (further information about this methodology will be covered in the subsequent section). Upon someone casting their vote, the custom object is updated and a message indicating a poll vote is sent to inform the group members.

Future<void> sendCreatePollMessage(String? dialogId,
    {required PollActionCreate data}) async {
  if (dialogId == null) {
    throw RepositoryException(_parameterIsNullException,
        affectedParams: ["dialogId"]);
  }

  ///Creates the poll record and returns a single custom object
  final List<QBCustomObject?> pollObject =
      await QB.data.create(className: 'Poll', fields: data.toJson());
  final pollID = pollObject.first!.id!;

  ///Sends an empty text message without body with the poll action and ID 
  await QB.chat.sendMessage(
    dialogId,
    saveToHistory: true,
    markable: true,
    properties: {"action": "pollActionCreate", "pollID": pollID},
  );
}

Future<void> sendVotePollMessage(String? dialogId,
      {required PollActionVote data, required String currentUserID}) async {
    if (dialogId == null) {
      throw RepositoryException(_parameterIsNullException,
          affectedParams: ["dialogId"]);
    }
    ///Updates the updated Votes value in the poll record.
    await QB.data.update("Poll", id: data.pollID, fields: data.updatedVotes);

    ///Sends a message to notify clients 
    await QB.chat.sendMessage(
      dialogId,
      markable: true,
      properties: {"action": "pollActionVote", "pollID": data.pollID},
    );
  }

At the recipient's end, we determine if the message is a poll message by examining the parameter stored in the message properties. Then, we fetch the related custom object, merge it, and transform it into a PollMessage, in the following manner:

//Check if the message is a poll create message
if (message.properties?['action'] == 'pollActionCreate') {

    //Fetch the corresponding custom object
    final pollObject = await _chatRepository.getCustomObject(
        ids: [message.properties!['pollID']!], className: "Poll");

    //Convert it into a poll message
    final poll = PollMessage.fromCustomObject(
        senderName, message, _localUserId!, pollObject!.first!);

    //Add the Poll Message to the list of messages, to display in UI.
    wrappedMessages.add(poll);
}

Check out the complete article with code snippets to learn more.

Adding Message Reactions to Flutter Chat

In the second tutorial, we outlined how to build a react-to-message feature, whereby users in a chat group can use emoticons to react to text messages in real time.

For this project, we needed 3 data models: MessageReactProperties, MessageActionReact, and ReactionMessage. The first two models are used to send and react to a message. The third model contains the message and reactions, which are to be displayed in the UI.

For sending a standard text message, we also created a corresponding custom object to store the reactions to that message. When users react, we update the custom object and send a reaction message to the group to inform the other members.

Future<void> sendMessage(
  String? dialogId,
  String messageBody, {
  Map<String, String>? properties,
  required MessageReactProperties reactProperties,
}) async {
  if (dialogId == null) {
    throw RepositoryException(_parameterIsNullException,
        affectedParams: ["dialogId"]);
  }

  //Create custom object to hold reactions for the message
  final List<QBCustomObject?> reactObject = await QB.data.create(
    className: 'Reaction',
    fields: reactProperties.toJson(),
  );

  //Get the id of custom object
  final messageReactId = reactObject.first!.id!;

  //Add the id to message properties
  properties ??= <String, String>{};
  properties['messageReactId'] = messageReactId;

  //Send message
  await QB.chat.sendMessage(
    dialogId,
    body: messageBody,
    saveToHistory: true,
    markable: true,
    properties: properties,
  );
}

Future<void> sendReactMessage(
  String? dialogId, {
  required MessageActionReact data,
}) async {
  if (dialogId == null) {
    throw RepositoryException(_parameterIsNullException,
        affectedParams: ["dialogId"]);
  }

  await QB.data.update(
    "Reaction",
    id: data.messageReactId,
    fields: data.updatedReacts,
  );

  await QB.chat.sendMessage(
    dialogId,
    markable: true,
    properties: {
      "action": "messageActionReact",
      "messageReactId": data.messageReactId
    },
  );
}

At the recipient's end, we determine if the message contains a messageReactID and fetch the reactions from the custom objects, combining them to construct a ReactionMessage as follows:

if (message.properties?['messageReactId'] != null) {

  //Get the ID out of react message.
  final id = message.properties!['messageReactId']!;
  try {
  //Get the custom object and add the Reaction Message.
    final reactObject = await _chatRepository
        .getCustomObject(ids: [id], className: 'Reaction');
    if (reactObject != null) {
      wrappedMessages.add(
        ReactionMessage.fromCustomObject(
          senderName,
          message,
          _localUserId!,
          reactObject.first!,
        ),
      );
    }
  } catch (e) {
    wrappedMessages
        .add(QBMessageWrapper(senderName, message, _localUserId!));
  }

After creating the ReactionMessage object to hold the message and reactions, we developed a simple user interface to display it.

Want to find out more? Read the complete article.

Boosting your Flutter Chat App with Gifs and Stickers

Our third tutorial extended Quickblox SDK functionalities by adding support for sticker and gif functionality to a chat app. The ability to send animated and still stickers to a chat engages users and makes messages more fun and personalized. For this project, we relied on the Stipop Flutter SDK, which provides over 150,000 .png and .gif stickers. Rather than consuming Stipop APIs and building UI on our own (which is time-consuming), we integrated their SDK directly.

We began by integrating the Stipop SDK and making Android and iOS-specific changes. We built two data models. The first model, StickerMessageProperties holds the properties of stickers, and the second model, StickerMessage, converts an incoming message to a Sticker message respectively.

class StickerMessageProperties {
  final String stickerImgUrl;

  StickerMessageProperties({
    required this.stickerImgUrl,
  });

  Map<String, String> toJson() {
    return {
      "stickerImgUrl": stickerImgUrl,
      "action" : "messageActionSticker",
    };
  }

  factory StickerMessageProperties.fromData(String stickerImage) {
    return StickerMessageProperties(
      stickerImgUrl: stickerImage,
    );
  }
}
import 'package:quickblox_polls_feature/models/message_wrapper.dart';
import 'package:quickblox_sdk/models/qb_message.dart';

class StickerMessage extends QBMessageWrapper {
  StickerMessage(
    super.senderName,
    super.message,
    super.currentUserId, {
    required this.stickerImgUrl,
  });

  final String stickerImgUrl;

  factory StickerMessage.fromMessage(
      String senderName, QBMessage message, int currentUserId) {
    return StickerMessage(
      senderName,
      message,
      currentUserId,
      stickerImgUrl: message.properties!['stickerImgUrl']!,
    );
  }
  StickerMessage copyWith({String? stickerImgUrl}) {
    return StickerMessage(
      senderName!,
      qbMessage,
      currentUserId,
      stickerImgUrl: stickerImgUrl ?? this.stickerImgUrl,
    );
  }
}

The Stipop SDK alerts us whenever a user taps on a sticker, prompting us to send a sticker message with its properties saved in the StickerMessageProperties model.

On the recipient’s end, when a message is received, we verify whether it’s a sticker message by examining the action parameter in the properties of the message and displaying it accordingly.

Future<void> sendStickerMessage(
  String? dialogId, {
  required StickerMessageProperties data,
}) async {
  if (dialogId == null) {
    throw RepositoryException(_parameterIsNullException,
        affectedParams: ["dialogId"]);
  }

  //Sending sticker message
  await QB.chat.sendMessage(
    dialogId,
    saveToHistory: true,
    markable: true,
    properties: data.toJson(),
  );
}
if (message.properties?['action'] == 'messageActionSticker') {
  //Converting received message into Sticker
  //Adding sticker in the list to display in UI
  wrappedMessages.add(
    StickerMessage.fromMessage(senderName, message, _localUserId!),
);

For more information, please refer to the full article.

Lessons Learned

Whenever you add features to a chat app there are always different approaches you can take. Often choosing one approach over another can involve some kind of trade-off. The ultimate goal, however, is always to find the most optimal option. In this final section, we reflect on some of the different approaches we explored and the trade-offs we made and suggest the best ways to optimize the codebase and make it cleaner and more efficient.

Use of saveToHistory parameter

When we added poll functionality to our app, we built two data models, one to create polls and one to vote on a poll.

Initially, we assumed that both creating a poll and voting on a poll technically involved sending a message. The difference lay in how we processed and combined the votes with the poll upon receiving the messages.

This meant we treated votes as normal messages and sent them like other standard messages (with the saveToHistory parameter passed as true).

Initially, this approach worked well, however as the number of polls and votes in the chat increased, we realized that excessive pagination was occurring.

Let’s provide an example of what we mean.

Suppose we create a poll and 15 members in a group chat vote. Each vote is technically considered a message, which means that in total we have 15 vote messages and 1 poll creation message. Even though we would only display one single message in the UI for the complete poll (creation and votes), there are still 16 messages acting in the background, however, and this behavior affects the performance during pagination.

Using saveToHistory for every message like votes and reactions that are not meant to be displayed in the UI is problematic because it means when you’re dealing with a large chat group, excessive pagination may occur to fetch only a small number of valuable messages to be rendered in the UI.

In conclusion, the saveToHistory parameter should be used sparingly and set to true only for the messages that will be displayed in the UI.

Use of Custom Objects

By this stage, we realized the inappropriateness of using the saveToHistory parameter to store poll votes and reactions in history. The next step was to find an alternative solution. After reviewing the Quickblox documentation, we discovered custom objects as a potential solution.

Learn more about: What are custom objects and how they can benefit your application

Custom Objects are essentially cloud-based key-value objects that allow for synchronization and data updates. We planned to utilize these objects to store votes and reactions and directly fetch/update the data upon receipt of a new message.

The challenge was how to notify other group members of changes when someone votes or reacts to a message if we just sync and update the custom objects. The solution was straightforward. We send a message to the group, but with the saveToHistory parameter set to false, which will only alert group members of the new notification without it being included in pagination.

Reducing the chat loading time

Presently, upon receiving a new message, we determine if it is a Poll Create, a Poll Vote, or a reaction message. Then, we access the relevant Custom object to fetch reactions (for reaction messages) or votes (for poll messages). The code currently appears as follows:

Future<List<QBMessageWrapper>> _wrapMessages(
    List<QBMessage?> messages) async {
  List<QBMessageWrapper> wrappedMessages = [];
  for (QBMessage? message in messages) {
    if (message == null) {
      break;
    }

    QBUser? sender = _getParticipantById(message.senderId);
    if (sender == null && message.senderId != null) {
      List<QBUser?> users =
          await _usersRepository.getUsersByIds([message.senderId!]);
      if (users.isNotEmpty) {
        sender = users[0];
        _saveParticipants(users);
      }
    }
    String senderName = sender?.fullName ?? sender?.login ?? "DELETED User";

    //Poll Vote
    if (message.properties?['action'] == 'pollActionVote') {
      final id = message.properties!['pollID']!;
      final pollObject =
          await _chatRepository.getCustomObject(ids: [id], className: "Poll");
      final votes = Map<String, String>.from(
          jsonDecode(pollObject!.first!.fields!['votes'] as String));
      final pollMessage = _wrappedMessageSet.firstWhere(
              (element) => element is PollMessage && element.pollID == id)
          as PollMessage;
      _wrappedMessageSet.removeWhere(
          (element) => element is PollMessage && element.pollID == id);
      wrappedMessages.add(pollMessage.copyWith(votes: votes));

      //Poll Create
    } else if (message.properties?['action'] == 'pollActionCreate') {
      final pollObject = await _chatRepository.getCustomObject(
          ids: [message.properties!['pollID']!], className: "Poll");
      final poll = PollMessage.fromCustomObject(
          senderName, message, _localUserId!, pollObject!.first!);

      wrappedMessages.add(poll);

      //Reaction message
    } else if (message.properties?['action'] == 'messageActionReact') {
      final id = message.properties!['messageReactId']!;
      try {
        final reactObject = await _chatRepository
            .getCustomObject(ids: [id], className: 'Reaction');
        if (reactObject != null) {
          final reacts = Map<String, String>.from(
            jsonDecode(reactObject.first!.fields!['reacts'] as String),
          );
          final reactMessage = _wrappedMessageSet.firstWhere((element) =>
                  element is ReactionMessage && element.messageReactId == id)
              as ReactionMessage;
          _wrappedMessageSet.removeWhere((element) =>
              element is ReactionMessage && element.messageReactId == id);
          wrappedMessages.add(
            reactMessage.copyWith(reacts: reacts),
          );
        }
      } catch (e) {
        wrappedMessages
            .add(QBMessageWrapper(senderName, message, _localUserId!));
      }

      //Message with react support
    } else if (message.properties?['messageReactId'] != null) {
      final id = message.properties!['messageReactId']!;
      try {
        final reactObject = await _chatRepository
            .getCustomObject(ids: [id], className: 'Reaction');
        if (reactObject != null) {
          wrappedMessages.add(
            ReactionMessage.fromCustomObject(
              senderName,
              message,
              _localUserId!,
              reactObject.first!,
            ),
          );
        }
      } catch (e) {
        wrappedMessages
            .add(QBMessageWrapper(senderName, message, _localUserId!));
      }
    } else if (message.properties?['action'] == 'messageActionSticker') {
      wrappedMessages.add(
        StickerMessage.fromMessage(senderName, message, _localUserId!),
      );
    } else {
      wrappedMessages
          .add(QBMessageWrapper(senderName, message, _localUserId!));
    }
  }
  return wrappedMessages;
}

This behavior works well for small chat groups, but as the chat grows, it takes longer to load due to the calculation being performed for each message.

Imagine a chat with 100 messages. Upon receipt, we fetch 100 corresponding custom objects, which takes a considerable amount of time. Let's examine how to optimize this process.

Let's go to the chat_repository file, where we have the method to fetch the custom object(s).

Future<List<QBCustomObject?>?> getCustomObject(
    {required List<String> ids, required String className}) {
  return QB.data.getByIds(className, ids);
}

The method takes a string and a list of IDs and returns the corresponding list of custom objects. To avoid loading these objects one by one, we can fetch the entire category at once.

For example, if we need to fetch 20 poll messages and 80 reaction messages, it will only require 2 attempts instead of 100, making it much more efficient. 😉

Let’s summarize the key steps:

  1. Create two lists, one to hold the polls, and one to hold the reaction messages

  2. Create two additional lists to hold the IDs of custom objects for the polls and reaction messages respectively.

  3. After the loop, we iterate through these lists and fetch the custom objects in one go for each category. Then, we add them to the list of messages to be displayed in the user interface.

Future<List<QBMessageWrapper>> _wrapMessages(
    List<QBMessage?> messages) async {
  List<String> pollMessageCustomObjectIds = [];
  List<QBMessage> pollMessages = [];
  List<QBMessage> reactionMessages = [];
  List<String> reactionMessageCustomObjectIds = [];
  List<QBMessageWrapper> wrappedMessages = [];
  for (QBMessage? message in messages) {
    if (message == null) {
      break;
    }

    QBUser? sender = _getParticipantById(message.senderId);
    if (sender == null && message.senderId != null) {
      List<QBUser?> users =
          await _usersRepository.getUsersByIds([message.senderId!]);
      if (users.isNotEmpty) {
        sender = users[0];
        _saveParticipants(users);
      }
    }
    String senderName = sender?.fullName ?? sender?.login ?? "DELETED User";

    //Poll Vote
    if (message.properties?['action'] == 'pollActionVote') {
      final id = message.properties!['pollID']!;
      final pollObject =
          await _chatRepository.getCustomObject(ids: [id], className: "Poll");
      final votes = Map<String, String>.from(
          jsonDecode(pollObject!.first!.fields!['votes'] as String));
      final pollMessage = _wrappedMessageSet.firstWhere(
              (element) => element is PollMessage && element.pollID == id)
          as PollMessage;
      _wrappedMessageSet.removeWhere(
          (element) => element is PollMessage && element.pollID == id);
      wrappedMessages.add(pollMessage.copyWith(votes: votes));

      //Poll Create
    } else if (message.properties?['action'] == 'pollActionCreate') {
      pollMessages.add(message);
      pollMessageCustomObjectIds.add(message.properties!['pollID']!);

      //Reaction message
    } else if (message.properties?['action'] == 'messageActionReact') {
      final id = message.properties!['messageReactId']!;
      try {
        final reactObject = await _chatRepository
            .getCustomObject(ids: [id], className: 'Reaction');
        if (reactObject != null) {
          final reacts = Map<String, String>.from(
            jsonDecode(reactObject.first!.fields!['reacts'] as String),
          );
          final reactMessage = _wrappedMessageSet.firstWhere((element) =>
                  element is ReactionMessage && element.messageReactId == id)
              as ReactionMessage;
          _wrappedMessageSet.removeWhere((element) =>
              element is ReactionMessage && element.messageReactId == id);
          wrappedMessages.add(
            reactMessage.copyWith(reacts: reacts),
          );
        }
      } catch (e) {
        wrappedMessages
            .add(QBMessageWrapper(senderName, message, _localUserId!));
      }

      //Message with react support
    } else if (message.properties?['messageReactId'] != null) {
      reactionMessageCustomObjectIds
          .add(message.properties!['messageReactId']!);
      reactionMessages.add(message);
    } else if (message.properties?['action'] == 'messageActionSticker') {
      wrappedMessages.add(
        StickerMessage.fromMessage(senderName, message, _localUserId!),
      );
    } else {
      wrappedMessages
          .add(QBMessageWrapper(senderName, message, _localUserId!));
    }
  }

  //POLLS
  if (pollMessageCustomObjectIds.isNotEmpty) {

    //Fetch all the custom objects for poll messages.
    final pollObjects = await _chatRepository.getCustomObject(
            ids: pollMessageCustomObjectIds, className: 'Poll') ??
        [];
    for (var element in pollObjects) {
      final message = messages.firstWhere(
          (message) => message?.properties!['pollID'] == element?.id,
          orElse: () => null);
      if (message != null) {
        QBUser? sender = _getParticipantById(message.senderId);
        if (sender == null && message.senderId != null) {
          List<QBUser?> users =
              await _usersRepository.getUsersByIds([message.senderId!]);
          if (users.isNotEmpty) {
            sender = users[0];
            _saveParticipants(users);
          }
        }
        String senderName =
            sender?.fullName ?? sender?.login ?? "DELETED User";

        //Parse the poll message and add to the list of messages
        final pollMessage = PollMessage.fromCustomObject(
            senderName, message, _localUserId!, element!);
        wrappedMessages.add(pollMessage);
      }
    }
  }

  //REACTIONS
  if (reactionMessageCustomObjectIds.isNotEmpty) {

    //Fetch all the custom objects for reaction messages.
    final reactionObjects = await _chatRepository.getCustomObject(
            ids: reactionMessageCustomObjectIds, className: 'Reaction') ??
        [];
    for (var element in reactionObjects) {
      final message = messages.firstWhere(
          (message) => message?.properties!['messageReactId'] == element?.id,
          orElse: () => null);
      if (message != null) {
        QBUser? sender = _getParticipantById(message.senderId);
        if (sender == null && message.senderId != null) {
          List<QBUser?> users =
              await _usersRepository.getUsersByIds([message.senderId!]);
          if (users.isNotEmpty) {
            sender = users[0];
            _saveParticipants(users);
          }
        }
        String senderName =
            sender?.fullName ?? sender?.login ?? "DELETED User";

        //Parse the reaction message and add to the list of messages
        final reactionMessage = ReactionMessage.fromCustomObject(
            senderName, message, _localUserId!, element!);
        wrappedMessages.add(reactionMessage);
      }
    }
  }

  return wrappedMessages;
}

In the code above, we gather the IDs of polls and reaction messages, fetch their relevant custom objects in one fetch, and then parse them into PollMessage and ReactionMessage objects.

It’s important to note that we continue to treat poll votes and message reactions separately. That is because when we receive a poll vote or a reaction, we want to update the old message which is present in the list to keep the UI current.

You might assume that the code has become lengthier and we are performing more operations, but in reality, we are performing synchronous operations instead of asynchronous ones. This switch to synchronous operations results in faster and more efficient processing.

Final Words

Although we’ve covered a lot in our tutorial series on how to build a flutter chat app with awesome features there are still many areas we could develop and approve. For example, we could work on:

  • Building a better UI code to handle all possible message categories in one place.

  • Avoiding the need to fetch the custom object when we receive a vote or react message by passing the updated values with message properties.

Perhaps you’ve got some ideas of your own? Please let us know in the comments below.

Join the QuickBlox Dev Discord Community ! Share ideas, learn about software, and get support.