Skip to content

Commit b0a4dc5

Browse files
committed
poll: Support vote/unvote for polls
Visually, this does not change the appearence of the vote count box. This does not implement local echoing for voting. Instead, we rely on the submessage events to get the updates after voting. For accessbility, the touch target is larger than the button. See also: https://chat.zulip.org/#narrow/stream/48-mobile/topic/Poll.20vote.2Funvote.20UI/near/1952724 Fixes: #166 Signed-off-by: Zixuan James Li <[email protected]>
1 parent db82c79 commit b0a4dc5

File tree

4 files changed

+93
-23
lines changed

4 files changed

+93
-23
lines changed

lib/api/model/submessage.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,9 @@ class Poll extends ChangeNotifier {
404404
final Set<String> _existingOptionTexts = {};
405405
final Map<PollOptionKey, PollOption> _options = {};
406406

407+
bool hasUserVotedFor({required int userId, required PollOptionKey key}) =>
408+
_options.containsKey(key) && _options[key]!.voters.contains(userId);
409+
407410
void handleSubmessageEvent(SubmessageEvent event) {
408411
final PollEventSubmessage? pollEventSubmessage;
409412
try {

lib/widgets/content.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -264,7 +264,7 @@ class MessageContent extends StatelessWidget {
264264
style: ContentTheme.of(context).textStylePlainParagraph,
265265
child: switch (content) {
266266
ZulipContent() => BlockContentList(nodes: content.nodes),
267-
PollContent() => PollWidget(poll: content.poll),
267+
PollContent() => PollWidget(messageId: message.id, poll: content.poll),
268268
}));
269269
}
270270
}

lib/widgets/poll.dart

Lines changed: 43 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
1+
import 'dart:async';
2+
13
import 'package:flutter/material.dart';
24
import 'package:flutter_gen/gen_l10n/zulip_localizations.dart';
35

46
import '../api/model/submessage.dart';
7+
import '../api/route/submessage.dart';
58
import 'content.dart';
69
import 'store.dart';
710
import 'text.dart';
811

912
class PollWidget extends StatefulWidget {
10-
const PollWidget({super.key, required this.poll});
13+
const PollWidget({super.key, required this.messageId, required this.poll});
1114

15+
final int messageId;
1216
final Poll poll;
1317

1418
@override
@@ -44,6 +48,18 @@ class _PollWidgetState extends State<PollWidget> {
4448
});
4549
}
4650

51+
void _toggleVote(PollOption option) async {
52+
final store = PerAccountStoreWidget.of(context);
53+
// The poll data in store might be obselete before we get the event
54+
// that updates it. This is fine because the result will be consistent
55+
// eventually, regardless of the possible duplicate requests.
56+
final op = widget.poll.hasUserVotedFor(userId: store.selfUserId, key: option.key)
57+
? PollVoteOp.remove
58+
: PollVoteOp.add;
59+
unawaited(sendSubmessage(store.connection, messageId: widget.messageId,
60+
content: PollVoteEventSubmessage(key: option.key, op: op)));
61+
}
62+
4763
@override
4864
Widget build(BuildContext context) {
4965
final zulipLocalizations = ZulipLocalizations.of(context);
@@ -73,25 +89,32 @@ class _PollWidgetState extends State<PollWidget> {
7389
crossAxisAlignment: CrossAxisAlignment.baseline,
7490
textBaseline: localizedTextBaseline(context),
7591
children: [
76-
ConstrainedBox(
77-
constraints: const BoxConstraints(
78-
minWidth: 39 + 5).tighten(height: 39 + verticalPadding * 2),
79-
child: Padding(
80-
padding: const EdgeInsetsDirectional.only(
81-
end: 5, top: verticalPadding, bottom: verticalPadding),
82-
child: Container(
83-
// This is only in effect
84-
// when the vote count has more than 2 digits.
85-
padding: const EdgeInsets.symmetric(horizontal: 4),
86-
decoration: BoxDecoration(
87-
color: theme.colorPollVoteCountBackground,
88-
border: Border.all(color: theme.colorPollVoteCountBorder),
89-
borderRadius: BorderRadius.circular(3)),
90-
child: Center(
91-
child: Text(option.voters.length.toString(),
92-
textAlign: TextAlign.center,
93-
style: textStyleBold.copyWith(
94-
color: theme.colorPollVoteCountText, fontSize: 18)))))),
92+
GestureDetector(
93+
onTap: () => _toggleVote(option),
94+
behavior: HitTestBehavior.translucent,
95+
child: ConstrainedBox(
96+
constraints: const BoxConstraints(
97+
minWidth: 39 + 5).tighten(height: 39 + verticalPadding * 2),
98+
child: Padding(
99+
// For accessibility, the touch target is padded to be larger
100+
// than the vote count box. Still, we avoid padding at the
101+
// start because we want to align all the poll options to the
102+
// surrounding messages.
103+
padding: const EdgeInsetsDirectional.only(
104+
end: 5, top: verticalPadding, bottom: verticalPadding),
105+
child: Container(
106+
// This is only in effect
107+
// when the vote count has more than 2 digits.
108+
padding: const EdgeInsets.symmetric(horizontal: 4),
109+
decoration: BoxDecoration(
110+
color: theme.colorPollVoteCountBackground,
111+
border: Border.all(color: theme.colorPollVoteCountBorder),
112+
borderRadius: BorderRadius.circular(3)),
113+
child: Center(
114+
child: Text(option.voters.length.toString(),
115+
textAlign: TextAlign.center,
116+
style: textStyleBold.copyWith(
117+
color: theme.colorPollVoteCountText, fontSize: 18))))))),
95118
Expanded(
96119
child: Wrap(
97120
spacing: 5,

test/widgets/poll_test.dart

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1+
import 'dart:convert';
2+
13
import 'package:checks/checks.dart';
4+
import 'package:http/http.dart' as http;
25
import 'package:flutter/widgets.dart';
36
import 'package:flutter_checks/flutter_checks.dart';
47
import 'package:flutter_test/flutter_test.dart';
@@ -8,6 +11,8 @@ import 'package:zulip/api/model/submessage.dart';
811
import 'package:zulip/model/store.dart';
912
import 'package:zulip/widgets/poll.dart';
1013

14+
import '../stdlib_checks.dart';
15+
import '../api/fake_api.dart';
1116
import '../example_data.dart' as eg;
1217
import '../model/binding.dart';
1318
import '../model/test_store.dart';
@@ -17,6 +22,8 @@ void main() {
1722
TestZulipBinding.ensureInitialized();
1823

1924
late PerAccountStore store;
25+
late FakeApiConnection connection;
26+
late Message message;
2027

2128
Future<void> preparePollWidget(
2229
WidgetTester tester,
@@ -28,13 +35,14 @@ void main() {
2835
await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot());
2936
store = await testBinding.globalStore.perAccount(eg.selfAccount.id);
3037
await store.addUsers(users ?? [eg.selfUser, eg.otherUser]);
38+
connection = store.connection as FakeApiConnection;
3139

32-
Message message = eg.streamMessage(
40+
message = eg.streamMessage(
3341
sender: eg.selfUser,
3442
submessages: [eg.submessage(content: submessageContent)]);
3543
await store.handleEvent(MessageEvent(id: 0, message: message));
3644
await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id,
37-
child: PollWidget(poll: message.poll!)));
45+
child: PollWidget(messageId: message.id, poll: message.poll!)));
3846
await tester.pump();
3947

4048
for (final (voter, idx) in voterIdxPairs) {
@@ -106,4 +114,40 @@ void main() {
106114
question: 'title', options: []));
107115
check(findInPoll(find.text('This poll has no options yet.'))).findsOne();
108116
});
117+
118+
void checkVoteRequest(PollOptionKey key, PollVoteOp op) {
119+
check(connection.takeRequests()).single.isA<http.Request>()
120+
..method.equals('POST')
121+
..url.path.equals('/api/v1/submessage')
122+
..bodyFields.deepEquals({
123+
'message_id': jsonEncode(message.id),
124+
'msg_type': 'widget',
125+
'content': jsonEncode(PollVoteEventSubmessage(key: key, op: op)),
126+
});
127+
}
128+
129+
testWidgets('tap to toggle vote', (tester) async {
130+
await preparePollWidget(tester, eg.pollWidgetData(
131+
question: 'title', options: ['A']), voterIdxPairs: [(eg.otherUser, 0)]);
132+
final optionKey = PollEventSubmessage.optionKey(senderId: null, idx: 0);
133+
134+
// Because eg.selfUser didn't vote for the option, add their vote.
135+
connection.prepare(json: {});
136+
await tester.tap(findTextAtRow('1', index: 0));
137+
await tester.pump(Duration.zero);
138+
checkVoteRequest(optionKey, PollVoteOp.add);
139+
140+
// We don't local echo right now,
141+
// so wait to hear from the server to get the poll updated.
142+
store.handleEvent(
143+
eg.submessageEvent(message.id, eg.selfUser.userId,
144+
content: PollVoteEventSubmessage(key: optionKey, op: PollVoteOp.add)));
145+
await tester.pump(Duration.zero);
146+
147+
// Because eg.selfUser did vote for the option, remove their vote.
148+
connection.prepare(json: {});
149+
await tester.tap(findTextAtRow('2', index: 0));
150+
await tester.pump(Duration.zero);
151+
checkVoteRequest(optionKey, PollVoteOp.remove);
152+
});
109153
}

0 commit comments

Comments
 (0)