Skip to content

Commit ec71e83

Browse files
committed
poll: Support vote/unvote for polls
Visually, the appearence of the polls do not change, except that now they are clickable. This does not implement local echoing for voting. Instead, we rely on the submessage events to get the updates after voting. Fixes: #166 Signed-off-by: Zixuan James Li <[email protected]>
1 parent a5278ec commit ec71e83

File tree

4 files changed

+87
-16
lines changed

4 files changed

+87
-16
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: 37 additions & 13 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);
@@ -75,22 +91,30 @@ class _PollWidgetState extends State<PollWidget> {
7591
crossAxisAlignment: CrossAxisAlignment.baseline,
7692
textBaseline: localizedTextBaseline(context),
7793
children: [
78-
ConstrainedBox(
79-
constraints: const BoxConstraints(minWidth: 44),
80-
child: Container(
81-
height: 44,
94+
OutlinedButton(
95+
style: OutlinedButton.styleFrom(
96+
minimumSize: const Size.square(44),
97+
// The default visual density is platform dependent.
98+
// For those whose density defaults to [VisualDensity.compact],
99+
// this button would be 8px smaller if we do not override it.
100+
//
101+
// See also:
102+
// * [ThemeData.visualDensity], which provides the default.
103+
visualDensity: VisualDensity.standard,
82104
// This padding is only in effect
83105
// when the vote count has more than one digit.
84106
padding: const EdgeInsets.symmetric(horizontal: 15),
85-
decoration: BoxDecoration(
86-
color: theme.colorPollVoteCountBackground,
87-
border: Border.all(color: theme.colorPollVoteCountBorder),
107+
shape: RoundedRectangleBorder(
88108
borderRadius: BorderRadius.circular(3)),
89-
child: Center(
90-
child: Text(option.voters.length.toString(),
91-
textAlign: TextAlign.center,
92-
style: textStyleBold.copyWith(
93-
color: theme.colorPollVoteCountText, fontSize: 16))))),
109+
backgroundColor: theme.colorPollVoteCountBackground,
110+
side: BorderSide(color: theme.colorPollVoteCountBorder),
111+
splashFactory: NoSplash.splashFactory,
112+
),
113+
onPressed: () => _toggleVote(option),
114+
child: Text(option.voters.length.toString(),
115+
textAlign: TextAlign.center,
116+
style: textStyleBold.copyWith(fontSize: 16,
117+
color: theme.colorPollVoteCountText))),
94118
Expanded(
95119
child: Wrap(
96120
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 do local echoing 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)