==

Q-Consultation for every industry

Securely hold virtual meetings and video conferences

Learn More>

Want to learn more about our products and services?

Speak to us now

How to Create Polls & Surveys in your Flutter Chat App using QuickBlox

Hitesh Garg
21 Dec 2022
mobile phone interface showing a poll

When you build chat using QuickBlox SDKs you can add a variety of stunning features including private, public, and group chats, media rich attachments, message read status, presence indicator, push notifications and many more. Have you ever wondered how you could use Quickblox SDK to add even more functionalities to your app or website?

In particular, polls and surveys can be an effective way to make your chat application more interactive and engaging for users. Not only do they provide the opportunity for users to share their input and opinions on different topics, but they can also give you valuable feedback and insights that you can use to improve the user experience.

So why not encourage your users to participate in polls and surveys and get the conversation started?

In this article, we’ll take it up a notch and learn how we can utilize the QuickBlox SDK to create polls and surveys in your Flutter chat application.

flutter chat app

Table of Contents

  1. Initial Set-up
  2. Quickblox Custom Objects and Data Models
  3. Writing the logic to support polls
  4. Constructing the UI
  5. Putting it all together
  6. Optional Steps
  7. Initial Set-up

    In order to get started quickly with the polls functionality, we are taking code from the official QuickBlox Flutter chat sample application. This template application consists of three screens: Splash, Login, and Chat. On the Chat screen, the _dialogId is hard-coded so that you can directly access the required Group chat.

    To find the template application code, please visit the link here.

    1. Sign-up for a QuickBlox account if you don’t already have one. You can sign in with either your Google or GitHub account.
    2. To create an app, click the ‘New app‘ button.
    3. Input the necessary information about your organization into the corresponding fields and click ‘Add‘.
    4. To get your Application ID, Authorization Key, Authorization Secret, and Account Key go to the ‘Overview‘ section of your app in the Dashboard.

    Once you have the application credentials, paste them into the main.dart file found in the template application. Once this is done correctly, run the application using the following commands:

    flutter packages get
    flutter run 
    

    You now have a basic chat application up and running. Well done!

    Quickblox Custom Objects and Data Models

    To start off, let’s take a look at the data models offered by Quickblox. After that, we can move on to creating our own models to enhance the functionality and create polls.

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

    Understanding Data Models

    QBMessage is a data object provided by Quickblox that stores an id(identifier), a body(text message), properties(extra metadata), etc.

    We have also created QBMessageWrapper, a wrapper around the QBMessage that contains additional fields such as a senderName and date, etc details, which makes it easier to display the message data on the chat screen.

    The properties parameter in QBMessage is useful for rendering static text messages, locations, and web links, but it cannot be used to host interactive polls that change over time.

    However, Quickblox offers Custom Objects as an ideal solution for hosting polls. To create the Poll class, go to Quickblox Dashboard > Custom > Add > Add New Class and set up the custom schema.

    Add Class to Chat App

    Once created, open the Edit Permissions window and adjust the permission level and checkboxes accordingly.

    Create Poll in Chat App

    Without granting open permissions, users will unable to update the Poll values from the app.

    Therefore, we will create two classes – PollActionCreate and PollActionVote.

    The PollActionCreate class will contain the poll title and options, and we will use the UUID package to generate and assign a unique ID to each option value. The toJson method will return the values that are mapped to our Poll object schema.

    The PollActionVote class will store the pollID, the existing votes, and the chosenOption by the currentUser. It will also have a getter, updatedVotes, which will recalculate the vote with the user-chosen option and return the final values.

    Since Quickblox custom object does not support the Map datatype, all the Map values will be jsonEncoded into a string.

    import 'dart:convert';
    import 'package:uuid/uuid.dart';
    
    class PollActionCreate {
      PollActionCreate({
        required this.pollTitle,
        required this.pollOptions,
      });
    
      final String pollTitle;
      final Map pollOptions;
    
      factory PollActionCreate.fromData(
        String title,
        List options,
      ) {
        const uuid = Uuid();
        return PollActionCreate(
          pollTitle: title,
          pollOptions: {for (var element in options) uuid.v4(): element},
        );
      }
      Map toJson() {
        return {
          "title": pollTitle,
          "options": jsonEncode(pollOptions),
          "votes": jsonEncode({})
        };
      }
    }
    
    class PollActionVote {
      const PollActionVote(
          {required this.pollID,
          required this.votes,
          required this.currentUserID,
          required this.choosenOptionID});
      final String pollID;
      final Map votes;
      final String choosenOptionID;
      final String currentUserID;
    
      Map get updatedVotes {
        votes[currentUserID] = choosenOptionID;
        return {"votes": jsonEncode(votes)};
      }
    }
    
    

    We also need a model that will come in handy when we receive the data, in addition to the models used to parse and send the data.

    We will create a class PollMessage, which extends QBMessageWrapper to contain all properties that are specific to polls.

    import 'dart:convert';
    
    import 'package:quickblox_polls_feature/models/message_wrapper.dart';
    import 'package:quickblox_sdk/models/qb_custom_object.dart';
    import 'package:quickblox_sdk/models/qb_message.dart';
    
    class PollMessage extends QBMessageWrapper {
      PollMessage(super.senderName, super.message, super.currentUserId,
          {required this.pollID,
          required this.pollTitle,
          required this.options,
          required this.votes});
    
      final String pollID;
      final String pollTitle;
      final Map options;
      final Map votes;
    
      factory PollMessage.fromCustomObject(String senderName, QBMessage message,
          int currentUserId, QBCustomObject object) {
        return PollMessage(senderName, message, currentUserId,
            pollID: message.properties!['pollID']!,
            pollTitle: object.fields!['title'] as String,
            options: Map.from(
                jsonDecode(object.fields!['options'] as String)),
            votes: Map.from(
                jsonDecode(object.fields!['votes'] as String)));
      }
      PollMessage copyWith({Map? votes}) {
        return PollMessage(senderName!, qbMessage, currentUserId,
            pollID: pollID,
            pollTitle: pollTitle,
            options: options,
            votes: votes ?? this.votes);
      }
    }
    

    With this, we have the models ready. Now let’s start coding!

    Writing the logic to support polls

    In this section, we will implement the logic to create and vote on a poll using our custom-made Poll object and the properties param from a normal message. We are using the Bloc pattern, which means that when a CreatePollMessageEvent and VoteToPollEvent are received from the UI interface, a repository call is triggered to communicate with the Quickblox servers.

    if (receivedEvent is SendMessageEvent) {
     // SOME PRE WRITTEN CODE PRESENT HERE.
    }
    if (receivedEvent is CreatePollMessageEvent) {
      try {
        await _chatRepository.sendStoppedTyping(_dialogId);
        await Future.delayed(const Duration(milliseconds: 300), () async {
          await _sendCreatePollMessage(data: receivedEvent.data);
        });
      } on PlatformException catch (e) {
        states?.add(
          SendMessageErrorState(
            makeErrorMessage(e),
            'Can\'t create poll',
          ),
        );
      } on RepositoryException catch (e) {
        states?.add(SendMessageErrorState(e.message, 'Can\'t create poll'));
      }
    }
    if (receivedEvent is VoteToPollEvent) {
      try {
        await _chatRepository.sendStoppedTyping(_dialogId);
        await Future.delayed(const Duration(milliseconds: 300), () async {
          await _sendVotePollMessage(data: receivedEvent.data);
        });
      } on PlatformException catch (e) {
        states?.add(
          SendMessageErrorState(
            makeErrorMessage(e),
            'Can\'t vote poll',
          ),
        );
      } on RepositoryException catch (e) {
        states?.add(SendMessageErrorState(e.message, 'Can\'t vote poll'));
      }
    }
    
    Future _sendCreatePollMessage({required PollActionCreate data}) async {
      await _chatRepository.sendCreatePollMessage(
        _dialogId,
        data: data,
      );
    }
    
    Future _sendVotePollMessage({required PollActionVote data}) async {
      await _chatRepository.sendVotePollMessage(
        _dialogId,
        data: data,
      );
    }
    

    We will add the following functions to chat_repository.dart:

    1. sendCreatePollMessage: registers a poll record and sends the pollID in the metadata of a chat message. This allows us to later retrieve the poll data using the pollID.

    Future 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 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},
        );
      }
    

    2. sendVotePollMessage: This function updates the poll record with the latest vote values. The only fields to be sent in the fields parameter are the votes. saveToHistroy is not enabled for this function as its sole purpose is to inform current clients that the poll values have been updated.

    This means that when chats are reopened, the latest poll values will already be fetched, eliminating the need for the pollActionVote message in the history.

    Note: saveToHistroy should be used wisely. Otherwise, when dealing with large groups of more than 100 people, we may need to paginate multiple times and end up with a series of useless pollActionVote messages.

    Future 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},
        );
      }
    

    3. getCustomObject : used to fetch the latest data state of a poll.

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

    If you want to learn more about parameters like markable and saveToHistory, you can refer to the helpful QuickBlox documentation. From the chat_screen_bloc.dart we are calling three methods from the repository and using try-catch blocks to catch and handle any unexpected errors.

    Note: Before calling the sendStoppedTyping method from the Chat Repository, we call the other methods to ensure that the UI on the receiver’s end no longer displays that we are typing.

    Now, we need to figure out what happens when we receive a new message. Where will it go? How will it get handled if it’s a poll or a vote to a poll? Let’s get this sorted out and then we’ll be done.

    In the chat_screen_bloc file we have a HashSet<QBMessageWrapper> _wrappedMessageSet which stores all messages sorted by time. We have a method _wrapMessages() which is responsible for wrapping the QBMesssage(s) in the List<QBMessageWrappers> when we receive new messages. To handle both pollActionCreate and pollActionVote incoming messages, this method needs to be updated.

    When a pollActionCreate occurs, we extract the pollID from the properties of the message and use getCustomObject to retrieve the poll record. We then construct a PollMessage object. For a pollActionVote, the id of the poll that has been updated is supplied. We use pollID to fetch the latest vote values and update the PollMessage object accordingly.

    ///Called whenever new messages are received.  
    Future> _wrapMessages(
          List messages) async {
        List wrappedMessages = [];
        for (QBMessage? message in messages) {
          if (message == null) {
            break;
          }
    
          QBUser? sender = _getParticipantById(message.senderId);
          if (sender == null && message.senderId != null) {
            List users =
                await _usersRepository.getUsersByIds([message.senderId!]);
            if (users.isNotEmpty) {
              sender = users[0];
              _saveParticipants(users);
            }
          }
          String senderName = sender?.fullName ?? sender?.login ?? "DELETED User";
          ///Fetch the latest poll object data using the pollID
          ///and update the PollMessage object with the new vote values
          if (message.properties?['action'] == 'pollActionVote') {
            final id = message.properties!['pollID']!;
            final pollObject =
                await _chatRepository.getCustomObject(ids: [id], className: "Poll");
            final votes = Map.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));
    
           ///Fetch the poll object associated with the pollID and save
           ///it as a PollMessage in the list.
          } 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);
          } else {
            wrappedMessages
                .add(QBMessageWrapper(senderName, message, _localUserId!));
          }
        }
    
        ///This list returned is then appended to _wrappedMessageSet
        return wrappedMessages;
      }
    

    The _wrappedMessageSet list now contains both regular and poll messages, which can be displayed on the UI.

    Constructing the UI

    Now that the data models and polls logic are ready, we should focus on constructing a visually appealing UI for our polls feature.

    Note: We decided to add the code manually and customize it as per our needs, since our polls UI was heavily inspired and built around the polls package.

    Create a new file, polls.dart, to hold everything related to the polls UI. In this file, create a PollOption model with a unique id, a string value, and a numeric value keeping count of how many people choose that option.

    
    class PollOption {
      String? optionId;
      String option;
      double value;
    
      PollOption({
        this.optionId,
        required this.option,
        required this.value,
      });
    }
    

    We need two widgets to form the basis of this. One widget will represent the unvoted state and the other will represent the voted state. Additionally, a boolean will be used to check if a person has voted or not. Therefore, let us start by creating a stateful widget with a few parameters.

    class Polls extends StatefulWidget {
      Polls({
        required this.children,
        required this.pollTitle,
        this.hasVoted,
        this.onVote,
        Key? key,
      }) : super(key: key);
    
      final Text pollTitle;
      final bool? hasVoted;
      final PollOnVote? onVote;
      List children;
    
      @override
      PollsState createState() => PollsState();
    }
    
    class PollsState extends State {
    @override
      Widget build(BuildContext context) {
        if (!hasVoted) {
          //user can cast vote with this widget
          return voterWidget(context);
        } else {
          //user can view his votes with this widget
          return voteCasted(context);
        }
      }
    
      /// voterWidget creates view for users to cast their votes
      Widget voterWidget(context) {
          return Container();
      }
    
      /// voteCasted created view for user to see votes they casted including other peoples vote
      Widget voteCasted(context) {
          return Container();
      }
    }
    
    typedef PollOnVote = void Function(
      PollOption pollOption,
      int optionIndex,
    );
    

    Adding the UI code for voterWidget and voteCasted widget is simple – just Rows and Columns with some decorations. We won’t spend too much time on explaining it, so that we can get to the more interesting part of combining the UI and Logic.

    class Polls extends StatefulWidget {
      Polls({
        required this.children,
        required this.pollTitle,
        this.hasVoted,
        this.controller,
        this.onVote,
        this.outlineColor = Colors.blue,
        this.backgroundColor = Colors.blueGrey,
        this.onVoteBackgroundColor = Colors.blue,
        this.leadingPollStyle,
        this.pollStyle,
        this.iconColor = Colors.black,
        this.leadingBackgroundColor = Colors.blueGrey,
        this.barRadius = 10,
        this.userChoiceIcon,
        this.showLogger = true,
        this.totalVotes = 0,
        this.userPollChoice,
        Key? key,
      }) : super(key: key);
    
      final double barRadius;
      int? userPollChoice;
    
      final int totalVotes;
    
      final Text pollTitle;
      final Widget? userChoiceIcon;
    
      final bool? hasVoted;
      final bool showLogger;
    
      final PollOnVote? onVote;
      List children;
      final PollController? controller;
    
      /// style
      final TextStyle? pollStyle;
      final TextStyle? leadingPollStyle;
    
      ///colors setting for polls widget
      final Color outlineColor;
      final Color backgroundColor;
      final Color? onVoteBackgroundColor;
      final Color? iconColor;
      final Color? leadingBackgroundColor;
    
      @override
      PollsState createState() => PollsState();
    }
    
    class PollsState extends State {
      PollController? _controller;
    
      var choiceList = [];
      var userChoiceList = [];
      var valueList = [];
      var userValueList = [];
    
      /// style
      late TextStyle pollStyle;
      late TextStyle leadingPollStyle;
    
      ///colors setting for polls widget
      Color? outlineColor;
      Color? backgroundColor;
      Color? onVoteBackgroundColor;
      Color? iconColor;
      Color? leadingBackgroundColor;
    
      double highest = 0.0;
    
      bool hasVoted = false;
    
      @override
      void initState() {
        super.initState();
    
        _controller = widget.controller;
        _controller ??= PollController();
        _controller!.children = widget.children;
        hasVoted = widget.hasVoted ?? _controller!.hasVoted;
    
        _controller?.addListener(() {
          if (_controller!.makeChange) {
            hasVoted = _controller!.hasVoted;
            _updateView();
          }
        });
    
        _reCalibrate();
      }
    
      void _updateView() {
        widget.children = _controller!.children;
        _controller!.revertChangeBoolean();
        _reCalibrate();
      }
    
      void _reCalibrate() {
        choiceList.clear();
        userChoiceList.clear();
        valueList.clear();
    
        /// if polls style is null, it sets default pollstyle and leading pollstyle
        pollStyle = widget.pollStyle ??
            const TextStyle(color: Colors.black, fontWeight: FontWeight.w300);
        leadingPollStyle = widget.leadingPollStyle ??
            const TextStyle(color: Colors.black, fontWeight: FontWeight.w800);
    
        widget.children.map((e) {
          choiceList.add(e.option);
          userChoiceList.add(e.option);
          valueList.add(e.value);
        }).toList();
      }
    
      @override
      Widget build(BuildContext context) {
        if (!hasVoted) {
          //user can cast vote with this widget
          return voterWidget(context);
        } else {
          //user can view his votes with this widget
          return voteCasted(context);
        }
      }
    
      /// voterWidget creates view for users to cast their votes
      Widget voterWidget(context) {
        return Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            widget.pollTitle,
            const SizedBox(
              height: 12,
            ),
            Column(
              children: widget.children.map((element) {
                int index = widget.children.indexOf(element);
                return Container(
                  width: double.infinity,
                  padding: const EdgeInsets.only(bottom: 10),
                  child: Container(
                    margin: const EdgeInsets.all(0),
                    width: MediaQuery.of(context).size.width / 1.5,
                    padding: const EdgeInsets.all(0),
                    // height: 38,
                    decoration: BoxDecoration(
                      borderRadius: BorderRadius.circular(22),
                      color: widget.backgroundColor,
                    ),
                    child: OutlinedButton(
                      onPressed: () {
                        widget.onVote!(
                          widget.children[index],
                          index,
                        );
                      },
                      style: OutlinedButton.styleFrom(
                        foregroundColor: widget.outlineColor,
                        padding: const EdgeInsets.all(5.0),
                        side: BorderSide(
                          color: widget.outlineColor,
                        ),
                        shape: RoundedRectangleBorder(
                          borderRadius: BorderRadius.circular(widget.barRadius),
                        ),
                      ),
                      child: Text(
                        element.option,
                        style: widget.pollStyle,
                        maxLines: 2,
                      ),
                    ),
                  ),
                );
              }).toList(),
            ),
          ],
        );
      }
    
      /// voteCasted created view for user to see votes they casted including other peoples vote
      Widget voteCasted(context) {
        return Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            widget.pollTitle,
            const SizedBox(
              height: 12,
            ),
            Column(
              children: widget.children.map(
                (element) {
                  int index = widget.children.indexOf(element);
                  return Container(
                    margin: const EdgeInsets.symmetric(vertical: 5),
                    width: double.infinity,
                    child: LinearPercentIndicator(
                      padding: EdgeInsets.zero,
                      animation: true,
                      lineHeight: 38.0,
                      animationDuration: 500,
                      percent:
                          PollMethods.getViewPercentage(valueList, index + 1, 1),
                      center: Padding(
                        padding: const EdgeInsets.symmetric(horizontal: 10),
                        child: Row(
                          mainAxisAlignment: MainAxisAlignment.spaceBetween,
                          children: [
                            Row(
                              mainAxisAlignment: MainAxisAlignment.start,
                              children: [
                                Text(
                                  choiceList[index].toString(),
                                  style: highest == valueList[index]
                                      ? widget.leadingPollStyle
                                      : widget.pollStyle,
                                ),
                              ],
                            ),
                            Text(
                              "${PollMethods.getViewPercentage(valueList, index + 1, 100).toStringAsFixed(1)}%",
                              style: highest == valueList[index]
                                  ? widget.leadingPollStyle
                                  : widget.pollStyle,
                            )
                          ],
                        ),
                      ),
                      barRadius: Radius.circular(widget.barRadius),
                      progressColor: highest == valueList[index]
                          ? widget.leadingBackgroundColor
                          : widget.onVoteBackgroundColor,
                    ),
                  );
                },
              ).toList(),
            )
          ],
        );
      }
    }
    
    class PollMethods {
      static double getViewPercentage(List valueList, choice, int byValue) {
        double div = 0.0;
        var slot = [];
        double sum = 0.0;
    
        valueList.map((element) {
          slot.add(element);
        }).toList();
    
        valueList.map((element) {
          sum = slot.map((value) => value).fold(0, (a, b) => a + b);
        }).toList();
        div = sum == 0 ? 0.0 : (byValue / sum) * slot[choice - 1];
        return div;
      }
    }
    
    class PollController extends ChangeNotifier {
      var children = [];
      bool hasVoted = false;
      bool makeChange = false;
    
      void revertChangeBoolean() {
        makeChange = false;
        notifyListeners();
      }
    }
    
    typedef PollOnVote = void Function(
      PollOption pollOption,
      int optionIndex,
    );
    

    We have created a PollController to store and update the UI when needed. To represent the number of votes for each option, we are using the LinearPercentIndicator widget from a package that helps us in building the UI more easily. The code for this is included below.

    import 'package:flutter/material.dart';
    
    // ignore: must_be_immutable
    class LinearPercentIndicator extends StatefulWidget {
      ///Percent value between 0.0 and 1.0
      final double percent;
      final double? width;
    
      ///Height of the line
      final double lineHeight;
    
      ///Color of the background of the Line , default = transparent
      final Color fillColor;
    
      ///First color applied to the complete line
      Color get backgroundColor => _backgroundColor;
      late Color _backgroundColor;
    
      ///First color applied to the complete line
      final LinearGradient? linearGradientBackgroundColor;
    
      Color get progressColor => _progressColor;
    
      late Color _progressColor;
    
      ///true if you want the Line to have animation
      final bool animation;
    
      ///duration of the animation in milliseconds, It only applies if animation attribute is true
      final int animationDuration;
    
      ///widget at the left of the Line
      final Widget? leading;
    
      ///widget at the right of the Line
      final Widget? trailing;
    
      ///widget inside the Line
      final Widget? center;
    
      ///The kind of finish to place on the end of lines drawn, values supported: butt, round, roundAll
      // @Deprecated('This property is no longer used, please use barRadius instead.')
      // final LinearStrokeCap? linearStrokeCap;
    
      /// The border radius of the progress bar (Will replace linearStrokeCap)
      final Radius? barRadius;
    
      ///alignment of the Row (leading-widget-center-trailing)
      final MainAxisAlignment alignment;
    
      ///padding to the LinearPercentIndicator
      final EdgeInsets padding;
    
      /// set true if you want to animate the linear from the last percent value you set
      final bool animateFromLastPercent;
    
      /// If present, this will make the progress bar colored by this gradient.
      ///
      /// This will override [progressColor]. It is an error to provide both.
      final LinearGradient? linearGradient;
    
      /// set false if you don't want to preserve the state of the widget
      final bool addAutomaticKeepAlive;
    
      /// set true if you want to animate the linear from the right to left (RTL)
      final bool isRTL;
    
      /// Creates a mask filter that takes the progress shape being drawn and blurs it.
      final MaskFilter? maskFilter;
    
      /// Set true if you want to display only part of [linearGradient] based on percent value
      /// (ie. create 'VU effect'). If no [linearGradient] is specified this option is ignored.
      final bool clipLinearGradient;
    
      /// set a linear curve animation type
      final Curve curve;
    
      /// set true when you want to restart the animation, it restarts only when reaches 1.0 as a value
      /// defaults to false
      final bool restartAnimation;
    
      /// Callback called when the animation ends (only if `animation` is true)
      final VoidCallback? onAnimationEnd;
    
      /// Display a widget indicator at the end of the progress. It only works when `animation` is true
      final Widget? widgetIndicator;
    
      LinearPercentIndicator({
        Key? key,
        this.fillColor = Colors.transparent,
        this.percent = 0.0,
        this.lineHeight = 5.0,
        this.width,
        Color? backgroundColor,
        this.linearGradientBackgroundColor,
        this.linearGradient,
        Color? progressColor,
        this.animation = false,
        this.animationDuration = 500,
        this.animateFromLastPercent = false,
        this.isRTL = false,
        this.leading,
        this.trailing,
        this.center,
        this.addAutomaticKeepAlive = true,
        // this.linearStrokeCap,
        this.barRadius,
        this.padding = const EdgeInsets.symmetric(horizontal: 10.0),
        this.alignment = MainAxisAlignment.start,
        this.maskFilter,
        this.clipLinearGradient = false,
        this.curve = Curves.linear,
        this.restartAnimation = false,
        this.onAnimationEnd,
        this.widgetIndicator,
      }) : super(key: key) {
        if (linearGradient != null && progressColor != null) {
          throw ArgumentError(
              'Cannot provide both linearGradient and progressColor');
        }
        _progressColor = progressColor ?? Colors.red;
    
        if (linearGradientBackgroundColor != null && backgroundColor != null) {
          throw ArgumentError(
              'Cannot provide both linearGradientBackgroundColor and backgroundColor');
        }
        _backgroundColor = backgroundColor ?? const Color(0xFFB8C7CB);
    
        if (percent < 0.0 || percent > 1.0) {
          throw Exception(
              "Percent value must be a double between 0.0 and 1.0, but it's $percent");
        }
      }
    
      @override
      LinearPercentIndicatorState createState() => LinearPercentIndicatorState();
    }
    
    class LinearPercentIndicatorState extends State
        with SingleTickerProviderStateMixin, AutomaticKeepAliveClientMixin {
      AnimationController? _animationController;
      Animation? _animation;
      double _percent = 0.0;
      final _containerKey = GlobalKey();
      final _keyIndicator = GlobalKey();
      double _containerWidth = 0.0;
      double _containerHeight = 0.0;
      double _indicatorWidth = 0.0;
      double _indicatorHeight = 0.0;
    
      @override
      void dispose() {
        _animationController?.dispose();
        super.dispose();
      }
    
      @override
      void initState() {
        WidgetsBinding.instance.addPostFrameCallback((_) {
          if (mounted) {
            setState(() {
              _containerWidth = _containerKey.currentContext?.size?.width ?? 0.0;
              _containerHeight = _containerKey.currentContext?.size?.height ?? 0.0;
              if (_keyIndicator.currentContext != null) {
                _indicatorWidth = _keyIndicator.currentContext?.size?.width ?? 0.0;
                _indicatorHeight =
                    _keyIndicator.currentContext?.size?.height ?? 0.0;
              }
            });
          }
        });
        if (widget.animation) {
          _animationController = AnimationController(
              vsync: this,
              duration: Duration(milliseconds: widget.animationDuration));
          _animation = Tween(begin: 0.0, end: widget.percent).animate(
            CurvedAnimation(parent: _animationController!, curve: widget.curve),
          )..addListener(() {
              setState(() {
                _percent = _animation!.value;
              });
              if (widget.restartAnimation && _percent == 1.0) {
                _animationController!.repeat(min: 0, max: 1.0);
              }
            });
          _animationController!.addStatusListener((status) {
            if (widget.onAnimationEnd != null &&
                status == AnimationStatus.completed) {
              widget.onAnimationEnd!();
            }
          });
          _animationController!.forward();
        } else {
          _updateProgress();
        }
        super.initState();
      }
    
      void _checkIfNeedCancelAnimation(LinearPercentIndicator oldWidget) {
        if (oldWidget.animation &&
            !widget.animation &&
            _animationController != null) {
          _animationController!.stop();
        }
      }
    
      @override
      void didUpdateWidget(LinearPercentIndicator oldWidget) {
        super.didUpdateWidget(oldWidget);
        if (oldWidget.percent != widget.percent) {
          if (_animationController != null) {
            _animationController!.duration =
                Duration(milliseconds: widget.animationDuration);
            _animation = Tween(
                    begin: widget.animateFromLastPercent ? oldWidget.percent : 0.0,
                    end: widget.percent)
                .animate(
              CurvedAnimation(parent: _animationController!, curve: widget.curve),
            );
            _animationController!.forward(from: 0.0);
          } else {
            _updateProgress();
          }
        }
        _checkIfNeedCancelAnimation(oldWidget);
      }
    
      _updateProgress() {
        setState(() {
          _percent = widget.percent;
        });
      }
    
      @override
      Widget build(BuildContext context) {
        super.build(context);
        var items = List.empty(growable: true);
        if (widget.leading != null) {
          items.add(widget.leading!);
        }
        final hasSetWidth = widget.width != null;
        final percentPositionedHorizontal =
            _containerWidth * _percent - _indicatorWidth / 3;
        var containerWidget = Container(
          width: hasSetWidth ? widget.width : double.infinity,
          height: widget.lineHeight,
          padding: widget.padding,
          child: Stack(
            clipBehavior: Clip.none,
            children: [
              CustomPaint(
                key: _containerKey,
                painter: _LinearPainter(
                  isRTL: widget.isRTL,
                  progress: _percent,
                  progressColor: widget.progressColor,
                  linearGradient: widget.linearGradient,
                  backgroundColor: widget.backgroundColor,
                  barRadius: widget.barRadius ??
                      Radius.zero, // If radius is not defined, set it to zero
                  linearGradientBackgroundColor:
                      widget.linearGradientBackgroundColor,
                  maskFilter: widget.maskFilter,
                  clipLinearGradient: widget.clipLinearGradient,
                ),
                child: (widget.center != null)
                    ? Center(child: widget.center)
                    : Container(),
              ),
              if (widget.widgetIndicator != null && _indicatorWidth == 0)
                Opacity(
                  opacity: 0.0,
                  key: _keyIndicator,
                  child: widget.widgetIndicator,
                ),
              if (widget.widgetIndicator != null &&
                  _containerWidth > 0 &&
                  _indicatorWidth > 0)
                Positioned(
                  right: widget.isRTL ? percentPositionedHorizontal : null,
                  left: !widget.isRTL ? percentPositionedHorizontal : null,
                  top: _containerHeight / 2 - _indicatorHeight,
                  child: widget.widgetIndicator!,
                ),
            ],
          ),
        );
    
        if (hasSetWidth) {
          items.add(containerWidget);
        } else {
          items.add(Expanded(
            child: containerWidget,
          ));
        }
        if (widget.trailing != null) {
          items.add(widget.trailing!);
        }
    
        return Material(
          color: Colors.transparent,
          child: Container(
            color: widget.fillColor,
            child: Row(
              mainAxisAlignment: widget.alignment,
              crossAxisAlignment: CrossAxisAlignment.center,
              children: items,
            ),
          ),
        );
      }
    
      @override
      bool get wantKeepAlive => widget.addAutomaticKeepAlive;
    }
    
    class _LinearPainter extends CustomPainter {
      final Paint _paintBackground = Paint();
      final Paint _paintLine = Paint();
      final double progress;
      final bool isRTL;
      final Color progressColor;
      final Color backgroundColor;
      final Radius barRadius;
      final LinearGradient? linearGradient;
      final LinearGradient? linearGradientBackgroundColor;
      final MaskFilter? maskFilter;
      final bool clipLinearGradient;
    
      _LinearPainter({
        required this.progress,
        required this.isRTL,
        required this.progressColor,
        required this.backgroundColor,
        required this.barRadius,
        this.linearGradient,
        this.maskFilter,
        required this.clipLinearGradient,
        this.linearGradientBackgroundColor,
      }) {
        _paintBackground.color = backgroundColor;
    
        _paintLine.color = progress.toString() == "0.0"
            ? progressColor.withOpacity(0.0)
            : progressColor;
      }
    
      @override
      void paint(Canvas canvas, Size size) {
        // Draw background first
        Path backgroundPath = Path();
        backgroundPath.addRRect(RRect.fromRectAndRadius(
            Rect.fromLTWH(0, 0, size.width, size.height), barRadius));
        canvas.drawPath(backgroundPath, _paintBackground);
        canvas.clipPath(backgroundPath);
    
        if (maskFilter != null) {
          _paintLine.maskFilter = maskFilter;
        }
    
        if (linearGradientBackgroundColor != null) {
          Offset shaderEndPoint =
              clipLinearGradient ? Offset.zero : Offset(size.width, size.height);
          _paintBackground.shader = linearGradientBackgroundColor
              ?.createShader(Rect.fromPoints(Offset.zero, shaderEndPoint));
        }
    
        // Then draw progress line
        final xProgress = size.width * progress;
        Path linePath = Path();
        if (isRTL) {
          if (linearGradient != null) {
            _paintLine.shader = _createGradientShaderRightToLeft(size, xProgress);
          }
          linePath.addRRect(RRect.fromRectAndRadius(
              Rect.fromLTWH(
                  size.width - size.width * progress, 0, xProgress, size.height),
              barRadius));
        } else {
          if (linearGradient != null) {
            _paintLine.shader = _createGradientShaderLeftToRight(size, xProgress);
          }
          linePath.addRRect(RRect.fromRectAndRadius(
              Rect.fromLTWH(0, 0, xProgress, size.height), barRadius));
        }
        canvas.drawPath(linePath, _paintLine);
      }
    
      Shader _createGradientShaderRightToLeft(Size size, double xProgress) {
        Offset shaderEndPoint =
            clipLinearGradient ? Offset.zero : Offset(xProgress, size.height);
        return linearGradient!.createShader(
          Rect.fromPoints(
            Offset(size.width, size.height),
            shaderEndPoint,
          ),
        );
      }
    
      Shader _createGradientShaderLeftToRight(Size size, double xProgress) {
        Offset shaderEndPoint = clipLinearGradient
            ? Offset(size.width, size.height)
            : Offset(xProgress, size.height);
        return linearGradient!.createShader(
          Rect.fromPoints(
            Offset.zero,
            shaderEndPoint,
          ),
        );
      }
    
      @override
      bool shouldRepaint(CustomPainter oldDelegate) => true;
    }
    

    Now, we have the Polls code ready to be integrated with our chat screen UI. If you go to the chat_screen.dart file, you can see that every message is rendered as a chat_list_item widget.

    To render our Polls widget, let’s create a chat_poll_item widget and wrap it according to our chat_screen.

    In the same directory, create a new file called chat_poll_item.dart.

    
    class ChatPollItem extends StatelessWidget {
      const ChatPollItem({
        Key? key,
      }) : super(key: key);
    
      @override
      Widget build(BuildContext context) {
        return Container();
    }
    
    

    We need a PollMessageCreate object to create the poll with the title and options. We also need a dialogType int to identify the chat as a group chat and to build an Avatar frame. Additionally, we need the boolean, if the user has voted in the poll or not. To check if the current user has voted, we can simply check if the votes property of the PollMessageCreate, which is a type Map of , contains the currentUserId.

    Now that we have all the necessary parameters, let’s pair them up with the UI and render the Poll.

    
    import 'package:flutter/material.dart';
    import 'package:provider/provider.dart';
    import 'package:quickblox_polls_feature/bloc/chat/chat_screen_bloc.dart';
    import 'package:quickblox_polls_feature/bloc/chat/chat_screen_events.dart';
    import 'package:quickblox_polls_feature/models/poll_action.dart';
    import 'package:quickblox_polls_feature/models/poll_message.dart';
    import 'package:quickblox_polls_feature/presentation/screens/chat/avatar_noname.dart';
    import 'package:quickblox_polls_feature/presentation/screens/chat/polls.dart';
    import 'package:quickblox_sdk/chat/constants.dart';
    
    import 'package:flutter/material.dart';
    import 'package:provider/provider.dart';
    import 'package:quickblox_polls_feature/bloc/chat/chat_screen_bloc.dart';
    import 'package:quickblox_polls_feature/bloc/chat/chat_screen_events.dart';
    import 'package:quickblox_polls_feature/models/poll_action.dart';
    import 'package:quickblox_polls_feature/models/poll_message.dart';
    import 'package:quickblox_polls_feature/presentation/screens/chat/avatar_noname.dart';
    import 'package:quickblox_polls_feature/presentation/screens/chat/polls.dart';
    import 'package:quickblox_sdk/chat/constants.dart';
    
    class ChatPollItem extends StatelessWidget {
      final PollMessage message;
    
      final int? dialogType;
    
      const ChatPollItem({required this.message, this.dialogType, Key? key})
          : super(key: key);
    
      @override
      Widget build(BuildContext context) {
        final List voters =
            message.votes.keys.map((userId) => int.parse(userId)).toList();
        bool hasVoted = voters.contains(message.currentUserId);
    
        return Container(
          padding: const EdgeInsets.only(left: 10, right: 12, bottom: 8),
          child: Row(
            mainAxisSize: MainAxisSize.min,
            crossAxisAlignment: CrossAxisAlignment.end,
            children: [
              Container(
                  child: message.isIncoming && dialogType != QBChatDialogTypes.CHAT
                      ? AvatarFromName(name: message.senderName)
                      : null),
              Padding(padding: EdgeInsets.only(left: dialogType == 3 ? 0 : 16)),
              Expanded(
                  child: Padding(
                padding: const EdgeInsets.only(top: 15),
                child: Column(
                  crossAxisAlignment: message.isIncoming
                      ? CrossAxisAlignment.start
                      : CrossAxisAlignment.end,
                  children: [
                    IntrinsicWidth(
                      child: Column(
                        crossAxisAlignment: CrossAxisAlignment.stretch,
                        children: [
                          Polls(
                            onVote: (pollOption, optionIndex) {
                              ///TODO: Explained in next section
                            },
                            pollStyle: TextStyle(
                              overflow: TextOverflow.ellipsis,
                              fontSize: 15,
                              color: message.isIncoming
                                  ? Colors.black87
                                  : Colors.white,
                            ),
                            backgroundColor:
                                message.isIncoming ? Colors.white : Colors.blue,
                            outlineColor: Colors.transparent,
                            hasVoted: hasVoted,
                            children: message.options.entries
                                .map((option) => PollOption(
                                    optionId: option.key, //OptionID
                                    option: option.value, //Option Value (Text)
                                    value: message.votes.values
                                        .where((choosenOptionID) =>
                                            choosenOptionID == option.key)
                                        .length
                                        .toDouble()))
                                .toList(),
                            pollTitle: Text(
                              message.pollTitle,
                            ),
                          ),
                        ],
                      ),
                    ),
                  ],
                ),
              ))
            ],
          ),
        );
      }
    }
    
    class AvatarFromName extends StatelessWidget {
      const AvatarFromName({
        Key? key,
        String? name,
      })  : _name = name ?? "Noname",
            super(key: key);
    
      final String _name;
    
      @override
      Widget build(BuildContext context) {
        return Container(
          width: 40,
          height: 40,
          decoration: BoxDecoration(
            color: Color(ColorUtil.getColor(_name)),
            borderRadius: const BorderRadius.all(
              Radius.circular(20),
            ),
          ),
          child: Center(
            child: Text(
              _name.substring(0, 1).toUpperCase(),
              style: const TextStyle(
                color: Colors.white,
                fontWeight: FontWeight.bold,
              ),
            ),
          ),
        );
      }
    }
    

    The above code should be self-explanatory, as we have already covered many of the points. We are calculating the value of the hasVoted variable on runtime by checking if the list of voters contains our currentUserId. Additionally, we have an onVote callback, which will trigger the vote action. We have marked this as a TODO in the code snippet above as we’ll get to it in the following section.

    As we have yet to build the actual form for creating the poll, let’s complete this task now. We’ll create a simple form.

    We need to modify the _buildEnterMessageRow method in the chat_screen.dart file to add an extra button at the beginning, which will enable us to create a poll.

    
    Widget _buildEnterMessageRow() {
      return SafeArea(
        child: Column(
          children: [
            _buildTypingIndicator(),
            Container(
              color: Colors.white,
              child: Row(
                mainAxisSize: MainAxisSize.max,
                mainAxisAlignment: MainAxisAlignment.spaceBetween,
                crossAxisAlignment: CrossAxisAlignment.center,
                children: [
                  // Our code here
                  SizedBox(
                    width: 50,
                    height: 50,
                    child: IconButton(
                      icon: const Icon(
                        Icons.poll,
                        color: Colors.blue,
                      ),
                      onPressed: () async {
                        final formKey = GlobalKey();
                        final pollTitleController = TextEditingController();
                        final pollOption1Controller = TextEditingController();
                        final pollOption2Controller = TextEditingController();
                        final pollOption3Controller = TextEditingController();
                        final pollOption4Controller = TextEditingController();
    
                        await showModalBottomSheet(
                          isScrollControlled: true,
                          enableDrag: true,
                          shape: const RoundedRectangleBorder(
                            borderRadius: BorderRadius.vertical(
                              top: Radius.circular(20.0),
                            ),
                          ),
                          context: context,
                          backgroundColor: Colors.white,
                          builder: (context) {
                            return Padding(
                              padding: EdgeInsets.only(
                                  bottom:
                                      MediaQuery.of(context).viewInsets.bottom),
                              child: Container(
                                padding: const EdgeInsets.all(20.0),
                                child: Form(
                                  key: formKey,
                                  child: SingleChildScrollView(
                                    child: Column(
                                      children: [
                                        PollTextFieldRow(
                                          label: 'Enter Poll Title here',
                                          txtController: pollTitleController,
                                        ),
                                        PollTextFieldRow(
                                          label: 'Poll Option 1',
                                          txtController: pollOption1Controller,
                                        ),
                                        PollTextFieldRow(
                                          label: 'Poll Option 2',
                                          txtController: pollOption2Controller,
                                        ),
                                        PollTextFieldRow(
                                          label: 'Poll Option 3',
                                          txtController: pollOption3Controller,
                                        ),
                                        PollTextFieldRow(
                                          label: 'Poll Option 4',
                                          txtController: pollOption4Controller,
                                        ),
                                        ElevatedButton(
                                          onPressed: () {
                                            //TODO: Figure this out in next section
                                          },
                                          child: const Text('Create Poll'),
                                        ),
                                      ],
                                    ),
                                  ),
                                ),
                              ),
                            );
                          },
                        );
                      },
                    ),
                  ),
                  SizedBox(
                    width: 50,
                    height: 50,
                    child: IconButton(
                      // Some code present here
                    ),
                  ),
                  Expanded(
                    child: Container(
                      // Some code present here
                    ),
                  ),
                  SizedBox(
                    width: 50,
                    height: 50,
                    child: IconButton(
                      // Some code present here
                    ),
                  ),
                ],
              ),
            ),
          ],
        ),
      );
    }
    

    We have just added a new IconButton to the Enter message row in the code above. To keep the snippets short, we have added comments instead of copying all the code; you can find the complete code in the attached repo. This form is quite simple and contains a button which creates a form. The next section will focus on actually creating the poll, as indicated by the TODO.

    Putting it all together

    Let’s plug our logic with the UI to make it happen. To bind everything together, we need to take the following steps:

    1. Figure out the two TODOs from the last section: Create and Send votes to the poll.
    2. We’ll also plug our PollListItem widget in the ChatScreen.

    Let’s start by figuring out the TODO on the onPressed callback of the Poll creation form.
    We need to send a message to Quickblox with the required parameters to treat it as a Poll Creation message.

    // Create poll button code from chat_screen.dart
    ElevatedButton(
      onPressed: () {
        //Cancel the Typing status timer
        TypingStatusManager.cancelTimer();
    
        //Add the CreatePoll event to the BLoC
        bloc?.events?.add(
          CreatePollMessageEvent(
            PollActionCreate.fromData(
              pollTitleController.text.trim(),
              [
                pollOption1Controller.text
                    .trim(),
                pollOption2Controller.text
                    .trim(),
                pollOption3Controller.text
                    .trim(),
                pollOption4Controller.text
                    .trim(),
              ],
            ),
          ),
        );
        
        //Pop the bottom modal sheet
        Navigator.of(context).pop();
      },
      child: const Text('Create Poll'),
    ),
    
    

    Next, we need to work on the other TODO, which is to vote in the poll. It’s pretty straightforward, as you can see from the code snippet below.

    Polls(
      onVote: (pollOption, optionIndex) {
        // If the user has already voted, Don't do anything
        if (!hasVoted) {
        // Add the VoteToPoll event to the BLoC
          Provider.of(context, listen: false)
              .events
              ?.add(
                VoteToPollEvent(
                  PollActionVote(
                    pollId: message.pollID,
                    voteOptionId: pollOption.optionId!,
                  ),
                ),
              );
        }
      },
      pollStyle: TextStyle(
        overflow: TextOverflow.ellipsis,
        fontSize: 15,
        color: message.isIncoming
            ? Colors.black87
            : Colors.white,
      ),
      backgroundColor:
          message.isIncoming ? Colors.white : Colors.blue,
      outlineColor: Colors.transparent,
      hasVoted: hasVoted,
      children: message.options.entries
          .map((e) => PollOption(
              optionId: e.key,
              option: e.value,
              value: votes
                  .map((e) => e.choosenOption)
                  .where((option) => option == e.key)
                  .length
                  .toDouble()))
          .toList(),
      pollTitle: Text(
        message.pollTitle,
      ),
    ),
    

    As you can see, everything was already prepared, so we just had to add the events with the necessary information and that was it.

    Now, let’s integrate the PollListItem widget into the ChatScreen. If you take a look inside ChatScreen, you’ll notice that there is a GroupedList that groups and displays the messages. Let’s modify its itemBuilder to detect and display a PollMessage.

    itemBuilder: (context, QBMessageWrapper message) {
      if (message is PollMessageCreate) {
          return ChatPollItem(
                    message: message,
                    key: ValueKey(
                        Key(
                           RandomUtil.getRandomString(10),
                        ),
                    ),
          );
      }
      return GestureDetector(
          child: ChatListItem(
            Key(
              RandomUtil.getRandomString(10),
            ),
            message,
            _dialogType,
          ),
          onTapDown: (details) {
            tapPosition = details.globalPosition;
          },
          onLongPress: () {
          //More code present here
        }
      );
    },
    

    We’re checking if the message is a PollMessageCreate, calculating the votes for the poll, and then rendering the ChatPollItem. The final version looks like this:

    Well done!

    If you have followed all the steps correctly, you should have the working polls functionality ready. However, if you see any errors or any left-out pieces, you can always compare your code to the full source code available in our repository, and this will help you finish the task.

    Optional Steps

    This article concentrated on constructing the polls functionality, rather than refining the UI/UX. But there are many additional steps you can take to enhance these aspects.

    1. Stronger form validations and capability for more or fewer than 4 options.
    2. Highlighting the chosen option.
    3. Estimating and highlighting the choice with the highest votes.
    4. Reverting the poll vote.
    5. Adding Images or other media as poll options.
    6. Let us know what else you think should be improved in the comments below.

      Refer the QuickBlox docs for more information.

      Have Questions? Need Support?

      Join the QuickBlox Developer Discord Community, where you can share ideas, learn about our software, & get support.

      Join QuickBlox Discord

Leave a Comment

Your email address will not be published. Required fields are marked *

Read More

Ready to get started?

QUICKBLOX
QuickBlox post-box