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 Map pollOptions;
factory PollActionCreate.fromData(
String title,
List options,
) {
const uuid = Uuid();
return PollActionCreate(
pollTitle: title,
pollOptions: {for (var element in options) uuid.v4(): element},
);
}
Map toJson() {
return {
"title": pollTitle,
"options": jsonEncode(pollOptions),
"votes": jsonEncode({})
};
}
}
class PollActionVote {
const PollActionVote(
{required this.pollID,
required this.votes,
required this.currentUserID,
required this.choosenOptionID});
final String pollID;
final Map votes;
final String choosenOptionID;
final String currentUserID;
Map get updatedVotes {
votes[currentUserID] = choosenOptionID;
return {"votes": jsonEncode(votes)};
}
}
We also need a model that will come in handy when we receive the data, in addition to the models used to parse and send the data.
We will create a class PollMessage, which extends QBMessageWrapper to contain all properties that are specific to polls.
import 'dart:convert';
import 'package:quickblox_polls_feature/models/message_wrapper.dart';
import 'package:quickblox_sdk/models/qb_custom_object.dart';
import 'package:quickblox_sdk/models/qb_message.dart';
class PollMessage extends QBMessageWrapper {
PollMessage(super.senderName, super.message, super.currentUserId,
{required this.pollID,
required this.pollTitle,
required this.options,
required this.votes});
final String pollID;
final String pollTitle;
final Map options;
final Map votes;
factory PollMessage.fromCustomObject(String senderName, QBMessage message,
int currentUserId, QBCustomObject object) {
return PollMessage(senderName, message, currentUserId,
pollID: message.properties!['pollID']!,
pollTitle: object.fields!['title'] as String,
options: Map.from(
jsonDecode(object.fields!['options'] as String)),
votes: Map.from(
jsonDecode(object.fields!['votes'] as String)));
}
PollMessage copyWith({Map? votes}) {
return PollMessage(senderName!, qbMessage, currentUserId,
pollID: pollID,
pollTitle: pollTitle,
options: options,
votes: votes ?? this.votes);
}
}
With this, we have the models ready. Now let’s start coding!
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;
List children;
@override
PollsState createState() => PollsState();
}
class PollsState extends State {
@override
Widget build(BuildContext context) {
if (!hasVoted) {
//user can cast vote with this widget
return voterWidget(context);
} else {
//user can view his votes with this widget
return voteCasted(context);
}
}
/// voterWidget creates view for users to cast their votes
Widget voterWidget(context) {
return Container();
}
/// voteCasted created view for user to see votes they casted including other peoples vote
Widget voteCasted(context) {
return Container();
}
}
typedef PollOnVote = void Function(
PollOption pollOption,
int optionIndex,
);
Adding the UI code for voterWidget and voteCasted widget is simple – just Rows and Columns with some decorations. We won’t spend too much time on explaining it, so that we can get to the more interesting part of combining the UI and Logic.
class Polls extends StatefulWidget {
Polls({
required this.children,
required this.pollTitle,
this.hasVoted,
this.controller,
this.onVote,
this.outlineColor = Colors.blue,
this.backgroundColor = Colors.blueGrey,
this.onVoteBackgroundColor = Colors.blue,
this.leadingPollStyle,
this.pollStyle,
this.iconColor = Colors.black,
this.leadingBackgroundColor = Colors.blueGrey,
this.barRadius = 10,
this.userChoiceIcon,
this.showLogger = true,
this.totalVotes = 0,
this.userPollChoice,
Key? key,
}) : super(key: key);
final double barRadius;
int? userPollChoice;
final int totalVotes;
final Text pollTitle;
final Widget? userChoiceIcon;
final bool? hasVoted;
final bool showLogger;
final PollOnVote? onVote;
List children;
final PollController? controller;
/// style
final TextStyle? pollStyle;
final TextStyle? leadingPollStyle;
///colors setting for polls widget
final Color outlineColor;
final Color backgroundColor;
final Color? onVoteBackgroundColor;
final Color? iconColor;
final Color? leadingBackgroundColor;
@override
PollsState createState() => PollsState();
}
class PollsState extends State {
PollController? _controller;
var choiceList = [];
var userChoiceList = [];
var valueList = [];
var userValueList = [];
/// style
late TextStyle pollStyle;
late TextStyle leadingPollStyle;
///colors setting for polls widget
Color? outlineColor;
Color? backgroundColor;
Color? onVoteBackgroundColor;
Color? iconColor;
Color? leadingBackgroundColor;
double highest = 0.0;
bool hasVoted = false;
@override
void initState() {
super.initState();
_controller = widget.controller;
_controller ??= PollController();
_controller!.children = widget.children;
hasVoted = widget.hasVoted ?? _controller!.hasVoted;
_controller?.addListener(() {
if (_controller!.makeChange) {
hasVoted = _controller!.hasVoted;
_updateView();
}
});
_reCalibrate();
}
void _updateView() {
widget.children = _controller!.children;
_controller!.revertChangeBoolean();
_reCalibrate();
}
void _reCalibrate() {
choiceList.clear();
userChoiceList.clear();
valueList.clear();
/// if polls style is null, it sets default pollstyle and leading pollstyle
pollStyle = widget.pollStyle ??
const TextStyle(color: Colors.black, fontWeight: FontWeight.w300);
leadingPollStyle = widget.leadingPollStyle ??
const TextStyle(color: Colors.black, fontWeight: FontWeight.w800);
widget.children.map((e) {
choiceList.add(e.option);
userChoiceList.add(e.option);
valueList.add(e.value);
}).toList();
}
@override
Widget build(BuildContext context) {
if (!hasVoted) {
//user can cast vote with this widget
return voterWidget(context);
} else {
//user can view his votes with this widget
return voteCasted(context);
}
}
/// voterWidget creates view for users to cast their votes
Widget voterWidget(context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
widget.pollTitle,
const SizedBox(
height: 12,
),
Column(
children: widget.children.map((element) {
int index = widget.children.indexOf(element);
return Container(
width: double.infinity,
padding: const EdgeInsets.only(bottom: 10),
child: Container(
margin: const EdgeInsets.all(0),
width: MediaQuery.of(context).size.width / 1.5,
padding: const EdgeInsets.all(0),
// height: 38,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(22),
color: widget.backgroundColor,
),
child: OutlinedButton(
onPressed: () {
widget.onVote!(
widget.children[index],
index,
);
},
style: OutlinedButton.styleFrom(
foregroundColor: widget.outlineColor,
padding: const EdgeInsets.all(5.0),
side: BorderSide(
color: widget.outlineColor,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(widget.barRadius),
),
),
child: Text(
element.option,
style: widget.pollStyle,
maxLines: 2,
),
),
),
);
}).toList(),
),
],
);
}
/// voteCasted created view for user to see votes they casted including other peoples vote
Widget voteCasted(context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
widget.pollTitle,
const SizedBox(
height: 12,
),
Column(
children: widget.children.map(
(element) {
int index = widget.children.indexOf(element);
return Container(
margin: const EdgeInsets.symmetric(vertical: 5),
width: double.infinity,
child: LinearPercentIndicator(
padding: EdgeInsets.zero,
animation: true,
lineHeight: 38.0,
animationDuration: 500,
percent:
PollMethods.getViewPercentage(valueList, index + 1, 1),
center: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
Text(
choiceList[index].toString(),
style: highest == valueList[index]
? widget.leadingPollStyle
: widget.pollStyle,
),
],
),
Text(
"${PollMethods.getViewPercentage(valueList, index + 1, 100).toStringAsFixed(1)}%",
style: highest == valueList[index]
? widget.leadingPollStyle
: widget.pollStyle,
)
],
),
),
barRadius: Radius.circular(widget.barRadius),
progressColor: highest == valueList[index]
? widget.leadingBackgroundColor
: widget.onVoteBackgroundColor,
),
);
},
).toList(),
)
],
);
}
}
class PollMethods {
static double getViewPercentage(List valueList, choice, int byValue) {
double div = 0.0;
var slot = [];
double sum = 0.0;
valueList.map((element) {
slot.add(element);
}).toList();
valueList.map((element) {
sum = slot.map((value) => value).fold(0, (a, b) => a + b);
}).toList();
div = sum == 0 ? 0.0 : (byValue / sum) * slot[choice - 1];
return div;
}
}
class PollController extends ChangeNotifier {
var children = [];
bool hasVoted = false;
bool makeChange = false;
void revertChangeBoolean() {
makeChange = false;
notifyListeners();
}
}
typedef PollOnVote = void Function(
PollOption pollOption,
int optionIndex,
);
We have created a PollController to store and update the UI when needed. To represent the number of votes for each option, we are using the LinearPercentIndicator widget from a package that helps us in building the UI more easily. The code for this is included below.
import 'package:flutter/material.dart';
// ignore: must_be_immutable
class LinearPercentIndicator extends StatefulWidget {
///Percent value between 0.0 and 1.0
final double percent;
final double? width;
///Height of the line
final double lineHeight;
///Color of the background of the Line , default = transparent
final Color fillColor;
///First color applied to the complete line
Color get backgroundColor => _backgroundColor;
late Color _backgroundColor;
///First color applied to the complete line
final LinearGradient? linearGradientBackgroundColor;
Color get progressColor => _progressColor;
late Color _progressColor;
///true if you want the Line to have animation
final bool animation;
///duration of the animation in milliseconds, It only applies if animation attribute is true
final int animationDuration;
///widget at the left of the Line
final Widget? leading;
///widget at the right of the Line
final Widget? trailing;
///widget inside the Line
final Widget? center;
///The kind of finish to place on the end of lines drawn, values supported: butt, round, roundAll
// @Deprecated('This property is no longer used, please use barRadius instead.')
// final LinearStrokeCap? linearStrokeCap;
/// The border radius of the progress bar (Will replace linearStrokeCap)
final Radius? barRadius;
///alignment of the Row (leading-widget-center-trailing)
final MainAxisAlignment alignment;
///padding to the LinearPercentIndicator
final EdgeInsets padding;
/// set true if you want to animate the linear from the last percent value you set
final bool animateFromLastPercent;
/// If present, this will make the progress bar colored by this gradient.
///
/// This will override [progressColor]. It is an error to provide both.
final LinearGradient? linearGradient;
/// set false if you don't want to preserve the state of the widget
final bool addAutomaticKeepAlive;
/// set true if you want to animate the linear from the right to left (RTL)
final bool isRTL;
/// Creates a mask filter that takes the progress shape being drawn and blurs it.
final MaskFilter? maskFilter;
/// Set true if you want to display only part of [linearGradient] based on percent value
/// (ie. create 'VU effect'). If no [linearGradient] is specified this option is ignored.
final bool clipLinearGradient;
/// set a linear curve animation type
final Curve curve;
/// set true when you want to restart the animation, it restarts only when reaches 1.0 as a value
/// defaults to false
final bool restartAnimation;
/// Callback called when the animation ends (only if `animation` is true)
final VoidCallback? onAnimationEnd;
/// Display a widget indicator at the end of the progress. It only works when `animation` is true
final Widget? widgetIndicator;
LinearPercentIndicator({
Key? key,
this.fillColor = Colors.transparent,
this.percent = 0.0,
this.lineHeight = 5.0,
this.width,
Color? backgroundColor,
this.linearGradientBackgroundColor,
this.linearGradient,
Color? progressColor,
this.animation = false,
this.animationDuration = 500,
this.animateFromLastPercent = false,
this.isRTL = false,
this.leading,
this.trailing,
this.center,
this.addAutomaticKeepAlive = true,
// this.linearStrokeCap,
this.barRadius,
this.padding = const EdgeInsets.symmetric(horizontal: 10.0),
this.alignment = MainAxisAlignment.start,
this.maskFilter,
this.clipLinearGradient = false,
this.curve = Curves.linear,
this.restartAnimation = false,
this.onAnimationEnd,
this.widgetIndicator,
}) : super(key: key) {
if (linearGradient != null && progressColor != null) {
throw ArgumentError(
'Cannot provide both linearGradient and progressColor');
}
_progressColor = progressColor ?? Colors.red;
if (linearGradientBackgroundColor != null && backgroundColor != null) {
throw ArgumentError(
'Cannot provide both linearGradientBackgroundColor and backgroundColor');
}
_backgroundColor = backgroundColor ?? const Color(0xFFB8C7CB);
if (percent < 0.0 || percent > 1.0) {
throw Exception(
"Percent value must be a double between 0.0 and 1.0, but it's $percent");
}
}
@override
LinearPercentIndicatorState createState() => LinearPercentIndicatorState();
}
class LinearPercentIndicatorState extends State
with SingleTickerProviderStateMixin, AutomaticKeepAliveClientMixin {
AnimationController? _animationController;
Animation? _animation;
double _percent = 0.0;
final _containerKey = GlobalKey();
final _keyIndicator = GlobalKey();
double _containerWidth = 0.0;
double _containerHeight = 0.0;
double _indicatorWidth = 0.0;
double _indicatorHeight = 0.0;
@override
void dispose() {
_animationController?.dispose();
super.dispose();
}
@override
void initState() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
setState(() {
_containerWidth = _containerKey.currentContext?.size?.width ?? 0.0;
_containerHeight = _containerKey.currentContext?.size?.height ?? 0.0;
if (_keyIndicator.currentContext != null) {
_indicatorWidth = _keyIndicator.currentContext?.size?.width ?? 0.0;
_indicatorHeight =
_keyIndicator.currentContext?.size?.height ?? 0.0;
}
});
}
});
if (widget.animation) {
_animationController = AnimationController(
vsync: this,
duration: Duration(milliseconds: widget.animationDuration));
_animation = Tween(begin: 0.0, end: widget.percent).animate(
CurvedAnimation(parent: _animationController!, curve: widget.curve),
)..addListener(() {
setState(() {
_percent = _animation!.value;
});
if (widget.restartAnimation && _percent == 1.0) {
_animationController!.repeat(min: 0, max: 1.0);
}
});
_animationController!.addStatusListener((status) {
if (widget.onAnimationEnd != null &&
status == AnimationStatus.completed) {
widget.onAnimationEnd!();
}
});
_animationController!.forward();
} else {
_updateProgress();
}
super.initState();
}
void _checkIfNeedCancelAnimation(LinearPercentIndicator oldWidget) {
if (oldWidget.animation &&
!widget.animation &&
_animationController != null) {
_animationController!.stop();
}
}
@override
void didUpdateWidget(LinearPercentIndicator oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.percent != widget.percent) {
if (_animationController != null) {
_animationController!.duration =
Duration(milliseconds: widget.animationDuration);
_animation = Tween(
begin: widget.animateFromLastPercent ? oldWidget.percent : 0.0,
end: widget.percent)
.animate(
CurvedAnimation(parent: _animationController!, curve: widget.curve),
);
_animationController!.forward(from: 0.0);
} else {
_updateProgress();
}
}
_checkIfNeedCancelAnimation(oldWidget);
}
_updateProgress() {
setState(() {
_percent = widget.percent;
});
}
@override
Widget build(BuildContext context) {
super.build(context);
var items = List.empty(growable: true);
if (widget.leading != null) {
items.add(widget.leading!);
}
final hasSetWidth = widget.width != null;
final percentPositionedHorizontal =
_containerWidth * _percent - _indicatorWidth / 3;
var containerWidget = Container(
width: hasSetWidth ? widget.width : double.infinity,
height: widget.lineHeight,
padding: widget.padding,
child: Stack(
clipBehavior: Clip.none,
children: [
CustomPaint(
key: _containerKey,
painter: _LinearPainter(
isRTL: widget.isRTL,
progress: _percent,
progressColor: widget.progressColor,
linearGradient: widget.linearGradient,
backgroundColor: widget.backgroundColor,
barRadius: widget.barRadius ??
Radius.zero, // If radius is not defined, set it to zero
linearGradientBackgroundColor:
widget.linearGradientBackgroundColor,
maskFilter: widget.maskFilter,
clipLinearGradient: widget.clipLinearGradient,
),
child: (widget.center != null)
? Center(child: widget.center)
: Container(),
),
if (widget.widgetIndicator != null && _indicatorWidth == 0)
Opacity(
opacity: 0.0,
key: _keyIndicator,
child: widget.widgetIndicator,
),
if (widget.widgetIndicator != null &&
_containerWidth > 0 &&
_indicatorWidth > 0)
Positioned(
right: widget.isRTL ? percentPositionedHorizontal : null,
left: !widget.isRTL ? percentPositionedHorizontal : null,
top: _containerHeight / 2 - _indicatorHeight,
child: widget.widgetIndicator!,
),
],
),
);
if (hasSetWidth) {
items.add(containerWidget);
} else {
items.add(Expanded(
child: containerWidget,
));
}
if (widget.trailing != null) {
items.add(widget.trailing!);
}
return Material(
color: Colors.transparent,
child: Container(
color: widget.fillColor,
child: Row(
mainAxisAlignment: widget.alignment,
crossAxisAlignment: CrossAxisAlignment.center,
children: items,
),
),
);
}
@override
bool get wantKeepAlive => widget.addAutomaticKeepAlive;
}
class _LinearPainter extends CustomPainter {
final Paint _paintBackground = Paint();
final Paint _paintLine = Paint();
final double progress;
final bool isRTL;
final Color progressColor;
final Color backgroundColor;
final Radius barRadius;
final LinearGradient? linearGradient;
final LinearGradient? linearGradientBackgroundColor;
final MaskFilter? maskFilter;
final bool clipLinearGradient;
_LinearPainter({
required this.progress,
required this.isRTL,
required this.progressColor,
required this.backgroundColor,
required this.barRadius,
this.linearGradient,
this.maskFilter,
required this.clipLinearGradient,
this.linearGradientBackgroundColor,
}) {
_paintBackground.color = backgroundColor;
_paintLine.color = progress.toString() == "0.0"
? progressColor.withOpacity(0.0)
: progressColor;
}
@override
void paint(Canvas canvas, Size size) {
// Draw background first
Path backgroundPath = Path();
backgroundPath.addRRect(RRect.fromRectAndRadius(
Rect.fromLTWH(0, 0, size.width, size.height), barRadius));
canvas.drawPath(backgroundPath, _paintBackground);
canvas.clipPath(backgroundPath);
if (maskFilter != null) {
_paintLine.maskFilter = maskFilter;
}
if (linearGradientBackgroundColor != null) {
Offset shaderEndPoint =
clipLinearGradient ? Offset.zero : Offset(size.width, size.height);
_paintBackground.shader = linearGradientBackgroundColor
?.createShader(Rect.fromPoints(Offset.zero, shaderEndPoint));
}
// Then draw progress line
final xProgress = size.width * progress;
Path linePath = Path();
if (isRTL) {
if (linearGradient != null) {
_paintLine.shader = _createGradientShaderRightToLeft(size, xProgress);
}
linePath.addRRect(RRect.fromRectAndRadius(
Rect.fromLTWH(
size.width - size.width * progress, 0, xProgress, size.height),
barRadius));
} else {
if (linearGradient != null) {
_paintLine.shader = _createGradientShaderLeftToRight(size, xProgress);
}
linePath.addRRect(RRect.fromRectAndRadius(
Rect.fromLTWH(0, 0, xProgress, size.height), barRadius));
}
canvas.drawPath(linePath, _paintLine);
}
Shader _createGradientShaderRightToLeft(Size size, double xProgress) {
Offset shaderEndPoint =
clipLinearGradient ? Offset.zero : Offset(xProgress, size.height);
return linearGradient!.createShader(
Rect.fromPoints(
Offset(size.width, size.height),
shaderEndPoint,
),
);
}
Shader _createGradientShaderLeftToRight(Size size, double xProgress) {
Offset shaderEndPoint = clipLinearGradient
? Offset(size.width, size.height)
: Offset(xProgress, size.height);
return linearGradient!.createShader(
Rect.fromPoints(
Offset.zero,
shaderEndPoint,
),
);
}
@override
bool shouldRepaint(CustomPainter oldDelegate) => true;
}
Now, we have the Polls code ready to be integrated with our chat screen UI. If you go to the chat_screen.dart file, you can see that every message is rendered as a chat_list_item widget.
To render our Polls widget, let’s create a chat_poll_item widget and wrap it according to our chat_screen.
In the same directory, create a new file called chat_poll_item.dart.
class ChatPollItem extends StatelessWidget {
const ChatPollItem({
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container();
}
We need a PollMessageCreate object to create the poll with the title and options. We also need a dialogType int to identify the chat as a group chat and to build an Avatar frame. Additionally, we need the boolean, if the user has voted in the poll or not. To check if the current user has voted, we can simply check if the votes property of the PollMessageCreate, which is a type Map of , contains the currentUserId.
Now that we have all the necessary parameters, let’s pair them up with the UI and render the Poll.
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:quickblox_polls_feature/bloc/chat/chat_screen_bloc.dart';
import 'package:quickblox_polls_feature/bloc/chat/chat_screen_events.dart';
import 'package:quickblox_polls_feature/models/poll_action.dart';
import 'package:quickblox_polls_feature/models/poll_message.dart';
import 'package:quickblox_polls_feature/presentation/screens/chat/avatar_noname.dart';
import 'package:quickblox_polls_feature/presentation/screens/chat/polls.dart';
import 'package:quickblox_sdk/chat/constants.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:quickblox_polls_feature/bloc/chat/chat_screen_bloc.dart';
import 'package:quickblox_polls_feature/bloc/chat/chat_screen_events.dart';
import 'package:quickblox_polls_feature/models/poll_action.dart';
import 'package:quickblox_polls_feature/models/poll_message.dart';
import 'package:quickblox_polls_feature/presentation/screens/chat/avatar_noname.dart';
import 'package:quickblox_polls_feature/presentation/screens/chat/polls.dart';
import 'package:quickblox_sdk/chat/constants.dart';
class ChatPollItem extends StatelessWidget {
final PollMessage message;
final int? dialogType;
const ChatPollItem({required this.message, this.dialogType, Key? key})
: super(key: key);
@override
Widget build(BuildContext context) {
final List voters =
message.votes.keys.map((userId) => int.parse(userId)).toList();
bool hasVoted = voters.contains(message.currentUserId);
return Container(
padding: const EdgeInsets.only(left: 10, right: 12, bottom: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Container(
child: message.isIncoming && dialogType != QBChatDialogTypes.CHAT
? AvatarFromName(name: message.senderName)
: null),
Padding(padding: EdgeInsets.only(left: dialogType == 3 ? 0 : 16)),
Expanded(
child: Padding(
padding: const EdgeInsets.only(top: 15),
child: Column(
crossAxisAlignment: message.isIncoming
? CrossAxisAlignment.start
: CrossAxisAlignment.end,
children: [
IntrinsicWidth(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Polls(
onVote: (pollOption, optionIndex) {
///TODO: Explained in next section
},
pollStyle: TextStyle(
overflow: TextOverflow.ellipsis,
fontSize: 15,
color: message.isIncoming
? Colors.black87
: Colors.white,
),
backgroundColor:
message.isIncoming ? Colors.white : Colors.blue,
outlineColor: Colors.transparent,
hasVoted: hasVoted,
children: message.options.entries
.map((option) => PollOption(
optionId: option.key, //OptionID
option: option.value, //Option Value (Text)
value: message.votes.values
.where((choosenOptionID) =>
choosenOptionID == option.key)
.length
.toDouble()))
.toList(),
pollTitle: Text(
message.pollTitle,
),
),
],
),
),
],
),
))
],
),
);
}
}
class AvatarFromName extends StatelessWidget {
const AvatarFromName({
Key? key,
String? name,
}) : _name = name ?? "Noname",
super(key: key);
final String _name;
@override
Widget build(BuildContext context) {
return Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: Color(ColorUtil.getColor(_name)),
borderRadius: const BorderRadius.all(
Radius.circular(20),
),
),
child: Center(
child: Text(
_name.substring(0, 1).toUpperCase(),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
);
}
}
The above code should be self-explanatory, as we have already covered many of the points. We are calculating the value of the hasVoted variable on runtime by checking if the list of voters contains our currentUserId. Additionally, we have an onVote callback, which will trigger the vote action. We have marked this as a TODO in the code snippet above as we’ll get to it in the following section.
As we have yet to build the actual form for creating the poll, let’s complete this task now. We’ll create a simple form.
We need to modify the _buildEnterMessageRow method in the chat_screen.dart file to add an extra button at the beginning, which will enable us to create a poll.
Widget _buildEnterMessageRow() {
return SafeArea(
child: Column(
children: [
_buildTypingIndicator(),
Container(
color: Colors.white,
child: Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Our code here
SizedBox(
width: 50,
height: 50,
child: IconButton(
icon: const Icon(
Icons.poll,
color: Colors.blue,
),
onPressed: () async {
final formKey = GlobalKey();
final pollTitleController = TextEditingController();
final pollOption1Controller = TextEditingController();
final pollOption2Controller = TextEditingController();
final pollOption3Controller = TextEditingController();
final pollOption4Controller = TextEditingController();
await showModalBottomSheet(
isScrollControlled: true,
enableDrag: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(20.0),
),
),
context: context,
backgroundColor: Colors.white,
builder: (context) {
return Padding(
padding: EdgeInsets.only(
bottom:
MediaQuery.of(context).viewInsets.bottom),
child: Container(
padding: const EdgeInsets.all(20.0),
child: Form(
key: formKey,
child: SingleChildScrollView(
child: Column(
children: [
PollTextFieldRow(
label: 'Enter Poll Title here',
txtController: pollTitleController,
),
PollTextFieldRow(
label: 'Poll Option 1',
txtController: pollOption1Controller,
),
PollTextFieldRow(
label: 'Poll Option 2',
txtController: pollOption2Controller,
),
PollTextFieldRow(
label: 'Poll Option 3',
txtController: pollOption3Controller,
),
PollTextFieldRow(
label: 'Poll Option 4',
txtController: pollOption4Controller,
),
ElevatedButton(
onPressed: () {
//TODO: Figure this out in next section
},
child: const Text('Create Poll'),
),
],
),
),
),
),
);
},
);
},
),
),
SizedBox(
width: 50,
height: 50,
child: IconButton(
// Some code present here
),
),
Expanded(
child: Container(
// Some code present here
),
),
SizedBox(
width: 50,
height: 50,
child: IconButton(
// Some code present here
),
),
],
),
),
],
),
);
}
We have just added a new IconButton to the Enter message row in the code above. To keep the snippets short, we have added comments instead of copying all the code; you can find the complete code in the attached repo. This form is quite simple and contains a button which creates a form. The next section will focus on actually creating the poll, as indicated by the TODO.
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.