When you build chat using QuickBlox SDKs you can add a variety of stunning features including private, public, and group chats, media rich attachments, message read status, presence indicator, push notifications and many more. Have you ever wondered how you could use Quickblox SDK to add even more functionalities to your app or website?
In particular, polls and surveys can be an effective way to make your chat application more interactive and engaging for users. Not only do they provide the opportunity for users to share their input and opinions on different topics, but they can also give you valuable feedback and insights that you can use to improve the user experience.
So why not encourage your users to participate in polls and surveys and get the conversation started?
In this article, we’ll take it up a notch and learn how we can utilize the QuickBlox SDK to create polls and surveys in your Flutter chat application.
In order to get started quickly with the polls functionality, we are taking code from the official QuickBlox Flutter chat sample application. This template application consists of three screens: Splash, Login, and Chat. On the Chat screen, the _dialogId
is hard-coded so that you can directly access the required Group chat.
To find the template application code, please visit the link here.
Once you have the application credentials, paste them into the main.dart
file found in the template application. Once this is done correctly, run the application using the following commands:
flutter packages get flutter run
You now have a basic chat application up and running. Well done!
To start off, let’s take a look at the data models offered by Quickblox. After that, we can move on to creating our own models to enhance the functionality and create polls.
Learn more about: What are custom objects and how they can benefit your application
QBMessage
is a data object provided by Quickblox that stores an id(identifier), a body(text message), properties(extra metadata), etc.
We have also created QBMessageWrapper
, a wrapper around the QBMessage
that contains additional fields such as a senderName and date, etc details, which makes it easier to display the message data on the chat screen.
The properties
parameter in QBMessage
is useful for rendering static text messages, locations, and web links, but it cannot be used to host interactive polls that change over time.
However, Quickblox offers Custom Objects as an ideal solution for hosting polls. To create the Poll class, go to Quickblox Dashboard > Custom > Add > Add New Class and set up the custom schema.
Once created, open the Edit Permissions window and adjust the permission level and checkboxes accordingly.
Without granting open permissions, users will unable to update the Poll values from the app.
Therefore, we will create two classes – PollActionCreate
and PollActionVote
.
The PollActionCreate
class will contain the poll title
and options
, and we will use the UUID
package to generate and assign a unique ID to each option value. The toJson
method will return the values that are mapped to our Poll object schema.
The PollActionVote
class will store the pollID
, the existing votes
, and the chosenOption
by the currentUser
. It will also have a getter, updatedVotes
, which will recalculate the vote with the user-chosen option and return the final values.
Since Quickblox custom object does not support the Map datatype, all the Map values will be jsonEncoded into a string.
import 'dart:convert'; import 'package:uuid/uuid.dart'; class PollActionCreate { PollActionCreate({ required this.pollTitle, required this.pollOptions, }); final String pollTitle; final MappollOptions; factory PollActionCreate.fromData( String title, List options, ) { const uuid = Uuid(); return PollActionCreate( pollTitle: title, pollOptions: {for (var element in options) uuid.v4(): element}, ); } Map toJson() { return { "title": pollTitle, "options": jsonEncode(pollOptions), "votes": jsonEncode({}) }; } } class PollActionVote { const PollActionVote( {required this.pollID, required this.votes, required this.currentUserID, required this.choosenOptionID}); final String pollID; final Map votes; final String choosenOptionID; final String currentUserID; Map get updatedVotes { votes[currentUserID] = choosenOptionID; return {"votes": jsonEncode(votes)}; } }
We also need a model that will come in handy when we receive the data, in addition to the models used to parse and send the data.
We will create a class PollMessage
, which extends QBMessageWrapper
to contain all properties that are specific to polls.
import 'dart:convert'; import 'package:quickblox_polls_feature/models/message_wrapper.dart'; import 'package:quickblox_sdk/models/qb_custom_object.dart'; import 'package:quickblox_sdk/models/qb_message.dart'; class PollMessage extends QBMessageWrapper { PollMessage(super.senderName, super.message, super.currentUserId, {required this.pollID, required this.pollTitle, required this.options, required this.votes}); final String pollID; final String pollTitle; final Mapoptions; final Map votes; factory PollMessage.fromCustomObject(String senderName, QBMessage message, int currentUserId, QBCustomObject object) { return PollMessage(senderName, message, currentUserId, pollID: message.properties!['pollID']!, pollTitle: object.fields!['title'] as String, options: Map .from( jsonDecode(object.fields!['options'] as String)), votes: Map .from( jsonDecode(object.fields!['votes'] as String))); } PollMessage copyWith({Map ? votes}) { return PollMessage(senderName!, qbMessage, currentUserId, pollID: pollID, pollTitle: pollTitle, options: options, votes: votes ?? this.votes); } }
With this, we have the models ready. Now let’s start coding!
In this section, we will implement the logic to create and vote on a poll using our custom-made Poll object and the properties
param from a normal message. We are using the Bloc pattern, which means that when a CreatePollMessageEvent
and VoteToPollEvent
are received from the UI interface, a repository call is triggered to communicate with the Quickblox servers.
if (receivedEvent is SendMessageEvent) { // SOME PRE WRITTEN CODE PRESENT HERE. } if (receivedEvent is CreatePollMessageEvent) { try { await _chatRepository.sendStoppedTyping(_dialogId); await Future.delayed(const Duration(milliseconds: 300), () async { await _sendCreatePollMessage(data: receivedEvent.data); }); } on PlatformException catch (e) { states?.add( SendMessageErrorState( makeErrorMessage(e), 'Can\'t create poll', ), ); } on RepositoryException catch (e) { states?.add(SendMessageErrorState(e.message, 'Can\'t create poll')); } } if (receivedEvent is VoteToPollEvent) { try { await _chatRepository.sendStoppedTyping(_dialogId); await Future.delayed(const Duration(milliseconds: 300), () async { await _sendVotePollMessage(data: receivedEvent.data); }); } on PlatformException catch (e) { states?.add( SendMessageErrorState( makeErrorMessage(e), 'Can\'t vote poll', ), ); } on RepositoryException catch (e) { states?.add(SendMessageErrorState(e.message, 'Can\'t vote poll')); } } Future_sendCreatePollMessage({required PollActionCreate data}) async { await _chatRepository.sendCreatePollMessage( _dialogId, data: data, ); } Future _sendVotePollMessage({required PollActionVote data}) async { await _chatRepository.sendVotePollMessage( _dialogId, data: data, ); }
We will add the following functions to chat_repository.dart:
1. sendCreatePollMessage
: registers a poll record and sends the pollID
in the metadata of a chat message. This allows us to later retrieve the poll data using the pollID
.
FuturesendCreatePollMessage(String? dialogId, {required PollActionCreate data}) async { if (dialogId == null) { throw RepositoryException(_parameterIsNullException, affectedParams: ["dialogId"]); } ///Creates the poll record and returns a single custom object final List pollObject = await QB.data.create(className: 'Poll', fields: data.toJson()); final pollID = pollObject.first!.id!; ///Sends an empty text message without body with the poll action and ID await QB.chat.sendMessage( dialogId, saveToHistory: true, markable: true, properties: {"action": "pollActionCreate", "pollID": pollID}, ); }
2. sendVotePollMessage
: This function updates the poll record with the latest vote values. The only fields to be sent in the fields parameter are the votes
. saveToHistroy
is not enabled for this function as its sole purpose is to inform current clients that the poll values have been updated.
This means that when chats are reopened, the latest poll values will already be fetched, eliminating the need for the pollActionVote
message in the history.
Note: saveToHistroy
should be used wisely. Otherwise, when dealing with large groups of more than 100 people, we may need to paginate multiple times and end up with a series of useless pollActionVote
messages.
FuturesendVotePollMessage(String? dialogId, {required PollActionVote data, required String currentUserID}) async { if (dialogId == null) { throw RepositoryException(_parameterIsNullException, affectedParams: ["dialogId"]); } ///Updates the updated Votes value in the poll record. await QB.data.update("Poll", id: data.pollID, fields: data.updatedVotes); ///Sends a message to notify clients await QB.chat.sendMessage( dialogId, markable: true, properties: {"action": "pollActionVote", "pollID": data.pollID}, ); }
3. getCustomObject
: used to fetch the latest data state of a poll.
Future?> getCustomObject( {required List
ids, required String className}) { return QB.data.getByIds("Poll", ids); }
If you want to learn more about parameters like markable and saveToHistory, you can refer to the helpful QuickBlox documentation. From the chat_screen_bloc.dart
we are calling three methods from the repository and using try-catch blocks to catch and handle any unexpected errors.
Note: Before calling the sendStoppedTyping
method from the Chat Repository, we call the other methods to ensure that the UI on the receiver’s end no longer displays that we are typing.
Now, we need to figure out what happens when we receive a new message. Where will it go? How will it get handled if it’s a poll or a vote to a poll? Let’s get this sorted out and then we’ll be done.
In the chat_screen_bloc
file we have a HashSet<QBMessageWrapper
> _wrappedMessageSet
which stores all messages sorted by time. We have a method _wrapMessages()
which is responsible for wrapping the QBMesssage
(s) in the List<QBMessageWrappers
> when we receive new messages. To handle both pollActionCreate
and pollActionVote
incoming messages, this method needs to be updated.
When a pollActionCreate
occurs, we extract the pollID
from the properties of the message and use getCustomObject
to retrieve the poll record. We then construct a PollMessage
object. For a pollActionVote
, the id of the poll that has been updated is supplied. We use pollID
to fetch the latest vote values and update the PollMessage
object accordingly.
///Called whenever new messages are received. Future> _wrapMessages( List
messages) async { List wrappedMessages = []; for (QBMessage? message in messages) { if (message == null) { break; } QBUser? sender = _getParticipantById(message.senderId); if (sender == null && message.senderId != null) { List users = await _usersRepository.getUsersByIds([message.senderId!]); if (users.isNotEmpty) { sender = users[0]; _saveParticipants(users); } } String senderName = sender?.fullName ?? sender?.login ?? "DELETED User"; ///Fetch the latest poll object data using the pollID ///and update the PollMessage object with the new vote values if (message.properties?['action'] == 'pollActionVote') { final id = message.properties!['pollID']!; final pollObject = await _chatRepository.getCustomObject(ids: [id], className: "Poll"); final votes = Map .from( jsonDecode(pollObject!.first!.fields!['votes'] as String)); final pollMessage = _wrappedMessageSet.firstWhere( (element) => element is PollMessage && element.pollID == id) as PollMessage; _wrappedMessageSet.removeWhere( (element) => element is PollMessage && element.pollID == id); wrappedMessages.add(pollMessage.copyWith(votes: votes)); ///Fetch the poll object associated with the pollID and save ///it as a PollMessage in the list. } else if (message.properties?['action'] == 'pollActionCreate') { final pollObject = await _chatRepository.getCustomObject( ids: [message.properties!['pollID']!], className: "Poll"); final poll = PollMessage.fromCustomObject( senderName, message, _localUserId!, pollObject!.first!); wrappedMessages.add(poll); } else { wrappedMessages .add(QBMessageWrapper(senderName, message, _localUserId!)); } } ///This list returned is then appended to _wrappedMessageSet return wrappedMessages; }
The _wrappedMessageSet
list now contains both regular and poll messages, which can be displayed on the UI.
Now that the data models and polls logic are ready, we should focus on constructing a visually appealing UI for our polls feature.
Note: We decided to add the code manually and customize it as per our needs, since our polls UI was heavily inspired and built around the polls package.
Create a new file, polls.dart
, to hold everything related to the polls UI. In this file, create a PollOption
model with a unique id, a string value, and a numeric value keeping count of how many people choose that option.
class PollOption { String? optionId; String option; double value; PollOption({ this.optionId, required this.option, required this.value, }); }
We need two widgets to form the basis of this. One widget will represent the unvoted state and the other will represent the voted state. Additionally, a boolean will be used to check if a person has voted or not. Therefore, let us start by creating a stateful widget with a few parameters.
class Polls extends StatefulWidget { Polls({ required this.children, required this.pollTitle, this.hasVoted, this.onVote, Key? key, }) : super(key: key); final Text pollTitle; final bool? hasVoted; final PollOnVote? onVote; Listchildren; @override PollsState createState() => PollsState(); } class PollsState extends State { @override Widget build(BuildContext context) { if (!hasVoted) { //user can cast vote with this widget return voterWidget(context); } else { //user can view his votes with this widget return voteCasted(context); } } /// voterWidget creates view for users to cast their votes Widget voterWidget(context) { return Container(); } /// voteCasted created view for user to see votes they casted including other peoples vote Widget voteCasted(context) { return Container(); } } typedef PollOnVote = void Function( PollOption pollOption, int optionIndex, );
Adding the UI code for voterWidget
and voteCasted
widget is simple – just Rows and Columns with some decorations. We won’t spend too much time on explaining it, so that we can get to the more interesting part of combining the UI and Logic.
class Polls extends StatefulWidget { Polls({ required this.children, required this.pollTitle, this.hasVoted, this.controller, this.onVote, this.outlineColor = Colors.blue, this.backgroundColor = Colors.blueGrey, this.onVoteBackgroundColor = Colors.blue, this.leadingPollStyle, this.pollStyle, this.iconColor = Colors.black, this.leadingBackgroundColor = Colors.blueGrey, this.barRadius = 10, this.userChoiceIcon, this.showLogger = true, this.totalVotes = 0, this.userPollChoice, Key? key, }) : super(key: key); final double barRadius; int? userPollChoice; final int totalVotes; final Text pollTitle; final Widget? userChoiceIcon; final bool? hasVoted; final bool showLogger; final PollOnVote? onVote; Listchildren; final PollController? controller; /// style final TextStyle? pollStyle; final TextStyle? leadingPollStyle; ///colors setting for polls widget final Color outlineColor; final Color backgroundColor; final Color? onVoteBackgroundColor; final Color? iconColor; final Color? leadingBackgroundColor; @override PollsState createState() => PollsState(); } class PollsState extends State { PollController? _controller; var choiceList = []; var userChoiceList = []; var valueList = []; var userValueList = []; /// style late TextStyle pollStyle; late TextStyle leadingPollStyle; ///colors setting for polls widget Color? outlineColor; Color? backgroundColor; Color? onVoteBackgroundColor; Color? iconColor; Color? leadingBackgroundColor; double highest = 0.0; bool hasVoted = false; @override void initState() { super.initState(); _controller = widget.controller; _controller ??= PollController(); _controller!.children = widget.children; hasVoted = widget.hasVoted ?? _controller!.hasVoted; _controller?.addListener(() { if (_controller!.makeChange) { hasVoted = _controller!.hasVoted; _updateView(); } }); _reCalibrate(); } void _updateView() { widget.children = _controller!.children; _controller!.revertChangeBoolean(); _reCalibrate(); } void _reCalibrate() { choiceList.clear(); userChoiceList.clear(); valueList.clear(); /// if polls style is null, it sets default pollstyle and leading pollstyle pollStyle = widget.pollStyle ?? const TextStyle(color: Colors.black, fontWeight: FontWeight.w300); leadingPollStyle = widget.leadingPollStyle ?? const TextStyle(color: Colors.black, fontWeight: FontWeight.w800); widget.children.map((e) { choiceList.add(e.option); userChoiceList.add(e.option); valueList.add(e.value); }).toList(); } @override Widget build(BuildContext context) { if (!hasVoted) { //user can cast vote with this widget return voterWidget(context); } else { //user can view his votes with this widget return voteCasted(context); } } /// voterWidget creates view for users to cast their votes Widget voterWidget(context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ widget.pollTitle, const SizedBox( height: 12, ), Column( children: widget.children.map((element) { int index = widget.children.indexOf(element); return Container( width: double.infinity, padding: const EdgeInsets.only(bottom: 10), child: Container( margin: const EdgeInsets.all(0), width: MediaQuery.of(context).size.width / 1.5, padding: const EdgeInsets.all(0), // height: 38, decoration: BoxDecoration( borderRadius: BorderRadius.circular(22), color: widget.backgroundColor, ), child: OutlinedButton( onPressed: () { widget.onVote!( widget.children[index], index, ); }, style: OutlinedButton.styleFrom( foregroundColor: widget.outlineColor, padding: const EdgeInsets.all(5.0), side: BorderSide( color: widget.outlineColor, ), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(widget.barRadius), ), ), child: Text( element.option, style: widget.pollStyle, maxLines: 2, ), ), ), ); }).toList(), ), ], ); } /// voteCasted created view for user to see votes they casted including other peoples vote Widget voteCasted(context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ widget.pollTitle, const SizedBox( height: 12, ), Column( children: widget.children.map( (element) { int index = widget.children.indexOf(element); return Container( margin: const EdgeInsets.symmetric(vertical: 5), width: double.infinity, child: LinearPercentIndicator( padding: EdgeInsets.zero, animation: true, lineHeight: 38.0, animationDuration: 500, percent: PollMethods.getViewPercentage(valueList, index + 1, 1), center: Padding( padding: const EdgeInsets.symmetric(horizontal: 10), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Row( mainAxisAlignment: MainAxisAlignment.start, children: [ Text( choiceList[index].toString(), style: highest == valueList[index] ? widget.leadingPollStyle : widget.pollStyle, ), ], ), Text( "${PollMethods.getViewPercentage(valueList, index + 1, 100).toStringAsFixed(1)}%", style: highest == valueList[index] ? widget.leadingPollStyle : widget.pollStyle, ) ], ), ), barRadius: Radius.circular(widget.barRadius), progressColor: highest == valueList[index] ? widget.leadingBackgroundColor : widget.onVoteBackgroundColor, ), ); }, ).toList(), ) ], ); } } class PollMethods { static double getViewPercentage(List valueList, choice, int byValue) { double div = 0.0; var slot = []; double sum = 0.0; valueList.map((element) { slot.add(element); }).toList(); valueList.map((element) { sum = slot.map((value) => value).fold(0, (a, b) => a + b); }).toList(); div = sum == 0 ? 0.0 : (byValue / sum) * slot[choice - 1]; return div; } } class PollController extends ChangeNotifier { var children = []; bool hasVoted = false; bool makeChange = false; void revertChangeBoolean() { makeChange = false; notifyListeners(); } } typedef PollOnVote = void Function( PollOption pollOption, int optionIndex, );
We have created a PollController
to store and update the UI when needed. To represent the number of votes for each option, we are using the LinearPercentIndicator
widget from a package that helps us in building the UI more easily. The code for this is included below.
import 'package:flutter/material.dart'; // ignore: must_be_immutable class LinearPercentIndicator extends StatefulWidget { ///Percent value between 0.0 and 1.0 final double percent; final double? width; ///Height of the line final double lineHeight; ///Color of the background of the Line , default = transparent final Color fillColor; ///First color applied to the complete line Color get backgroundColor => _backgroundColor; late Color _backgroundColor; ///First color applied to the complete line final LinearGradient? linearGradientBackgroundColor; Color get progressColor => _progressColor; late Color _progressColor; ///true if you want the Line to have animation final bool animation; ///duration of the animation in milliseconds, It only applies if animation attribute is true final int animationDuration; ///widget at the left of the Line final Widget? leading; ///widget at the right of the Line final Widget? trailing; ///widget inside the Line final Widget? center; ///The kind of finish to place on the end of lines drawn, values supported: butt, round, roundAll // @Deprecated('This property is no longer used, please use barRadius instead.') // final LinearStrokeCap? linearStrokeCap; /// The border radius of the progress bar (Will replace linearStrokeCap) final Radius? barRadius; ///alignment of the Row (leading-widget-center-trailing) final MainAxisAlignment alignment; ///padding to the LinearPercentIndicator final EdgeInsets padding; /// set true if you want to animate the linear from the last percent value you set final bool animateFromLastPercent; /// If present, this will make the progress bar colored by this gradient. /// /// This will override [progressColor]. It is an error to provide both. final LinearGradient? linearGradient; /// set false if you don't want to preserve the state of the widget final bool addAutomaticKeepAlive; /// set true if you want to animate the linear from the right to left (RTL) final bool isRTL; /// Creates a mask filter that takes the progress shape being drawn and blurs it. final MaskFilter? maskFilter; /// Set true if you want to display only part of [linearGradient] based on percent value /// (ie. create 'VU effect'). If no [linearGradient] is specified this option is ignored. final bool clipLinearGradient; /// set a linear curve animation type final Curve curve; /// set true when you want to restart the animation, it restarts only when reaches 1.0 as a value /// defaults to false final bool restartAnimation; /// Callback called when the animation ends (only if `animation` is true) final VoidCallback? onAnimationEnd; /// Display a widget indicator at the end of the progress. It only works when `animation` is true final Widget? widgetIndicator; LinearPercentIndicator({ Key? key, this.fillColor = Colors.transparent, this.percent = 0.0, this.lineHeight = 5.0, this.width, Color? backgroundColor, this.linearGradientBackgroundColor, this.linearGradient, Color? progressColor, this.animation = false, this.animationDuration = 500, this.animateFromLastPercent = false, this.isRTL = false, this.leading, this.trailing, this.center, this.addAutomaticKeepAlive = true, // this.linearStrokeCap, this.barRadius, this.padding = const EdgeInsets.symmetric(horizontal: 10.0), this.alignment = MainAxisAlignment.start, this.maskFilter, this.clipLinearGradient = false, this.curve = Curves.linear, this.restartAnimation = false, this.onAnimationEnd, this.widgetIndicator, }) : super(key: key) { if (linearGradient != null && progressColor != null) { throw ArgumentError( 'Cannot provide both linearGradient and progressColor'); } _progressColor = progressColor ?? Colors.red; if (linearGradientBackgroundColor != null && backgroundColor != null) { throw ArgumentError( 'Cannot provide both linearGradientBackgroundColor and backgroundColor'); } _backgroundColor = backgroundColor ?? const Color(0xFFB8C7CB); if (percent < 0.0 || percent > 1.0) { throw Exception( "Percent value must be a double between 0.0 and 1.0, but it's $percent"); } } @override LinearPercentIndicatorState createState() => LinearPercentIndicatorState(); } class LinearPercentIndicatorState extends Statewith SingleTickerProviderStateMixin, AutomaticKeepAliveClientMixin { AnimationController? _animationController; Animation? _animation; double _percent = 0.0; final _containerKey = GlobalKey(); final _keyIndicator = GlobalKey(); double _containerWidth = 0.0; double _containerHeight = 0.0; double _indicatorWidth = 0.0; double _indicatorHeight = 0.0; @override void dispose() { _animationController?.dispose(); super.dispose(); } @override void initState() { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { setState(() { _containerWidth = _containerKey.currentContext?.size?.width ?? 0.0; _containerHeight = _containerKey.currentContext?.size?.height ?? 0.0; if (_keyIndicator.currentContext != null) { _indicatorWidth = _keyIndicator.currentContext?.size?.width ?? 0.0; _indicatorHeight = _keyIndicator.currentContext?.size?.height ?? 0.0; } }); } }); if (widget.animation) { _animationController = AnimationController( vsync: this, duration: Duration(milliseconds: widget.animationDuration)); _animation = Tween(begin: 0.0, end: widget.percent).animate( CurvedAnimation(parent: _animationController!, curve: widget.curve), )..addListener(() { setState(() { _percent = _animation!.value; }); if (widget.restartAnimation && _percent == 1.0) { _animationController!.repeat(min: 0, max: 1.0); } }); _animationController!.addStatusListener((status) { if (widget.onAnimationEnd != null && status == AnimationStatus.completed) { widget.onAnimationEnd!(); } }); _animationController!.forward(); } else { _updateProgress(); } super.initState(); } void _checkIfNeedCancelAnimation(LinearPercentIndicator oldWidget) { if (oldWidget.animation && !widget.animation && _animationController != null) { _animationController!.stop(); } } @override void didUpdateWidget(LinearPercentIndicator oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.percent != widget.percent) { if (_animationController != null) { _animationController!.duration = Duration(milliseconds: widget.animationDuration); _animation = Tween( begin: widget.animateFromLastPercent ? oldWidget.percent : 0.0, end: widget.percent) .animate( CurvedAnimation(parent: _animationController!, curve: widget.curve), ); _animationController!.forward(from: 0.0); } else { _updateProgress(); } } _checkIfNeedCancelAnimation(oldWidget); } _updateProgress() { setState(() { _percent = widget.percent; }); } @override Widget build(BuildContext context) { super.build(context); var items = List .empty(growable: true); if (widget.leading != null) { items.add(widget.leading!); } final hasSetWidth = widget.width != null; final percentPositionedHorizontal = _containerWidth * _percent - _indicatorWidth / 3; var containerWidget = Container( width: hasSetWidth ? widget.width : double.infinity, height: widget.lineHeight, padding: widget.padding, child: Stack( clipBehavior: Clip.none, children: [ CustomPaint( key: _containerKey, painter: _LinearPainter( isRTL: widget.isRTL, progress: _percent, progressColor: widget.progressColor, linearGradient: widget.linearGradient, backgroundColor: widget.backgroundColor, barRadius: widget.barRadius ?? Radius.zero, // If radius is not defined, set it to zero linearGradientBackgroundColor: widget.linearGradientBackgroundColor, maskFilter: widget.maskFilter, clipLinearGradient: widget.clipLinearGradient, ), child: (widget.center != null) ? Center(child: widget.center) : Container(), ), if (widget.widgetIndicator != null && _indicatorWidth == 0) Opacity( opacity: 0.0, key: _keyIndicator, child: widget.widgetIndicator, ), if (widget.widgetIndicator != null && _containerWidth > 0 && _indicatorWidth > 0) Positioned( right: widget.isRTL ? percentPositionedHorizontal : null, left: !widget.isRTL ? percentPositionedHorizontal : null, top: _containerHeight / 2 - _indicatorHeight, child: widget.widgetIndicator!, ), ], ), ); if (hasSetWidth) { items.add(containerWidget); } else { items.add(Expanded( child: containerWidget, )); } if (widget.trailing != null) { items.add(widget.trailing!); } return Material( color: Colors.transparent, child: Container( color: widget.fillColor, child: Row( mainAxisAlignment: widget.alignment, crossAxisAlignment: CrossAxisAlignment.center, children: items, ), ), ); } @override bool get wantKeepAlive => widget.addAutomaticKeepAlive; } class _LinearPainter extends CustomPainter { final Paint _paintBackground = Paint(); final Paint _paintLine = Paint(); final double progress; final bool isRTL; final Color progressColor; final Color backgroundColor; final Radius barRadius; final LinearGradient? linearGradient; final LinearGradient? linearGradientBackgroundColor; final MaskFilter? maskFilter; final bool clipLinearGradient; _LinearPainter({ required this.progress, required this.isRTL, required this.progressColor, required this.backgroundColor, required this.barRadius, this.linearGradient, this.maskFilter, required this.clipLinearGradient, this.linearGradientBackgroundColor, }) { _paintBackground.color = backgroundColor; _paintLine.color = progress.toString() == "0.0" ? progressColor.withOpacity(0.0) : progressColor; } @override void paint(Canvas canvas, Size size) { // Draw background first Path backgroundPath = Path(); backgroundPath.addRRect(RRect.fromRectAndRadius( Rect.fromLTWH(0, 0, size.width, size.height), barRadius)); canvas.drawPath(backgroundPath, _paintBackground); canvas.clipPath(backgroundPath); if (maskFilter != null) { _paintLine.maskFilter = maskFilter; } if (linearGradientBackgroundColor != null) { Offset shaderEndPoint = clipLinearGradient ? Offset.zero : Offset(size.width, size.height); _paintBackground.shader = linearGradientBackgroundColor ?.createShader(Rect.fromPoints(Offset.zero, shaderEndPoint)); } // Then draw progress line final xProgress = size.width * progress; Path linePath = Path(); if (isRTL) { if (linearGradient != null) { _paintLine.shader = _createGradientShaderRightToLeft(size, xProgress); } linePath.addRRect(RRect.fromRectAndRadius( Rect.fromLTWH( size.width - size.width * progress, 0, xProgress, size.height), barRadius)); } else { if (linearGradient != null) { _paintLine.shader = _createGradientShaderLeftToRight(size, xProgress); } linePath.addRRect(RRect.fromRectAndRadius( Rect.fromLTWH(0, 0, xProgress, size.height), barRadius)); } canvas.drawPath(linePath, _paintLine); } Shader _createGradientShaderRightToLeft(Size size, double xProgress) { Offset shaderEndPoint = clipLinearGradient ? Offset.zero : Offset(xProgress, size.height); return linearGradient!.createShader( Rect.fromPoints( Offset(size.width, size.height), shaderEndPoint, ), ); } Shader _createGradientShaderLeftToRight(Size size, double xProgress) { Offset shaderEndPoint = clipLinearGradient ? Offset(size.width, size.height) : Offset(xProgress, size.height); return linearGradient!.createShader( Rect.fromPoints( Offset.zero, shaderEndPoint, ), ); } @override bool shouldRepaint(CustomPainter oldDelegate) => true; }
Now, we have the Polls code ready to be integrated with our chat screen UI. If you go to the chat_screen.dart
file, you can see that every message is rendered as a chat_list_item
widget.
To render our Polls widget, let’s create a chat_poll_item widget and wrap it according to our chat_screen.
In the same directory, create a new file called chat_poll_item.dart
.
class ChatPollItem extends StatelessWidget { const ChatPollItem({ Key? key, }) : super(key: key); @override Widget build(BuildContext context) { return Container(); }
We need a PollMessageCreate
object to create the poll with the title and options. We also need a dialogType
int to identify the chat as a group chat and to build an Avatar frame. Additionally, we need the boolean, if the user has voted in the poll or not. To check if the current user has voted, we can simply check if the votes property of the PollMessageCreate
, which is a
type Map of
, contains the currentUserId
.
Now that we have all the necessary parameters, let’s pair them up with the UI and render the Poll.
import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:quickblox_polls_feature/bloc/chat/chat_screen_bloc.dart'; import 'package:quickblox_polls_feature/bloc/chat/chat_screen_events.dart'; import 'package:quickblox_polls_feature/models/poll_action.dart'; import 'package:quickblox_polls_feature/models/poll_message.dart'; import 'package:quickblox_polls_feature/presentation/screens/chat/avatar_noname.dart'; import 'package:quickblox_polls_feature/presentation/screens/chat/polls.dart'; import 'package:quickblox_sdk/chat/constants.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:quickblox_polls_feature/bloc/chat/chat_screen_bloc.dart'; import 'package:quickblox_polls_feature/bloc/chat/chat_screen_events.dart'; import 'package:quickblox_polls_feature/models/poll_action.dart'; import 'package:quickblox_polls_feature/models/poll_message.dart'; import 'package:quickblox_polls_feature/presentation/screens/chat/avatar_noname.dart'; import 'package:quickblox_polls_feature/presentation/screens/chat/polls.dart'; import 'package:quickblox_sdk/chat/constants.dart'; class ChatPollItem extends StatelessWidget { final PollMessage message; final int? dialogType; const ChatPollItem({required this.message, this.dialogType, Key? key}) : super(key: key); @override Widget build(BuildContext context) { final Listvoters = message.votes.keys.map((userId) => int.parse(userId)).toList(); bool hasVoted = voters.contains(message.currentUserId); return Container( padding: const EdgeInsets.only(left: 10, right: 12, bottom: 8), child: Row( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.end, children: [ Container( child: message.isIncoming && dialogType != QBChatDialogTypes.CHAT ? AvatarFromName(name: message.senderName) : null), Padding(padding: EdgeInsets.only(left: dialogType == 3 ? 0 : 16)), Expanded( child: Padding( padding: const EdgeInsets.only(top: 15), child: Column( crossAxisAlignment: message.isIncoming ? CrossAxisAlignment.start : CrossAxisAlignment.end, children: [ IntrinsicWidth( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Polls( onVote: (pollOption, optionIndex) { ///TODO: Explained in next section }, pollStyle: TextStyle( overflow: TextOverflow.ellipsis, fontSize: 15, color: message.isIncoming ? Colors.black87 : Colors.white, ), backgroundColor: message.isIncoming ? Colors.white : Colors.blue, outlineColor: Colors.transparent, hasVoted: hasVoted, children: message.options.entries .map((option) => PollOption( optionId: option.key, //OptionID option: option.value, //Option Value (Text) value: message.votes.values .where((choosenOptionID) => choosenOptionID == option.key) .length .toDouble())) .toList(), pollTitle: Text( message.pollTitle, ), ), ], ), ), ], ), )) ], ), ); } } class AvatarFromName extends StatelessWidget { const AvatarFromName({ Key? key, String? name, }) : _name = name ?? "Noname", super(key: key); final String _name; @override Widget build(BuildContext context) { return Container( width: 40, height: 40, decoration: BoxDecoration( color: Color(ColorUtil.getColor(_name)), borderRadius: const BorderRadius.all( Radius.circular(20), ), ), child: Center( child: Text( _name.substring(0, 1).toUpperCase(), style: const TextStyle( color: Colors.white, fontWeight: FontWeight.bold, ), ), ), ); } }
The above code should be self-explanatory, as we have already covered many of the points. We are calculating the value of the hasVoted
variable on runtime by checking if the list of voters
contains our currentUserId
. Additionally, we have an onVote
callback, which will trigger the vote action. We have marked this as a TODO in the code snippet above as we’ll get to it in the following section.
As we have yet to build the actual form for creating the poll, let’s complete this task now. We’ll create a simple form.
We need to modify the _buildEnterMessageRow
method in the chat_screen.dart
file to add an extra button at the beginning, which will enable us to create a poll.
Widget _buildEnterMessageRow() { return SafeArea( child: Column( children: [ _buildTypingIndicator(), Container( color: Colors.white, child: Row( mainAxisSize: MainAxisSize.max, mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.center, children:[ // Our code here SizedBox( width: 50, height: 50, child: IconButton( icon: const Icon( Icons.poll, color: Colors.blue, ), onPressed: () async { final formKey = GlobalKey (); final pollTitleController = TextEditingController(); final pollOption1Controller = TextEditingController(); final pollOption2Controller = TextEditingController(); final pollOption3Controller = TextEditingController(); final pollOption4Controller = TextEditingController(); await showModalBottomSheet( isScrollControlled: true, enableDrag: true, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical( top: Radius.circular(20.0), ), ), context: context, backgroundColor: Colors.white, builder: (context) { return Padding( padding: EdgeInsets.only( bottom: MediaQuery.of(context).viewInsets.bottom), child: Container( padding: const EdgeInsets.all(20.0), child: Form( key: formKey, child: SingleChildScrollView( child: Column( children: [ PollTextFieldRow( label: 'Enter Poll Title here', txtController: pollTitleController, ), PollTextFieldRow( label: 'Poll Option 1', txtController: pollOption1Controller, ), PollTextFieldRow( label: 'Poll Option 2', txtController: pollOption2Controller, ), PollTextFieldRow( label: 'Poll Option 3', txtController: pollOption3Controller, ), PollTextFieldRow( label: 'Poll Option 4', txtController: pollOption4Controller, ), ElevatedButton( onPressed: () { //TODO: Figure this out in next section }, child: const Text('Create Poll'), ), ], ), ), ), ), ); }, ); }, ), ), SizedBox( width: 50, height: 50, child: IconButton( // Some code present here ), ), Expanded( child: Container( // Some code present here ), ), SizedBox( width: 50, height: 50, child: IconButton( // Some code present here ), ), ], ), ), ], ), ); }
We have just added a new IconButton
to the Enter message row in the code above. To keep the snippets short, we have added comments instead of copying all the code; you can find the complete code in the attached repo. This form is quite simple and contains a button which creates a form. The next section will focus on actually creating the poll, as indicated by the TODO.
Let’s plug our logic with the UI to make it happen. To bind everything together, we need to take the following steps:
PollListItem
widget in the ChatScreen
.Let’s start by figuring out the TODO on the onPressed
callback of the Poll creation form.
We need to send a message to Quickblox with the required parameters to treat it as a Poll Creation message.
// Create poll button code from chat_screen.dart ElevatedButton( onPressed: () { //Cancel the Typing status timer TypingStatusManager.cancelTimer(); //Add the CreatePoll event to the BLoC bloc?.events?.add( CreatePollMessageEvent( PollActionCreate.fromData( pollTitleController.text.trim(), [ pollOption1Controller.text .trim(), pollOption2Controller.text .trim(), pollOption3Controller.text .trim(), pollOption4Controller.text .trim(), ], ), ), ); //Pop the bottom modal sheet Navigator.of(context).pop(); }, child: const Text('Create Poll'), ),
Next, we need to work on the other TODO, which is to vote in the poll. It’s pretty straightforward, as you can see from the code snippet below.
Polls( onVote: (pollOption, optionIndex) { // If the user has already voted, Don't do anything if (!hasVoted) { // Add the VoteToPoll event to the BLoC Provider.of(context, listen: false) .events ?.add( VoteToPollEvent( PollActionVote( pollId: message.pollID, voteOptionId: pollOption.optionId!, ), ), ); } }, pollStyle: TextStyle( overflow: TextOverflow.ellipsis, fontSize: 15, color: message.isIncoming ? Colors.black87 : Colors.white, ), backgroundColor: message.isIncoming ? Colors.white : Colors.blue, outlineColor: Colors.transparent, hasVoted: hasVoted, children: message.options.entries .map((e) => PollOption( optionId: e.key, option: e.value, value: votes .map((e) => e.choosenOption) .where((option) => option == e.key) .length .toDouble())) .toList(), pollTitle: Text( message.pollTitle, ), ),
As you can see, everything was already prepared, so we just had to add the events with the necessary information and that was it.
Now, let’s integrate the PollListItem
widget into the ChatScreen
. If you take a look inside ChatScreen
, you’ll notice that there is a GroupedList
that groups and displays the messages. Let’s modify its itemBuilder
to detect and display a PollMessage
.
itemBuilder: (context, QBMessageWrapper message) { if (message is PollMessageCreate) { return ChatPollItem( message: message, key: ValueKey( Key( RandomUtil.getRandomString(10), ), ), ); } return GestureDetector( child: ChatListItem( Key( RandomUtil.getRandomString(10), ), message, _dialogType, ), onTapDown: (details) { tapPosition = details.globalPosition; }, onLongPress: () { //More code present here } ); },
We’re checking if the message is a PollMessageCreate
, calculating the votes for the poll, and then rendering the ChatPollItem
. The final version looks like this:
Well done!
If you have followed all the steps correctly, you should have the working polls functionality ready. However, if you see any errors or any left-out pieces, you can always compare your code to the full source code available in our repository, and this will help you finish the task.
This article concentrated on constructing the polls functionality, rather than refining the UI/UX. But there are many additional steps you can take to enhance these aspects.
Let us know what else you think should be improved in the comments below.
Refer the QuickBlox docs for more information.