paint-brush
A Guide to Creating Fun User Polls and Surveys With QuickBlox in Your Flutter Appby@hiteshgarg285
628 reads
628 reads

A Guide to Creating Fun User Polls and Surveys With QuickBlox in Your Flutter App

by Hitesh GargJanuary 8th, 2023
Read on Terminal Reader
Read this story w/o Javascript
tldt arrow

Too Long; Didn't Read

We already know how amazing Quickblox SDK is with all the handy features it already provides like sending text and attachment messages, typing status, message read status, push notifications, private/public/group chats support, and many more. In this article, We are going to see how we can extend the existing functionalities to build cool features like polls and surveys.
featured image - A Guide to Creating Fun User Polls and Surveys With QuickBlox in Your Flutter App
Hitesh Garg HackerNoon profile picture

We already know how amazing Quickblox SDK is with all the handy features it already provides like sending text and attachment messages, typing status, message read status, push notifications, private/public/group chats support, and many more.


In this article, We are going to see how we can extend the existing functionalities to build cool features like polls and surveys.


The final result for polls feature

Here is an index of the sections we will be covering. Please feel free to skip some or read throughout for maximum value. 😃


  • Getting Started

  • Quickblox Custom Objects and Data Models

  • Writing the logic to support polls.

  • Building the UI

  • Combining everything

  • Next steps


Getting Started

This section is all about setting up the template application on which we can start building the polls functionality. We have taken some code from the official chat_sample application of Quickblox to get started quickly with the chat screen.


For simplicity of this article, We only have 3 screens. Splash, Login, and Chat screen. We have also hard coded the _dialogId on the Chat screen so that we are directly taken to the required Group chat.


The template application code can be found here.

Initial Setup

  1. Create a new account following this link. You can also use your Google or GitHub accounts to sign in.

  2. Create an app by clicking the New app button.

  3. Configure your app. Type in the information about your organization into corresponding fields and click Add button.

  4. Go to Dashboard => YOUR_APP => Overview section and copy your Application ID, Authorization Key, Authorization Secret, and Account Key.


Once you have the application credentials, You can paste them into the main.dartfile, which is present in the template application.


If you have followed the above steps correctly, you can now run the application by using the following commands:


flutter packages get
flutter run 


Voila! You should have a basic chat application up and running now.

Quickblox Custom Objects and Data Models

In this section, we will first understand the data models provided by Quickblox, followed by creating our own models on top of it to extend functionalities and build polls.


Let’s begin by understanding the provided data models by Quickblox.

QBMessageWrapper & QBMessage data models


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


In this project, we have also created a QBMessageWrapper which is a wrapper around the QBMessage with additional fields like senderName, date etc that come in handy to display the messages data in the chat screen.


While QBMessage with its properties parameter is great to render static text messages, location, weblink etc, it cannot be used to host interactive polls that update over time.


For this, Quickblox provides us with Custom Objects which is basically a custom schema key-value database that can be updated in realtime and hence is a perfect fit to host polls.


To set it up, let’s head over to our Quickblox Dashboard > Custom > Add > Add New Class and prepare a custom schema class Poll as below.


Add Poll class Image

Once created, open Edit Permission and change the permission level and checkboxes as follows.


Edit Poll Permissions Image

Without open permissions, the users cannot update the Poll values from the app.


In the code, we will create two classes


  • PollActionCreate to hold poll title and options. We are using uuid package to generate and assign a unique id to every option value. The toJson returns mapped values required as per our Poll object schema.

  • PollActionVote stores the pollID, the existing votes and the choosenOption by the currentUser. It has a getter updatedVotes that recalculates the vote with the user-chosen option and returns the final values.


All the Map values are jsonEncoded into a string since quickblox custom object doesn’t support Map datatype.



import 'dart:convert';
import 'package:uuid/uuid.dart';

class PollActionCreate {
  PollActionCreate({
    required this.pollTitle,
    required this.pollOptions,
  });

  final String pollTitle;
  final Map<String, String> pollOptions;

  factory PollActionCreate.fromData(
    String title,
    List<String> options,
  ) {
    const uuid = Uuid();
    return PollActionCreate(
      pollTitle: title,
      pollOptions: {for (var element in options) uuid.v4(): element},
    );
  }
  Map<String, String> 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<String, String> votes;
  final String choosenOptionID;
  final String currentUserID;

  Map<String, String> get updatedVotes {
    votes[currentUserID] = choosenOptionID;
    return {"votes": jsonEncode(votes)};
  }
}


The above models are used to parse and send the data, but we also need a model that will come in handy when we receive the data.



We will create aPollMessage class extending the QBMessageWrapper to hold all poll-specific properties.


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<String, String> options;
  final Map<String, String> 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<String, String>.from(
            jsonDecode(object.fields!['options'] as String)),
        votes: Map<String, String>.from(
            jsonDecode(object.fields!['votes'] as String)));
  }
  PollMessage copyWith({Map<String, String>? votes}) {
    return PollMessage(senderName!, qbMessage, currentUserId,
        pollID: pollID,
        pollTitle: pollTitle,
        options: options,
        votes: votes ?? this.votes);
  }
}


Writing the logic to support polls.

Let’s start this section and do what we (as programmers) love to do, build the logic. 😉

In this section, we will write the logic to create and vote on a poll, by utilizing our custom-created Poll object and the properties param of a normal message.


We are using bloc pattern where, in our logic, we receive  CreatePollMessageEvent and VoteToPollEvent from the UI interface which should trigger a repository call that communicates 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<void> _sendCreatePollMessage({required PollActionCreate data}) async {
  await _chatRepository.sendCreatePollMessage(
    _dialogId,
    data: data,
  );
}

Future<void> _sendVotePollMessage({required PollActionVote data}) async {
  await _chatRepository.sendVotePollMessage(
    _dialogId,
    data: data,
  );
}


In chat_repository.dart, along with our simple sendMessage function for text messages, we will also add the following functions:


  1. sendCreatePollMessage : registers a poll record and the returned pollID is then sent in the metadata of a chat message. We can later use the pollID to retrieve the poll data.


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


  1. sendVotePollMessage : Updates the poll record with the latest vote values. Only the fields to be updated have to be sent in the fields parameter, which for us is votes.


    Please note that saveToHistroy isn’t enabled for sendMessage here since the only purpose of sendMessage here is to notify the current clients that the poll values have been updated.

    In the future, when we reopen the chats, the poll values fetched will be the latest already, leaving out the need for pollActionVote message in history.


Important note: saveToHistroy should be used sensibly. Otherwise, for larger groups of more than 100 people, we can find ourselves paginating multiple times only to find a series of useless pollActionVote messages.


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


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


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


To know more about params like markable and saveToHistory you can refer to the amazing docs of Quickblox.


So from the chat_screen_bloc.dart, we are basically calling these three methods from the repository only, and wrapping them in try-catch blocks to catch and formulate a proper error, if things go unexpected.


Note: You might have already noticed, that before calling these methods, we are also calling the sendStoppedTyping method from the Chat Repository, that is just to make sure, the UI on the receiver’s end doesn’t show that we are typing anymore.


Phewww!! So we are almost done (wait, again almost? 😟)
Well, there is one last thing remaining, Can you guess what are we missing? 🤔 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 figure this out.



So, In the chat_screen_bloc file, we have a HashSet<QBMessageWrapper> _wrappedMessageSet which stores all the messages sorted by time.


We also have a method _wrapMessages(), which is called every time when we receive new messages and is responsible for wrapping the QBMesssage(s) in the List<QBMessageWrappers>. We will now update this method to handle both pollActionCreate and pollActionVote incoming messages.

Upon receiving a:


  1. pollActionCreate: We first extract the pollID from properties of the message and then use getCustomObject to fetch the poll record and build the PollMessage object.


  2. pollActionVote: gives us the id of the poll that has been updated. We then use the pollID to fetch the latest vote values and update the previous PollMessage object.


///Called whenever new messages are received.  
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";
      ///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<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));

       ///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;
  }


All the messages, normal and poll are now populated in the _wrappedMessageSet list and can be rendered on the UI.

Building the UI

So now we have the data models ready, we have the polls logic ready, Let’s now focus on building a good-looking UI for our polls feature.


Note: Our polls UI is heavily inspired and built around polls package, However since we needed to customise it as per our needs, We considered adding the code manually and made the changes.


First things first, We know every poll option will have a unique id (as we already mentioned earlier) to identify it uniquely, it will also have an actual string value and the last one, a numeric value keeping count of how many people choose that option. ( You know where it’s heading 😉, Yes! Another model, but trust me, a simple one.)


So create a new file polls.dart file to hold everything related to the polls UI. Let’s create PollOption model in the file.


class PollOption {
  String? optionId;
  String option;
  double value;

  PollOption({
    this.optionId,
    required this.option,
    required this.value,
  });
}


Now, At the very base, We will basically need 2 widgets. One representing the unvoted state and another one when voted.


That means we will also have a bool to check if the person has voted or not. So let’s begin by creating a stateful widget with a bunch of 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<PollOption> children;

  @override
  PollsState createState() => PollsState();
}

class PollsState extends State<Polls> {
@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,
);


That was simple, right? Now we are going to add the UI code for voterWidget and the voteCastedwidget. Those are just simple Rows and Columns with some decorations around. So we are not going to focus too much on explaining them.


The fun part is yet to come when we will combine 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<PollOption> 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<Polls> {
  PollController? _controller;

  var choiceList = <String>[];
  var userChoiceList = <String>[];
  var valueList = <double>[];
  var userValueList = <double>[];

  /// 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>[
        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>[
        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: <Widget>[
                        Row(
                          mainAxisAlignment: MainAxisAlignment.start,
                          children: <Widget>[
                            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<double> valueList, choice, int byValue) {
    double div = 0.0;
    var slot = <double>[];
    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 = <PollOption>[];
  bool hasVoted = false;
  bool makeChange = false;

  void revertChangeBoolean() {
    makeChange = false;
    notifyListeners();
  }
}

typedef PollOnVote = void Function(
  PollOption pollOption,
  int optionIndex,
);


In the above code, We have set up a PollControllerthat will help us in holding the variables and update the UI when needed. Rest is all about styling the widgets.


We also have a widgetLinearPercentIndicator, which basically helps us in filling up the required percentage according to the number of votes on each option. This widget is basically taken from this package to simplify the process of building UI. Attaching the code 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<LinearPercentIndicator>
    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<Widget>.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;
}


With this, We now have the Polls code ready to be integrated with our actual chat screen UI, If you go to chat_screen.dart file, you can see every message is rendered in form of a chat_list_itemwidget.


So, Let’s also create achat_poll_item widget to wrap the Polls widget and render 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();
}


Now, let’s try to figure out, what all do we need in this widget and how are going to render the Poll.

  • We need a PollMessageCreate object to create the poll with the title and options.

  • To count and render the total votes on the poll, we use the votes property of the PollMessageCreate which is a <String, String> type Map of <UserID, ChoosenOptionID>.

  • We need a dialogType int to identify the chat as a group chat and build an Avatar frame.

  • We also need the boolean, if the user has voted in the poll or not, but since we have a list of voters, we can simply check if it contains the currentUserId.


Let’s now add all these parameters, and pair up the Polls with UI.


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<int?> 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: <Widget>[
          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: <Widget>[
                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 is mostly self-explanatory as we already discussed most of the points. We are calculating the value of hasVoted variable on runtime, by checking if the list of voters contains our currentUserId.


We also have anonVote callback, which will basically trigger the vote action. We have it as a TODO because we will do it in the next section. 😉


So, we have created the Poll UI, Poll methods, and everything else required to render a poll, but wait, Are we missing something? 🤔 (I’ll let you guess)



We haven’t yet built the actual form, using which we will create the poll, Let’s create a very simple form.


Let’s go to chat_screen.dart file, there we have a method _buildEnterMessageRow, which basically builds the bottommost area of the screen, where we enter the message and send it. Let’s modify it to have an extra button at the beginning which can help us create polls.


Widget _buildEnterMessageRow() {
  return SafeArea(
    child: Column(
      children: [
        _buildTypingIndicator(),
        Container(
          color: Colors.white,
          child: Row(
            mainAxisSize: MainAxisSize.max,
            mainAxisAlignment: MainAxisAlignment.spaceBetween,
            crossAxisAlignment: CrossAxisAlignment.center,
            children: <Widget>[
              // Our code here
              SizedBox(
                width: 50,
                height: 50,
                child: IconButton(
                  icon: const Icon(
                    Icons.poll,
                    color: Colors.blue,
                  ),
                  onPressed: () async {
                    final formKey = GlobalKey<FormState>();
                    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
                ),
              ),
            ],
          ),
        ),
      ],
    ),
  );
}


In the above code, we have just added a new IconButton as the first child of your Enter message row. We are not copying all the below code to keep the snippets short. So we have added the comments. You can get the complete code in the attached repo.


As you might have guessed, it’s just a simple form with a button to create a form, and we again have a TODO there that will actually create the poll, which is exactly what we are going to figure out in the next section.

Combining everything

We’ve come a far way, If you’re still here, believe us the result is going to be worth the effort. 😌



In this section, we are going to plug our logic with the UI to make it happen, We will also plug our poll_list_item at the right place. So let’s write down the steps that we need to take to bind everything together.


  • Figure out the two TODOs from the last section, Which will actually Create and Send votes to the poll. (Yes, I remember the TODOs 😌).
  • Plug the PollListItem widget in ChatScreen.


So let’s begin by figuring out the TODOs, Starting with the TODO on the onPressed callback of the Poll creation form.


Basically here, we want to send a message to Quickblox with the required parameters, so that it will be treated 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'),
),


Now let’s figure out the other TODO, to vote in the poll, Many of you would’ve already figured it out, And you are correct, It’s that simple.


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<ChatScreenBloc>(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,
  ),
),


Wasn’t it simple ? We already had everything ready, We Just added the events with the required data and it’s done.

Moving on, Let’s Plug the PollListItem widget in ChatScreen.
In Chat Screen, We can see there is a GroupedList that is grouping and rendering the messages. Let’s modify itsitemBuilder to detect and render 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
    }
  );
},


In the above code, We are basically checking if the message is a PollMessageCreate and rendering the ChatPollItem after calculating the votes for that poll. Let’s see what the final version looks like:

Demo

Polls demo


With this, We are finally done and if you have followed all the steps correctly, You should have the working polls functionality ready. In any case, if you see any errors or any left-out pieces, you can always match your code to the full source code available in our repository.


Next Steps

This article was more focused on building the polls functionality, instead of polishing the UI/UX and focusing on nitty gritty details, Here are a few things that we think could be improved.

  • Better Form validations and support for less or more than 4 options.
  • Highlighting the voted option.
  • Calculating and highlighting the option with the highest votes.
  • Reverting the poll vote.
  • Adding Images or other media as poll options.
  • You let us know. 😉

References


Also published here.