Skip to content

Commit 9fde534

Browse files
PIG208gnprice
authored andcommitted
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 We add a TODO for pending poll votes visual indicator. This would also apply to emoji reaction pills. See also: #939 (review) Fixes: #166 Signed-off-by: Zixuan James Li <[email protected]>
1 parent 1db46de commit 9fde534

File tree

3 files changed

+89
-22
lines changed

3 files changed

+89
-22
lines changed

lib/widgets/content.dart

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

lib/widgets/poll.dart

Lines changed: 41 additions & 19 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,16 @@ class _PollWidgetState extends State<PollWidget> {
4448
});
4549
}
4650

51+
void _toggleVote(PollOption option) async {
52+
final store = PerAccountStoreWidget.of(context);
53+
final op = option.voters.contains(store.selfUserId)
54+
? PollVoteOp.remove
55+
: PollVoteOp.add;
56+
unawaited(sendSubmessage(store.connection, messageId: widget.messageId,
57+
submessageType: SubmessageType.widget,
58+
content: PollVoteEventSubmessage(key: option.key, op: op)));
59+
}
60+
4761
@override
4862
Widget build(BuildContext context) {
4963
const verticalPadding = 2.5;
@@ -74,24 +88,32 @@ class _PollWidgetState extends State<PollWidget> {
7488
crossAxisAlignment: CrossAxisAlignment.baseline,
7589
textBaseline: localizedTextBaseline(context),
7690
children: [
77-
ConstrainedBox(
78-
constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
79-
child: Padding(
80-
padding: const EdgeInsetsDirectional.only(
81-
end: 5, top: verticalPadding, bottom: verticalPadding),
82-
child: Container(
83-
// Inner padding preserves whitespace even when the text's
84-
// width approaches the button's min-width (e.g. because
85-
// there are more than three digits).
86-
padding: const EdgeInsets.symmetric(horizontal: 4),
87-
decoration: BoxDecoration(
88-
color: theme.colorPollVoteCountBackground,
89-
border: Border.all(color: theme.colorPollVoteCountBorder),
90-
borderRadius: BorderRadius.circular(3)),
91-
child: Center(
92-
child: Text(option.voters.length.toString(),
93-
style: textStyleBold.copyWith(
94-
color: theme.colorPollVoteCountText, fontSize: 20)))))),
91+
GestureDetector(
92+
// TODO: Implement feedback when the user taps the button
93+
onTap: () => _toggleVote(option),
94+
behavior: HitTestBehavior.translucent,
95+
child: ConstrainedBox(
96+
constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
97+
child: Padding(
98+
// For accessibility, the touch target is padded to be larger
99+
// than the vote count box. Still, we avoid padding at the
100+
// start because we want to align all the poll options to the
101+
// surrounding messages.
102+
padding: const EdgeInsetsDirectional.only(
103+
end: 5, top: verticalPadding, bottom: verticalPadding),
104+
child: Container(
105+
// Inner padding preserves whitespace even when the text's
106+
// width approaches the button's min-width (e.g. because
107+
// there are more than three 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+
style: textStyleBold.copyWith(
116+
color: theme.colorPollVoteCountText, fontSize: 20))))))),
95117
Expanded(
96118
child: Padding(
97119
// This and the padding on the vote count box both extend the row

test/widgets/poll_test.dart

Lines changed: 47 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,41 @@ 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+
await store.handleEvent(
143+
eg.submessageEvent(message.id, eg.selfUser.userId,
144+
content: PollVoteEventSubmessage(key: optionKey, op: PollVoteOp.add)));
145+
// Wait for the poll widget rebuild
146+
await tester.pump(Duration.zero);
147+
148+
// Because eg.selfUser did vote for the option, remove their vote.
149+
connection.prepare(json: {});
150+
await tester.tap(findTextAtRow('2', index: 0));
151+
await tester.pump(Duration.zero);
152+
checkVoteRequest(optionKey, PollVoteOp.remove);
153+
});
109154
}

0 commit comments

Comments
 (0)