We already know how amazing 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. Quickblox In this article, We are going to see how we can extend the existing functionalities to build cool features like polls and surveys. 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 application of Quickblox to get started quickly with the chat screen. chat_sample For simplicity of this article, We only have 3 screens. Splash, Login, and Chat screen. We have also hard coded the on the Chat screen so that we are directly taken to the required Group chat. _dialogId The template application code can be found . here Initial Setup Create a new account following . You can also use your Google or GitHub accounts to sign in. this link Create an app by clicking the button. New app Configure your app. Type in the information about your organization into corresponding fields and click button. Add Go to Dashboard => => Overview section and copy your Application ID, Authorization Key, Authorization Secret, and Account Key. YOUR_APP Once you have the application credentials, You can paste them into the file, which is present in the template application. main.dart 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. is a default data object provided by Quickblox that stores id(message identifier), body(text message), properties(extra metadata) etc. QBMessage In this project, we have also created a 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. QBMessageWrapper While with its parameter is great to render static text messages, location, weblink etc, it cannot be used to host interactive polls that update over time. QBMessage properties For this, Quickblox provides us with which is basically a custom schema key-value database that can be updated in realtime and hence is a perfect fit to host polls. Custom Objects To set it up, let’s head over to our and prepare a custom schema class as below. Quickblox Dashboard > Custom > Add > Add New Class Poll Once created, open and change the permission level and checkboxes as follows. Edit Permission Without open permissions, the users cannot update the Poll values from the app. In the code, we will create two classes to hold poll and . We are using package to generate and assign a unique id to every option value. The returns mapped values required as per our object schema. PollActionCreate title options uuid toJson Poll stores the , the existing and the by the . It has a getter that recalculates the vote with the user-chosen option and returns the final values. PollActionVote pollID votes choosenOption currentUser updatedVotes 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 a class extending the QBMessageWrapper to hold all poll-specific properties. PollMessage 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 object and the param of a normal message. Poll properties We are using bloc pattern where, in our logic, we receive and from the UI interface which should trigger a repository call that communicates with the Quickblox servers. CreatePollMessageEvent VoteToPollEvent 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 , along with our simple function for text messages, we will also add the following functions: chat_repository.dart sendMessage : registers a poll record and the returned is then sent in the metadata of a chat message. We can later use the pollID to retrieve the poll data. sendCreatePollMessage pollID 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}, ); } : 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 . sendVotePollMessage votes Please note that isn’t enabled for here since the only purpose of sendMessage here is to notify the current clients that the poll values have been updated. saveToHistroy sendMessage In the future, when we reopen the chats, the poll values fetched will be the latest already, leaving out the need for message in history. pollActionVote 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. : used to fetch the latest data state of a poll. getCustomObject 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 of Quickblox. docs So from the , 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. chat_screen_bloc.dart 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 file, we have a HashSet< > which stores all the messages sorted by time. chat_screen_bloc QBMessageWrapper _wrappedMessageSet We also have a method , which is called every time when we receive new messages and is responsible for wrapping the (s) in the List< >. We will now update this method to handle both and incoming messages. _wrapMessages() QBMesssage QBMessageWrappers pollActionCreate pollActionVote Upon receiving a: : We first extract the from of the message and then use to fetch the poll record and build the object. pollActionCreate pollID properties getCustomObject PollMessage : gives us the id of the poll that has been updated. We then use the to fetch the latest vote values and update the previous object. pollActionVote pollID PollMessage ///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 list and can be rendered on the UI. _wrappedMessageSet 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 package, However since we needed to customise it as per our needs, We considered adding the code manually and made the changes. polls 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 file to hold everything related to the polls UI. Let’s create model in the file. polls.dart PollOption 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 and the widget. Those are just simple Rows and Columns with some decorations around. So we are not going to focus too much on explaining them. voterWidget voteCasted 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 that will help us in holding the variables and update the UI when needed. Rest is all about styling the widgets. PollController We also have a widget , 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 package to simplify the process of building UI. Attaching the code below. LinearPercentIndicator this 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 file, you can see every message is rendered in form of a widget. chat_screen.dart chat_list_item So, Let’s also create a widget to wrap the Polls widget and render according to our chat_screen. chat_poll_item 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 object to create the poll with the title and options. PollMessageCreate To count and render the total votes on the poll, we use the property of the which is a type Map of votes PollMessageCreate <String, String> <UserID, ChoosenOptionID>. We need a int to identify the chat as a group chat and build an Avatar frame. dialogType 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 variable on runtime, by checking if the list of contains our . hasVoted voters currentUserId We also have an callback, which will basically trigger the vote action. We have it as a TODO because we will do it in the next section. 😉 onVote 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 file, there we have a method , 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. chat_screen.dart _buildEnterMessageRow 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 at the right place. So let’s write down the steps that we need to take to bind everything together. poll_list_item 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 widget in PollListItem ChatScreen. So let’s begin by figuring out the TODOs, Starting with the TODO on the callback of the Poll creation form. onPressed 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 widget in . In Chat Screen, We can see there is a GroupedList that is grouping and rendering the messages. Let’s modify its to detect and render a . PollListItem ChatScreen itemBuilder 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 after calculating the votes for that poll. Let’s see what the final version looks like: ChatPollItem 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 https://docs.quickblox.com/docs https://github.com/PixelAppsMobile/quickblox-chat-fun-feature https://www.pixelapps.io/ Also published . here