We already know how amazing
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
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
For simplicity of this article, We only have 3 screens. Splash, Login, and Chat screen. We have also hard coded the _dialogId
on the Chat screen so that we are directly taken to the required Group chat.
The template application code can be found
Create a new account following
Create an app by clicking the New app button.
Configure your app. Type in the information about your organization into corresponding fields and click Add button.
Go to Dashboard => YOUR_APP => Overview section and copy your Application ID, Authorization Key, Authorization Secret, and Account Key.
Once you have the application credentials, You can paste them into the main.dart
file, which is present in the template application.
If you have followed the above steps correctly, you can now run the application by using the following commands:
flutter packages get
flutter run
Voila! You should have a basic chat application up and running now.
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.
QBMessage
is a default data object provided by Quickblox that stores id(message identifier), body(text message), properties(extra metadata) etc.
In this project, we have also created a QBMessageWrapper
which is a wrapper around the QBMessage with additional fields like senderName, date etc that come in handy to display the messages data in the chat screen.
While QBMessage
with its properties
parameter is great to render static text messages, location, weblink etc, it cannot be used to host interactive polls that update over time.
For this, Quickblox provides us with
To set it up, let’s head over to our Quickblox Dashboard > Custom > Add > Add New Class and prepare a custom schema class Poll as below.
Once created, open Edit Permission and change the permission level and checkboxes as follows.
Without open permissions, the users cannot update the Poll values from the app.
In the code, we will create two classes
PollActionCreate
to hold poll title
and options
. We are using uuid
package to generate and assign a unique id to every option value. The toJson
returns mapped values required as per our Poll object schema.
PollActionVote
stores the pollID
, the existing votes
and the choosenOption
by the currentUser
. It has a getter updatedVotes
that recalculates the vote with the user-chosen option and returns the final values.
All the Map values are jsonEncoded into a string since quickblox custom object doesn’t support Map datatype.
import 'dart:convert';
import 'package:uuid/uuid.dart';
class PollActionCreate {
PollActionCreate({
required this.pollTitle,
required this.pollOptions,
});
final String pollTitle;
final Map<String, String> pollOptions;
factory PollActionCreate.fromData(
String title,
List<String> options,
) {
const uuid = Uuid();
return PollActionCreate(
pollTitle: title,
pollOptions: {for (var element in options) uuid.v4(): element},
);
}
Map<String, String> toJson() {
return {
"title": pollTitle,
"options": jsonEncode(pollOptions),
"votes": jsonEncode({})
};
}
}
class PollActionVote {
const PollActionVote(
{required this.pollID,
required this.votes,
required this.currentUserID,
required this.choosenOptionID});
final String pollID;
final Map<String, String> votes;
final String choosenOptionID;
final String currentUserID;
Map<String, String> get updatedVotes {
votes[currentUserID] = choosenOptionID;
return {"votes": jsonEncode(votes)};
}
}
The above models are used to parse and send the data, but we also need a model that will come in handy when we receive the data.
We will create aPollMessage
class extending the QBMessageWrapper to hold all poll-specific properties.
import 'dart:convert';
import 'package:quickblox_polls_feature/models/message_wrapper.dart';
import 'package:quickblox_sdk/models/qb_custom_object.dart';
import 'package:quickblox_sdk/models/qb_message.dart';
class PollMessage extends QBMessageWrapper {
PollMessage(super.senderName, super.message, super.currentUserId,
{required this.pollID,
required this.pollTitle,
required this.options,
required this.votes});
final String pollID;
final String pollTitle;
final Map<String, String> options;
final Map<String, String> votes;
factory PollMessage.fromCustomObject(String senderName, QBMessage message,
int currentUserId, QBCustomObject object) {
return PollMessage(senderName, message, currentUserId,
pollID: message.properties!['pollID']!,
pollTitle: object.fields!['title'] as String,
options: Map<String, String>.from(
jsonDecode(object.fields!['options'] as String)),
votes: Map<String, String>.from(
jsonDecode(object.fields!['votes'] as String)));
}
PollMessage copyWith({Map<String, String>? votes}) {
return PollMessage(senderName!, qbMessage, currentUserId,
pollID: pollID,
pollTitle: pollTitle,
options: options,
votes: votes ?? this.votes);
}
}
Let’s start this section and do what we (as programmers) love to do, build the logic. 😉
In this section, we will write the logic to create and vote on a poll, by utilizing our custom-created Poll object and the properties
param of a normal message.
We are using bloc pattern where, in our logic, we receive CreatePollMessageEvent
and VoteToPollEvent
from the UI interface which should trigger a repository call that communicates with the Quickblox servers.
if (receivedEvent is SendMessageEvent) {
// SOME PRE WRITTEN CODE PRESENT HERE.
}
if (receivedEvent is CreatePollMessageEvent) {
try {
await _chatRepository.sendStoppedTyping(_dialogId);
await Future.delayed(const Duration(milliseconds: 300), () async {
await _sendCreatePollMessage(data: receivedEvent.data);
});
} on PlatformException catch (e) {
states?.add(
SendMessageErrorState(
makeErrorMessage(e),
'Can\'t create poll',
),
);
} on RepositoryException catch (e) {
states?.add(SendMessageErrorState(e.message, 'Can\'t create poll'));
}
}
if (receivedEvent is VoteToPollEvent) {
try {
await _chatRepository.sendStoppedTyping(_dialogId);
await Future.delayed(const Duration(milliseconds: 300), () async {
await _sendVotePollMessage(data: receivedEvent.data);
});
} on PlatformException catch (e) {
states?.add(
SendMessageErrorState(
makeErrorMessage(e),
'Can\'t vote poll',
),
);
} on RepositoryException catch (e) {
states?.add(SendMessageErrorState(e.message, 'Can\'t vote poll'));
}
}
Future<void> _sendCreatePollMessage({required PollActionCreate data}) async {
await _chatRepository.sendCreatePollMessage(
_dialogId,
data: data,
);
}
Future<void> _sendVotePollMessage({required PollActionVote data}) async {
await _chatRepository.sendVotePollMessage(
_dialogId,
data: data,
);
}
In chat_repository.dart
, along with our simple sendMessage
function for text messages, we will also add the following functions:
sendCreatePollMessage
: registers a poll record and the returned pollID
is then sent in the metadata of a chat message. We can later use the pollID to retrieve the poll data.
Future<void> sendCreatePollMessage(String? dialogId,
{required PollActionCreate data}) async {
if (dialogId == null) {
throw RepositoryException(_parameterIsNullException,
affectedParams: ["dialogId"]);
}
///Creates the poll record and returns a single custom object
final List<QBCustomObject?> pollObject =
await QB.data.create(className: 'Poll', fields: data.toJson());
final pollID = pollObject.first!.id!;
///Sends an empty text message without body with the poll action and ID
await QB.chat.sendMessage(
dialogId,
saveToHistory: true,
markable: true,
properties: {"action": "pollActionCreate", "pollID": pollID},
);
}
sendVotePollMessage
: Updates the poll record with the latest vote values. Only the fields to be updated have to be sent in the fields parameter, which for us is votes
.
Please note that saveToHistroy
isn’t enabled for sendMessage
here since the only purpose of sendMessage here is to notify the current clients that the poll values have been updated.
In the future, when we reopen the chats, the poll values fetched will be the latest already, leaving out the need for pollActionVote
message in history.
Important note: saveToHistroy should be used sensibly. Otherwise, for larger groups of more than 100 people, we can find ourselves paginating multiple times only to find a series of useless pollActionVote messages.
Future<void> sendVotePollMessage(String? dialogId,
{required PollActionVote data, required String currentUserID}) async {
if (dialogId == null) {
throw RepositoryException(_parameterIsNullException,
affectedParams: ["dialogId"]);
}
///Updates the updated Votes value in the poll record.
await QB.data.update("Poll", id: data.pollID, fields: data.updatedVotes);
///Sends a message to notify clients
await QB.chat.sendMessage(
dialogId,
markable: true,
properties: {"action": "pollActionVote", "pollID": data.pollID},
);
}
3. getCustomObject
: used to fetch the latest data state of a poll.
Future<List<QBCustomObject?>?> getCustomObject(
{required List<String> ids, required String className}) {
return QB.data.getByIds("Poll", ids);
}
To know more about params like markable and saveToHistory you can refer to the amazing
So from the chat_screen_bloc.dart
, we are basically calling these three methods from the repository only, and wrapping them in try-catch blocks to catch and formulate a proper error, if things go unexpected.
Note: You might have already noticed, that before calling these methods, we are also calling the sendStoppedTyping method from the Chat Repository, that is just to make sure, the UI on the receiver’s end doesn’t show that we are typing anymore.
Phewww!! So we are almost done (wait, again almost? 😟)
Well, there is one last thing remaining, Can you guess what are we missing? 🤔 What happens when we receive a new message? Where will it go? How will it get handled if it’s a poll or a vote to a poll? Let’s figure this out.
So, In the chat_screen_bloc
file, we have a HashSet<QBMessageWrapper
> _wrappedMessageSet
which stores all the messages sorted by time.
We also have a method _wrapMessages()
, which is called every time when we receive new messages and is responsible for wrapping the QBMesssage
(s) in the List<QBMessageWrappers
>. We will now update this method to handle both pollActionCreate
and pollActionVote
incoming messages.
Upon receiving a:
pollActionCreate
: We first extract the pollID
from properties
of the message and then use getCustomObject
to fetch the poll record and build the PollMessage
object.
pollActionVote
: gives us the id of the poll that has been updated. We then use the pollID
to fetch the latest vote values and update the previous PollMessage
object.
///Called whenever new messages are received.
Future<List<QBMessageWrapper>> _wrapMessages(
List<QBMessage?> messages) async {
List<QBMessageWrapper> wrappedMessages = [];
for (QBMessage? message in messages) {
if (message == null) {
break;
}
QBUser? sender = _getParticipantById(message.senderId);
if (sender == null && message.senderId != null) {
List<QBUser?> users =
await _usersRepository.getUsersByIds([message.senderId!]);
if (users.isNotEmpty) {
sender = users[0];
_saveParticipants(users);
}
}
String senderName = sender?.fullName ?? sender?.login ?? "DELETED User";
///Fetch the latest poll object data using the pollID
///and update the PollMessage object with the new vote values
if (message.properties?['action'] == 'pollActionVote') {
final id = message.properties!['pollID']!;
final pollObject =
await _chatRepository.getCustomObject(ids: [id], className: "Poll");
final votes = Map<String, String>.from(
jsonDecode(pollObject!.first!.fields!['votes'] as String));
final pollMessage = _wrappedMessageSet.firstWhere(
(element) => element is PollMessage && element.pollID == id)
as PollMessage;
_wrappedMessageSet.removeWhere(
(element) => element is PollMessage && element.pollID == id);
wrappedMessages.add(pollMessage.copyWith(votes: votes));
///Fetch the poll object associated with the pollID and save
///it as a PollMessage in the list.
} else if (message.properties?['action'] == 'pollActionCreate') {
final pollObject = await _chatRepository.getCustomObject(
ids: [message.properties!['pollID']!], className: "Poll");
final poll = PollMessage.fromCustomObject(
senderName, message, _localUserId!, pollObject!.first!);
wrappedMessages.add(poll);
} else {
wrappedMessages
.add(QBMessageWrapper(senderName, message, _localUserId!));
}
}
///This list returned is then appended to _wrappedMessageSet
return wrappedMessages;
}
All the messages, normal and poll are now populated in the _wrappedMessageSet
list and can be rendered on the UI.
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
First things first, We know every poll option will have a unique id (as we already mentioned earlier) to identify it uniquely, it will also have an actual string value and the last one, a numeric value keeping count of how many people choose that option. ( You know where it’s heading 😉, Yes! Another model, but trust me, a simple one.)
So create a new file polls.dart
file to hold everything related to the polls UI. Let’s create PollOption
model in the file.
class PollOption {
String? optionId;
String option;
double value;
PollOption({
this.optionId,
required this.option,
required this.value,
});
}
Now, At the very base, We will basically need 2 widgets. One representing the unvoted state and another one when voted.
That means we will also have a bool to check if the person has voted or not. So let’s begin by creating a stateful widget with a bunch of parameters.
class Polls extends StatefulWidget {
Polls({
required this.children,
required this.pollTitle,
this.hasVoted,
this.onVote,
Key? key,
}) : super(key: key);
final Text pollTitle;
final bool? hasVoted;
final PollOnVote? onVote;
List<PollOption> children;
@override
PollsState createState() => PollsState();
}
class PollsState extends State<Polls> {
@override
Widget build(BuildContext context) {
if (!hasVoted) {
//user can cast vote with this widget
return voterWidget(context);
} else {
//user can view his votes with this widget
return voteCasted(context);
}
}
/// voterWidget creates view for users to cast their votes
Widget voterWidget(context) {
return Container();
}
/// voteCasted created view for user to see votes they casted including other peoples vote
Widget voteCasted(context) {
return Container();
}
}
typedef PollOnVote = void Function(
PollOption pollOption,
int optionIndex,
);
That was simple, right? Now we are going to add the UI code for voterWidget
and the voteCasted
widget. Those are just simple Rows and Columns with some decorations around. So we are not going to focus too much on explaining them.
The fun part is yet to come when we will combine the UI and Logic. 😉
class Polls extends StatefulWidget {
Polls({
required this.children,
required this.pollTitle,
this.hasVoted,
this.controller,
this.onVote,
this.outlineColor = Colors.blue,
this.backgroundColor = Colors.blueGrey,
this.onVoteBackgroundColor = Colors.blue,
this.leadingPollStyle,
this.pollStyle,
this.iconColor = Colors.black,
this.leadingBackgroundColor = Colors.blueGrey,
this.barRadius = 10,
this.userChoiceIcon,
this.showLogger = true,
this.totalVotes = 0,
this.userPollChoice,
Key? key,
}) : super(key: key);
final double barRadius;
int? userPollChoice;
final int totalVotes;
final Text pollTitle;
final Widget? userChoiceIcon;
final bool? hasVoted;
final bool showLogger;
final PollOnVote? onVote;
List<PollOption> children;
final PollController? controller;
/// style
final TextStyle? pollStyle;
final TextStyle? leadingPollStyle;
///colors setting for polls widget
final Color outlineColor;
final Color backgroundColor;
final Color? onVoteBackgroundColor;
final Color? iconColor;
final Color? leadingBackgroundColor;
@override
PollsState createState() => PollsState();
}
class PollsState extends State<Polls> {
PollController? _controller;
var choiceList = <String>[];
var userChoiceList = <String>[];
var valueList = <double>[];
var userValueList = <double>[];
/// style
late TextStyle pollStyle;
late TextStyle leadingPollStyle;
///colors setting for polls widget
Color? outlineColor;
Color? backgroundColor;
Color? onVoteBackgroundColor;
Color? iconColor;
Color? leadingBackgroundColor;
double highest = 0.0;
bool hasVoted = false;
@override
void initState() {
super.initState();
_controller = widget.controller;
_controller ??= PollController();
_controller!.children = widget.children;
hasVoted = widget.hasVoted ?? _controller!.hasVoted;
_controller?.addListener(() {
if (_controller!.makeChange) {
hasVoted = _controller!.hasVoted;
_updateView();
}
});
_reCalibrate();
}
void _updateView() {
widget.children = _controller!.children;
_controller!.revertChangeBoolean();
_reCalibrate();
}
void _reCalibrate() {
choiceList.clear();
userChoiceList.clear();
valueList.clear();
/// if polls style is null, it sets default pollstyle and leading pollstyle
pollStyle = widget.pollStyle ??
const TextStyle(color: Colors.black, fontWeight: FontWeight.w300);
leadingPollStyle = widget.leadingPollStyle ??
const TextStyle(color: Colors.black, fontWeight: FontWeight.w800);
widget.children.map((e) {
choiceList.add(e.option);
userChoiceList.add(e.option);
valueList.add(e.value);
}).toList();
}
@override
Widget build(BuildContext context) {
if (!hasVoted) {
//user can cast vote with this widget
return voterWidget(context);
} else {
//user can view his votes with this widget
return voteCasted(context);
}
}
/// voterWidget creates view for users to cast their votes
Widget voterWidget(context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
widget.pollTitle,
const SizedBox(
height: 12,
),
Column(
children: widget.children.map((element) {
int index = widget.children.indexOf(element);
return Container(
width: double.infinity,
padding: const EdgeInsets.only(bottom: 10),
child: Container(
margin: const EdgeInsets.all(0),
width: MediaQuery.of(context).size.width / 1.5,
padding: const EdgeInsets.all(0),
// height: 38,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(22),
color: widget.backgroundColor,
),
child: OutlinedButton(
onPressed: () {
widget.onVote!(
widget.children[index],
index,
);
},
style: OutlinedButton.styleFrom(
foregroundColor: widget.outlineColor,
padding: const EdgeInsets.all(5.0),
side: BorderSide(
color: widget.outlineColor,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(widget.barRadius),
),
),
child: Text(
element.option,
style: widget.pollStyle,
maxLines: 2,
),
),
),
);
}).toList(),
),
],
);
}
/// voteCasted created view for user to see votes they casted including other peoples vote
Widget voteCasted(context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
widget.pollTitle,
const SizedBox(
height: 12,
),
Column(
children: widget.children.map(
(element) {
int index = widget.children.indexOf(element);
return Container(
margin: const EdgeInsets.symmetric(vertical: 5),
width: double.infinity,
child: LinearPercentIndicator(
padding: EdgeInsets.zero,
animation: true,
lineHeight: 38.0,
animationDuration: 500,
percent:
PollMethods.getViewPercentage(valueList, index + 1, 1),
center: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Row(
mainAxisAlignment: MainAxisAlignment.start,
children: <Widget>[
Text(
choiceList[index].toString(),
style: highest == valueList[index]
? widget.leadingPollStyle
: widget.pollStyle,
),
],
),
Text(
"${PollMethods.getViewPercentage(valueList, index + 1, 100).toStringAsFixed(1)}%",
style: highest == valueList[index]
? widget.leadingPollStyle
: widget.pollStyle,
)
],
),
),
barRadius: Radius.circular(widget.barRadius),
progressColor: highest == valueList[index]
? widget.leadingBackgroundColor
: widget.onVoteBackgroundColor,
),
);
},
).toList(),
)
],
);
}
}
class PollMethods {
static double getViewPercentage(List<double> valueList, choice, int byValue) {
double div = 0.0;
var slot = <double>[];
double sum = 0.0;
valueList.map((element) {
slot.add(element);
}).toList();
valueList.map((element) {
sum = slot.map((value) => value).fold(0, (a, b) => a + b);
}).toList();
div = sum == 0 ? 0.0 : (byValue / sum) * slot[choice - 1];
return div;
}
}
class PollController extends ChangeNotifier {
var children = <PollOption>[];
bool hasVoted = false;
bool makeChange = false;
void revertChangeBoolean() {
makeChange = false;
notifyListeners();
}
}
typedef PollOnVote = void Function(
PollOption pollOption,
int optionIndex,
);
In the above code, We have set up a PollController
that will help us in holding the variables and update the UI when needed. Rest is all about styling the widgets.
We also have a widgetLinearPercentIndicator
, which basically helps us in filling up the required percentage according to the number of votes on each option. This widget is basically taken from
import 'package:flutter/material.dart';
// ignore: must_be_immutable
class LinearPercentIndicator extends StatefulWidget {
///Percent value between 0.0 and 1.0
final double percent;
final double? width;
///Height of the line
final double lineHeight;
///Color of the background of the Line , default = transparent
final Color fillColor;
///First color applied to the complete line
Color get backgroundColor => _backgroundColor;
late Color _backgroundColor;
///First color applied to the complete line
final LinearGradient? linearGradientBackgroundColor;
Color get progressColor => _progressColor;
late Color _progressColor;
///true if you want the Line to have animation
final bool animation;
///duration of the animation in milliseconds, It only applies if animation attribute is true
final int animationDuration;
///widget at the left of the Line
final Widget? leading;
///widget at the right of the Line
final Widget? trailing;
///widget inside the Line
final Widget? center;
///The kind of finish to place on the end of lines drawn, values supported: butt, round, roundAll
// @Deprecated('This property is no longer used, please use barRadius instead.')
// final LinearStrokeCap? linearStrokeCap;
/// The border radius of the progress bar (Will replace linearStrokeCap)
final Radius? barRadius;
///alignment of the Row (leading-widget-center-trailing)
final MainAxisAlignment alignment;
///padding to the LinearPercentIndicator
final EdgeInsets padding;
/// set true if you want to animate the linear from the last percent value you set
final bool animateFromLastPercent;
/// If present, this will make the progress bar colored by this gradient.
///
/// This will override [progressColor]. It is an error to provide both.
final LinearGradient? linearGradient;
/// set false if you don't want to preserve the state of the widget
final bool addAutomaticKeepAlive;
/// set true if you want to animate the linear from the right to left (RTL)
final bool isRTL;
/// Creates a mask filter that takes the progress shape being drawn and blurs it.
final MaskFilter? maskFilter;
/// Set true if you want to display only part of [linearGradient] based on percent value
/// (ie. create 'VU effect'). If no [linearGradient] is specified this option is ignored.
final bool clipLinearGradient;
/// set a linear curve animation type
final Curve curve;
/// set true when you want to restart the animation, it restarts only when reaches 1.0 as a value
/// defaults to false
final bool restartAnimation;
/// Callback called when the animation ends (only if `animation` is true)
final VoidCallback? onAnimationEnd;
/// Display a widget indicator at the end of the progress. It only works when `animation` is true
final Widget? widgetIndicator;
LinearPercentIndicator({
Key? key,
this.fillColor = Colors.transparent,
this.percent = 0.0,
this.lineHeight = 5.0,
this.width,
Color? backgroundColor,
this.linearGradientBackgroundColor,
this.linearGradient,
Color? progressColor,
this.animation = false,
this.animationDuration = 500,
this.animateFromLastPercent = false,
this.isRTL = false,
this.leading,
this.trailing,
this.center,
this.addAutomaticKeepAlive = true,
// this.linearStrokeCap,
this.barRadius,
this.padding = const EdgeInsets.symmetric(horizontal: 10.0),
this.alignment = MainAxisAlignment.start,
this.maskFilter,
this.clipLinearGradient = false,
this.curve = Curves.linear,
this.restartAnimation = false,
this.onAnimationEnd,
this.widgetIndicator,
}) : super(key: key) {
if (linearGradient != null && progressColor != null) {
throw ArgumentError(
'Cannot provide both linearGradient and progressColor');
}
_progressColor = progressColor ?? Colors.red;
if (linearGradientBackgroundColor != null && backgroundColor != null) {
throw ArgumentError(
'Cannot provide both linearGradientBackgroundColor and backgroundColor');
}
_backgroundColor = backgroundColor ?? const Color(0xFFB8C7CB);
if (percent < 0.0 || percent > 1.0) {
throw Exception(
"Percent value must be a double between 0.0 and 1.0, but it's $percent");
}
}
@override
LinearPercentIndicatorState createState() => LinearPercentIndicatorState();
}
class LinearPercentIndicatorState extends State<LinearPercentIndicator>
with SingleTickerProviderStateMixin, AutomaticKeepAliveClientMixin {
AnimationController? _animationController;
Animation? _animation;
double _percent = 0.0;
final _containerKey = GlobalKey();
final _keyIndicator = GlobalKey();
double _containerWidth = 0.0;
double _containerHeight = 0.0;
double _indicatorWidth = 0.0;
double _indicatorHeight = 0.0;
@override
void dispose() {
_animationController?.dispose();
super.dispose();
}
@override
void initState() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
setState(() {
_containerWidth = _containerKey.currentContext?.size?.width ?? 0.0;
_containerHeight = _containerKey.currentContext?.size?.height ?? 0.0;
if (_keyIndicator.currentContext != null) {
_indicatorWidth = _keyIndicator.currentContext?.size?.width ?? 0.0;
_indicatorHeight =
_keyIndicator.currentContext?.size?.height ?? 0.0;
}
});
}
});
if (widget.animation) {
_animationController = AnimationController(
vsync: this,
duration: Duration(milliseconds: widget.animationDuration));
_animation = Tween(begin: 0.0, end: widget.percent).animate(
CurvedAnimation(parent: _animationController!, curve: widget.curve),
)..addListener(() {
setState(() {
_percent = _animation!.value;
});
if (widget.restartAnimation && _percent == 1.0) {
_animationController!.repeat(min: 0, max: 1.0);
}
});
_animationController!.addStatusListener((status) {
if (widget.onAnimationEnd != null &&
status == AnimationStatus.completed) {
widget.onAnimationEnd!();
}
});
_animationController!.forward();
} else {
_updateProgress();
}
super.initState();
}
void _checkIfNeedCancelAnimation(LinearPercentIndicator oldWidget) {
if (oldWidget.animation &&
!widget.animation &&
_animationController != null) {
_animationController!.stop();
}
}
@override
void didUpdateWidget(LinearPercentIndicator oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.percent != widget.percent) {
if (_animationController != null) {
_animationController!.duration =
Duration(milliseconds: widget.animationDuration);
_animation = Tween(
begin: widget.animateFromLastPercent ? oldWidget.percent : 0.0,
end: widget.percent)
.animate(
CurvedAnimation(parent: _animationController!, curve: widget.curve),
);
_animationController!.forward(from: 0.0);
} else {
_updateProgress();
}
}
_checkIfNeedCancelAnimation(oldWidget);
}
_updateProgress() {
setState(() {
_percent = widget.percent;
});
}
@override
Widget build(BuildContext context) {
super.build(context);
var items = List<Widget>.empty(growable: true);
if (widget.leading != null) {
items.add(widget.leading!);
}
final hasSetWidth = widget.width != null;
final percentPositionedHorizontal =
_containerWidth * _percent - _indicatorWidth / 3;
var containerWidget = Container(
width: hasSetWidth ? widget.width : double.infinity,
height: widget.lineHeight,
padding: widget.padding,
child: Stack(
clipBehavior: Clip.none,
children: [
CustomPaint(
key: _containerKey,
painter: _LinearPainter(
isRTL: widget.isRTL,
progress: _percent,
progressColor: widget.progressColor,
linearGradient: widget.linearGradient,
backgroundColor: widget.backgroundColor,
barRadius: widget.barRadius ??
Radius.zero, // If radius is not defined, set it to zero
linearGradientBackgroundColor:
widget.linearGradientBackgroundColor,
maskFilter: widget.maskFilter,
clipLinearGradient: widget.clipLinearGradient,
),
child: (widget.center != null)
? Center(child: widget.center)
: Container(),
),
if (widget.widgetIndicator != null && _indicatorWidth == 0)
Opacity(
opacity: 0.0,
key: _keyIndicator,
child: widget.widgetIndicator,
),
if (widget.widgetIndicator != null &&
_containerWidth > 0 &&
_indicatorWidth > 0)
Positioned(
right: widget.isRTL ? percentPositionedHorizontal : null,
left: !widget.isRTL ? percentPositionedHorizontal : null,
top: _containerHeight / 2 - _indicatorHeight,
child: widget.widgetIndicator!,
),
],
),
);
if (hasSetWidth) {
items.add(containerWidget);
} else {
items.add(Expanded(
child: containerWidget,
));
}
if (widget.trailing != null) {
items.add(widget.trailing!);
}
return Material(
color: Colors.transparent,
child: Container(
color: widget.fillColor,
child: Row(
mainAxisAlignment: widget.alignment,
crossAxisAlignment: CrossAxisAlignment.center,
children: items,
),
),
);
}
@override
bool get wantKeepAlive => widget.addAutomaticKeepAlive;
}
class _LinearPainter extends CustomPainter {
final Paint _paintBackground = Paint();
final Paint _paintLine = Paint();
final double progress;
final bool isRTL;
final Color progressColor;
final Color backgroundColor;
final Radius barRadius;
final LinearGradient? linearGradient;
final LinearGradient? linearGradientBackgroundColor;
final MaskFilter? maskFilter;
final bool clipLinearGradient;
_LinearPainter({
required this.progress,
required this.isRTL,
required this.progressColor,
required this.backgroundColor,
required this.barRadius,
this.linearGradient,
this.maskFilter,
required this.clipLinearGradient,
this.linearGradientBackgroundColor,
}) {
_paintBackground.color = backgroundColor;
_paintLine.color = progress.toString() == "0.0"
? progressColor.withOpacity(0.0)
: progressColor;
}
@override
void paint(Canvas canvas, Size size) {
// Draw background first
Path backgroundPath = Path();
backgroundPath.addRRect(RRect.fromRectAndRadius(
Rect.fromLTWH(0, 0, size.width, size.height), barRadius));
canvas.drawPath(backgroundPath, _paintBackground);
canvas.clipPath(backgroundPath);
if (maskFilter != null) {
_paintLine.maskFilter = maskFilter;
}
if (linearGradientBackgroundColor != null) {
Offset shaderEndPoint =
clipLinearGradient ? Offset.zero : Offset(size.width, size.height);
_paintBackground.shader = linearGradientBackgroundColor
?.createShader(Rect.fromPoints(Offset.zero, shaderEndPoint));
}
// Then draw progress line
final xProgress = size.width * progress;
Path linePath = Path();
if (isRTL) {
if (linearGradient != null) {
_paintLine.shader = _createGradientShaderRightToLeft(size, xProgress);
}
linePath.addRRect(RRect.fromRectAndRadius(
Rect.fromLTWH(
size.width - size.width * progress, 0, xProgress, size.height),
barRadius));
} else {
if (linearGradient != null) {
_paintLine.shader = _createGradientShaderLeftToRight(size, xProgress);
}
linePath.addRRect(RRect.fromRectAndRadius(
Rect.fromLTWH(0, 0, xProgress, size.height), barRadius));
}
canvas.drawPath(linePath, _paintLine);
}
Shader _createGradientShaderRightToLeft(Size size, double xProgress) {
Offset shaderEndPoint =
clipLinearGradient ? Offset.zero : Offset(xProgress, size.height);
return linearGradient!.createShader(
Rect.fromPoints(
Offset(size.width, size.height),
shaderEndPoint,
),
);
}
Shader _createGradientShaderLeftToRight(Size size, double xProgress) {
Offset shaderEndPoint = clipLinearGradient
? Offset(size.width, size.height)
: Offset(xProgress, size.height);
return linearGradient!.createShader(
Rect.fromPoints(
Offset.zero,
shaderEndPoint,
),
);
}
@override
bool shouldRepaint(CustomPainter oldDelegate) => true;
}
With this, We now have the Polls code ready to be integrated with our actual chat screen UI, If you go to chat_screen.dart
file, you can see every message is rendered in form of a chat_list_item
widget.
So, Let’s also create achat_poll_item
widget to wrap the Polls widget and render according to our chat_screen.
In the same directory, create a new file called chat_poll_item.dart
class ChatPollItem extends StatelessWidget {
const ChatPollItem({
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container();
}
Now, let’s try to figure out, what all do we need in this widget and how are going to render the Poll.
We need a PollMessageCreate
object to create the poll with the title and options.
To count and render the total votes on the poll, we use the votes
property of the PollMessageCreate
which is a <String, String>
type Map of <UserID, ChoosenOptionID>.
We need a dialogType
int to identify the chat as a group chat and build an Avatar frame.
We also need the boolean, if the user has voted in the poll or not, but since we have a list of voters, we can simply check if it contains the currentUserId
.
Let’s now add all these parameters, and pair up the Polls with UI.
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:quickblox_polls_feature/bloc/chat/chat_screen_bloc.dart';
import 'package:quickblox_polls_feature/bloc/chat/chat_screen_events.dart';
import 'package:quickblox_polls_feature/models/poll_action.dart';
import 'package:quickblox_polls_feature/models/poll_message.dart';
import 'package:quickblox_polls_feature/presentation/screens/chat/avatar_noname.dart';
import 'package:quickblox_polls_feature/presentation/screens/chat/polls.dart';
import 'package:quickblox_sdk/chat/constants.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:quickblox_polls_feature/bloc/chat/chat_screen_bloc.dart';
import 'package:quickblox_polls_feature/bloc/chat/chat_screen_events.dart';
import 'package:quickblox_polls_feature/models/poll_action.dart';
import 'package:quickblox_polls_feature/models/poll_message.dart';
import 'package:quickblox_polls_feature/presentation/screens/chat/avatar_noname.dart';
import 'package:quickblox_polls_feature/presentation/screens/chat/polls.dart';
import 'package:quickblox_sdk/chat/constants.dart';
class ChatPollItem extends StatelessWidget {
final PollMessage message;
final int? dialogType;
const ChatPollItem({required this.message, this.dialogType, Key? key})
: super(key: key);
@override
Widget build(BuildContext context) {
final List<int?> voters =
message.votes.keys.map((userId) => int.parse(userId)).toList();
bool hasVoted = voters.contains(message.currentUserId);
return Container(
padding: const EdgeInsets.only(left: 10, right: 12, bottom: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: <Widget>[
Container(
child: message.isIncoming && dialogType != QBChatDialogTypes.CHAT
? AvatarFromName(name: message.senderName)
: null),
Padding(padding: EdgeInsets.only(left: dialogType == 3 ? 0 : 16)),
Expanded(
child: Padding(
padding: const EdgeInsets.only(top: 15),
child: Column(
crossAxisAlignment: message.isIncoming
? CrossAxisAlignment.start
: CrossAxisAlignment.end,
children: <Widget>[
IntrinsicWidth(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Polls(
onVote: (pollOption, optionIndex) {
///TODO: Explained in next section
},
pollStyle: TextStyle(
overflow: TextOverflow.ellipsis,
fontSize: 15,
color: message.isIncoming
? Colors.black87
: Colors.white,
),
backgroundColor:
message.isIncoming ? Colors.white : Colors.blue,
outlineColor: Colors.transparent,
hasVoted: hasVoted,
children: message.options.entries
.map((option) => PollOption(
optionId: option.key, //OptionID
option: option.value, //Option Value (Text)
value: message.votes.values
.where((choosenOptionID) =>
choosenOptionID == option.key)
.length
.toDouble()))
.toList(),
pollTitle: Text(
message.pollTitle,
),
),
],
),
),
],
),
))
],
),
);
}
}
class AvatarFromName extends StatelessWidget {
const AvatarFromName({
Key? key,
String? name,
}) : _name = name ?? "Noname",
super(key: key);
final String _name;
@override
Widget build(BuildContext context) {
return Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: Color(ColorUtil.getColor(_name)),
borderRadius: const BorderRadius.all(
Radius.circular(20),
),
),
child: Center(
child: Text(
_name.substring(0, 1).toUpperCase(),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
);
}
}
The above code is mostly self-explanatory as we already discussed most of the points. We are calculating the value of hasVoted
variable on runtime, by checking if the list of voters
contains our currentUserId
.
We also have anonVote
callback, which will basically trigger the vote action. We have it as a TODO because we will do it in the next section. 😉
So, we have created the Poll UI, Poll methods, and everything else required to render a poll, but wait, Are we missing something? 🤔 (I’ll let you guess)
We haven’t yet built the actual form, using which we will create the poll, Let’s create a very simple form.
Let’s go to chat_screen.dart
file, there we have a method _buildEnterMessageRow
, which basically builds the bottommost area of the screen, where we enter the message and send it. Let’s modify it to have an extra button at the beginning which can help us create polls.
Widget _buildEnterMessageRow() {
return SafeArea(
child: Column(
children: [
_buildTypingIndicator(),
Container(
color: Colors.white,
child: Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
// Our code here
SizedBox(
width: 50,
height: 50,
child: IconButton(
icon: const Icon(
Icons.poll,
color: Colors.blue,
),
onPressed: () async {
final formKey = GlobalKey<FormState>();
final pollTitleController = TextEditingController();
final pollOption1Controller = TextEditingController();
final pollOption2Controller = TextEditingController();
final pollOption3Controller = TextEditingController();
final pollOption4Controller = TextEditingController();
await showModalBottomSheet(
isScrollControlled: true,
enableDrag: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(20.0),
),
),
context: context,
backgroundColor: Colors.white,
builder: (context) {
return Padding(
padding: EdgeInsets.only(
bottom:
MediaQuery.of(context).viewInsets.bottom),
child: Container(
padding: const EdgeInsets.all(20.0),
child: Form(
key: formKey,
child: SingleChildScrollView(
child: Column(
children: [
PollTextFieldRow(
label: 'Enter Poll Title here',
txtController: pollTitleController,
),
PollTextFieldRow(
label: 'Poll Option 1',
txtController: pollOption1Controller,
),
PollTextFieldRow(
label: 'Poll Option 2',
txtController: pollOption2Controller,
),
PollTextFieldRow(
label: 'Poll Option 3',
txtController: pollOption3Controller,
),
PollTextFieldRow(
label: 'Poll Option 4',
txtController: pollOption4Controller,
),
ElevatedButton(
onPressed: () {
//TODO: Figure this out in next section
},
child: const Text('Create Poll'),
),
],
),
),
),
),
);
},
);
},
),
),
SizedBox(
width: 50,
height: 50,
child: IconButton(
// Some code present here
),
),
Expanded(
child: Container(
// Some code present here
),
),
SizedBox(
width: 50,
height: 50,
child: IconButton(
// Some code present here
),
),
],
),
),
],
),
);
}
In the above code, we have just added a new IconButton as the first child of your Enter message row. We are not copying all the below code to keep the snippets short. So we have added the comments. You can get the complete code in the attached repo.
As you might have guessed, it’s just a simple form with a button to create a form, and we again have a TODO there that will actually create the poll, which is exactly what we are going to figure out in the next section.
We’ve come a far way, If you’re still here, believe us the result is going to be worth the effort. 😌
In this section, we are going to plug our logic with the UI to make it happen, We will also plug our poll_list_item
at the right place. So let’s write down the steps that we need to take to bind everything together.
So let’s begin by figuring out the TODOs, Starting with the TODO on the onPressed
callback of the Poll creation form.
Basically here, we want to send a message to Quickblox with the required parameters, so that it will be treated as a Poll Creation message.
// Create poll button code from chat_screen.dart
ElevatedButton(
onPressed: () {
//Cancel the Typing status timer
TypingStatusManager.cancelTimer();
//Add the CreatePoll event to the BLoC
bloc?.events?.add(
CreatePollMessageEvent(
PollActionCreate.fromData(
pollTitleController.text.trim(),
[
pollOption1Controller.text
.trim(),
pollOption2Controller.text
.trim(),
pollOption3Controller.text
.trim(),
pollOption4Controller.text
.trim(),
],
),
),
);
//Pop the bottom modal sheet
Navigator.of(context).pop();
},
child: const Text('Create Poll'),
),
Now let’s figure out the other TODO, to vote in the poll, Many of you would’ve already figured it out, And you are correct, It’s that simple.
Polls(
onVote: (pollOption, optionIndex) {
// If the user has already voted, Don't do anything
if (!hasVoted) {
// Add the VoteToPoll event to the BLoC
Provider.of<ChatScreenBloc>(context, listen: false)
.events
?.add(
VoteToPollEvent(
PollActionVote(
pollId: message.pollID,
voteOptionId: pollOption.optionId!,
),
),
);
}
},
pollStyle: TextStyle(
overflow: TextOverflow.ellipsis,
fontSize: 15,
color: message.isIncoming
? Colors.black87
: Colors.white,
),
backgroundColor:
message.isIncoming ? Colors.white : Colors.blue,
outlineColor: Colors.transparent,
hasVoted: hasVoted,
children: message.options.entries
.map((e) => PollOption(
optionId: e.key,
option: e.value,
value: votes
.map((e) => e.choosenOption)
.where((option) => option == e.key)
.length
.toDouble()))
.toList(),
pollTitle: Text(
message.pollTitle,
),
),
Wasn’t it simple ? We already had everything ready, We Just added the events with the required data and it’s done.
Moving on, Let’s Plug the PollListItem
widget in ChatScreen
.
In Chat Screen, We can see there is a GroupedList that is grouping and rendering the messages. Let’s modify itsitemBuilder
to detect and render a PollMessage
.
itemBuilder: (context, QBMessageWrapper message) {
if (message is PollMessageCreate) {
return ChatPollItem(
message: message,
key: ValueKey(
Key(
RandomUtil.getRandomString(10),
),
),
);
}
return GestureDetector(
child: ChatListItem(
Key(
RandomUtil.getRandomString(10),
),
message,
_dialogType,
),
onTapDown: (details) {
tapPosition = details.globalPosition;
},
onLongPress: () {
//More code present here
}
);
},
In the above code, We are basically checking if the message is a PollMessageCreate and rendering the ChatPollItem
after calculating the votes for that poll. Let’s see what the final version looks like:
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.
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.
Also published here.