Skip to content

Commit 1701b2c

Browse files
committed
poll: Support read-only poll widget UI.
The UI is temporary until we get an actual design. Fixes #165. Signed-off-by: Zixuan James Li <[email protected]>
1 parent 854bdc9 commit 1701b2c

File tree

7 files changed

+141
-17
lines changed

7 files changed

+141
-17
lines changed

assets/l10n/app_en.arb

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -483,5 +483,24 @@
483483
"notifSelfUser": "You",
484484
"@notifSelfUser": {
485485
"description": "Display name for the user themself, to show after replying in an Android notification"
486+
},
487+
"pollWidgetQuestionMissing": "Untitled Poll",
488+
"@pollWidgetQuestionMissing": {
489+
"description": "Text to display for a poll when the question is missing"
490+
},
491+
"pollWidgetOptionsMissing": "Empty poll",
492+
"@pollWidgetOptionsMissing": {
493+
"description": "Text to display for a poll when it has no options"
494+
},
495+
"pollWidgetVoteCountZero": "no votes",
496+
"@pollWidgetVoteCountZero": {
497+
"description": "Label when the number of votes on a poll option is zero"
498+
},
499+
"pollWidgetVoteCount": "{numVotes, plural, =1{1 vote} other{{numVotes} votes}}",
500+
"@pollWidgetVoteCount": {
501+
"description": "Label for the number of votes on a poll option",
502+
"placeholders": {
503+
"numVotes": {"type": "int", "example": "3"}
504+
}
486505
}
487506
}

lib/api/model/widget.dart

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import '../../model/store.dart';
12
import 'submessage.dart';
23

34
/// States of a poll Zulip widget.
@@ -88,6 +89,9 @@ class Option {
8889
final String text;
8990
final Set<int> voters = {};
9091

92+
Iterable<String> getVoterNames(PerAccountStore store) =>
93+
voters.map((userId) => store.users[userId]?.fullName).whereType<String>();
94+
9195
@override
9296
bool operator ==(Object other) {
9397
if (other is! Option) return false;

lib/model/content.dart

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,13 +72,17 @@ mixin UnimplementedNode on ContentNode {
7272
}
7373
}
7474

75+
sealed class ZulipMessageContent {}
76+
77+
class PollContent implements ZulipMessageContent {}
78+
7579
/// A complete parse tree for a Zulip message's content,
7680
/// or other complete piece of Zulip HTML content.
7781
///
7882
/// This is a parsed representation for an entire value of [Message.content],
7983
/// [Stream.renderedDescription], or other text from a Zulip server that comes
8084
/// in the same Zulip HTML format.
81-
class ZulipContent extends ContentNode {
85+
class ZulipContent extends ContentNode implements ZulipMessageContent {
8286
const ZulipContent({super.debugHtmlNode, required this.nodes});
8387

8488
final List<BlockContentNode> nodes;

lib/model/message_list.dart

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ class MessageListDateSeparatorItem extends MessageListItem {
3434
/// A message to show in the message list.
3535
class MessageListMessageItem extends MessageListItem {
3636
final Message message;
37-
ZulipContent content;
37+
ZulipMessageContent content;
3838
bool showSender;
3939
bool isLastInBlock;
4040

@@ -94,7 +94,7 @@ mixin _MessageSequence {
9494
///
9595
/// This information is completely derived from [messages].
9696
/// It exists as an optimization, to memoize the work of parsing.
97-
final List<ZulipContent> contents = [];
97+
final List<ZulipMessageContent> contents = [];
9898

9999
/// The messages and their siblings in the UI, in order.
100100
///
@@ -130,10 +130,18 @@ mixin _MessageSequence {
130130
}
131131
}
132132

133+
ZulipMessageContent _parseMessage(Message message) {
134+
if (message.poll != null) {
135+
return PollContent();
136+
} else {
137+
return parseContent(message.content);
138+
}
139+
}
140+
133141
/// Update data derived from the content of the index-th message.
134142
void _reparseContent(int index) {
135143
final message = messages[index];
136-
final content = parseContent(message.content);
144+
final content = _parseMessage(message);
137145
contents[index] = content;
138146

139147
final itemIndex = findItemWithMessageId(message.id);
@@ -150,7 +158,7 @@ mixin _MessageSequence {
150158
void _addMessage(Message message) {
151159
assert(contents.length == messages.length);
152160
messages.add(message);
153-
contents.add(parseContent(message.content));
161+
contents.add(_parseMessage(message));
154162
assert(contents.length == messages.length);
155163
_processMessage(messages.length - 1);
156164
}
@@ -161,7 +169,7 @@ mixin _MessageSequence {
161169
/// If none of [messageIds] are found, this is a no-op.
162170
bool _removeMessagesById(Iterable<int> messageIds) {
163171
final messagesToRemoveById = <int>{};
164-
final contentToRemove = Set<ZulipContent>.identity();
172+
final contentToRemove = Set<ZulipMessageContent>.identity();
165173
for (final messageId in messageIds) {
166174
final index = _findMessageWithId(messageId);
167175
if (index == -1) continue;
@@ -187,7 +195,7 @@ mixin _MessageSequence {
187195
assert(contents.length == messages.length);
188196
messages.insertAll(index, toInsert);
189197
contents.insertAll(index, toInsert.map(
190-
(message) => parseContent(message.content)));
198+
(message) => _parseMessage(message)));
191199
assert(contents.length == messages.length);
192200
_reprocessAll();
193201
}
@@ -196,7 +204,7 @@ mixin _MessageSequence {
196204
void _recompute() {
197205
assert(contents.length == messages.length);
198206
contents.clear();
199-
contents.addAll(messages.map((message) => parseContent(message.content)));
207+
contents.addAll(messages.map((message) => _parseMessage(message)));
200208
assert(contents.length == messages.length);
201209
_reprocessAll();
202210
}

lib/widgets/content.dart

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import 'dialog.dart';
1818
import 'icons.dart';
1919
import 'lightbox.dart';
2020
import 'message_list.dart';
21+
import 'poll.dart';
2122
import 'store.dart';
2223
import 'text.dart';
2324

@@ -222,17 +223,20 @@ const double kBaseFontSize = 17;
222223
/// This does not include metadata like the sender's name and avatar, the time,
223224
/// or the message's status as starred or edited.
224225
class MessageContent extends StatelessWidget {
225-
const MessageContent({super.key, required this.message, required this.content});
226+
const MessageContent({super.key, required this.message, required ZulipMessageContent content}) : _content = content;
226227

227228
final Message message;
228-
final ZulipContent content;
229+
final ZulipMessageContent _content;
229230

230231
@override
231232
Widget build(BuildContext context) {
232233
return InheritedMessage(message: message,
233234
child: DefaultTextStyle(
234235
style: ContentTheme.of(context).textStylePlainParagraph,
235-
child: BlockContentList(nodes: content.nodes)));
236+
child: switch(_content) {
237+
ZulipContent() => BlockContentList(nodes: _content.nodes),
238+
PollContent() => PollWidget(poll: message.poll!),
239+
}));
236240
}
237241
}
238242

lib/widgets/poll.dart

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import 'package:flutter/material.dart';
2+
import 'package:flutter_gen/gen_l10n/zulip_localizations.dart';
3+
4+
import '../api/model/widget.dart';
5+
import 'content.dart';
6+
import 'store.dart';
7+
import 'text.dart';
8+
9+
class PollWidget extends StatelessWidget {
10+
const PollWidget({super.key, required this.poll});
11+
12+
final Poll poll;
13+
14+
@override
15+
Widget build(BuildContext context) {
16+
final zulipLocalizations = ZulipLocalizations.of(context);
17+
final store = PerAccountStoreWidget.of(context);
18+
19+
final optionTiles = [
20+
for (final option in poll.options)
21+
ListTile(
22+
titleAlignment: ListTileTitleAlignment.top,
23+
enabled: false,
24+
leading: Checkbox(
25+
value: option.voters.contains(store.selfUserId),
26+
onChanged: (bool? value) {},
27+
),
28+
contentPadding: EdgeInsets.zero,
29+
title: Row(children: [
30+
Flexible(
31+
fit: FlexFit.tight,
32+
child: Text(option.text,
33+
textAlign: TextAlign.start,
34+
overflow: TextOverflow.ellipsis,
35+
style: const TextStyle(fontSize: 17)),
36+
),
37+
Text(
38+
(option.voters.isEmpty)
39+
? zulipLocalizations.pollWidgetVoteCountZero
40+
: zulipLocalizations.pollWidgetVoteCount(option.voters.length),
41+
textAlign: TextAlign.end)
42+
]),
43+
subtitle: Wrap(
44+
alignment: WrapAlignment.end,
45+
spacing: -5,
46+
children: [
47+
for (final voterId in option.voters.take(20))
48+
Avatar(size: 17, borderRadius: 17, userId: voterId),
49+
]),
50+
)
51+
];
52+
53+
final effectiveQuestion = (poll.question.isNotEmpty)
54+
? poll.question
55+
: zulipLocalizations.pollWidgetQuestionMissing;
56+
57+
return Padding(
58+
padding: const EdgeInsets.fromLTRB(0, 15, 0, 15),
59+
child: DecoratedBox(
60+
decoration: BoxDecoration(
61+
// Web has the same color in light and dark mode.
62+
border: Border.all(color: const Color(0xff808080)),
63+
borderRadius: BorderRadius.circular(10),
64+
),
65+
child: Padding(padding: const EdgeInsetsDirectional.fromSTEB(10, 2, 8, 2),
66+
child: Column(
67+
crossAxisAlignment: CrossAxisAlignment.start,
68+
children: [
69+
Padding(
70+
padding: const EdgeInsets.all(5.0),
71+
child: DefaultTextStyle.merge(
72+
style: weightVariableTextStyle(context, wght: 700),
73+
child: Text(effectiveQuestion, textAlign: TextAlign.start)),
74+
),
75+
const Divider(color: Color(0xff808080), height: 1),
76+
if (optionTiles.isEmpty)
77+
Padding(
78+
padding: const EdgeInsets.all(5.0),
79+
child: Text(zulipLocalizations.pollWidgetOptionsMissing),
80+
),
81+
...ListTile.divideTiles(context: context, tiles: optionTiles),
82+
],
83+
))));
84+
}
85+
}

test/model/message_list_test.dart

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -584,12 +584,12 @@ void main() {
584584
model.contents[0] = const ZulipContent(nodes: [
585585
ParagraphNode(links: null, nodes: [TextNode('something outdated')])
586586
]);
587-
check(model.contents[0]).not((it) => it.equalsNode(correctContent));
587+
check(model.contents[0]).isA<ZulipContent>().not((it) => it.equalsNode(correctContent));
588588

589589
model.reassemble();
590590
checkNotifiedOnce();
591591
check(model).messages.length.equals(31);
592-
check(model.contents[0]).equalsNode(correctContent);
592+
check(model.contents[0]).isA<ZulipContent>().equalsNode(correctContent);
593593
});
594594

595595
group('stream/topic muting', () {
@@ -987,7 +987,7 @@ void checkInvariants(MessageListView model) {
987987

988988
check(model).contents.length.equals(model.messages.length);
989989
for (int i = 0; i < model.contents.length; i++) {
990-
check(model.contents[i])
990+
check(model.contents[i]).isA<ZulipContent>()
991991
.equalsNode(parseContent(model.messages[i].content));
992992
}
993993

@@ -1012,7 +1012,7 @@ void checkInvariants(MessageListView model) {
10121012
}
10131013
check(model.items[i++]).isA<MessageListMessageItem>()
10141014
..message.identicalTo(model.messages[j])
1015-
..content.identicalTo(model.contents[j])
1015+
..content.identicalTo(model.contents[j] as ZulipContent)
10161016
..showSender.equals(
10171017
forcedShowSender || model.messages[j].senderId != model.messages[j-1].senderId)
10181018
..isLastInBlock.equals(
@@ -1037,7 +1037,7 @@ extension MessageListDateSeparatorItemChecks on Subject<MessageListDateSeparator
10371037

10381038
extension MessageListMessageItemChecks on Subject<MessageListMessageItem> {
10391039
Subject<Message> get message => has((x) => x.message, 'message');
1040-
Subject<ZulipContent> get content => has((x) => x.content, 'content');
1040+
Subject<ZulipMessageContent> get content => has((x) => x.content, 'content');
10411041
Subject<bool> get showSender => has((x) => x.showSender, 'showSender');
10421042
Subject<bool> get isLastInBlock => has((x) => x.isLastInBlock, 'isLastInBlock');
10431043
}
@@ -1046,7 +1046,7 @@ extension MessageListViewChecks on Subject<MessageListView> {
10461046
Subject<PerAccountStore> get store => has((x) => x.store, 'store');
10471047
Subject<Narrow> get narrow => has((x) => x.narrow, 'narrow');
10481048
Subject<List<Message>> get messages => has((x) => x.messages, 'messages');
1049-
Subject<List<ZulipContent>> get contents => has((x) => x.contents, 'contents');
1049+
Subject<List<ZulipMessageContent>> get contents => has((x) => x.contents, 'contents');
10501050
Subject<List<MessageListItem>> get items => has((x) => x.items, 'items');
10511051
Subject<bool> get fetched => has((x) => x.fetched, 'fetched');
10521052
Subject<bool> get haveOldest => has((x) => x.haveOldest, 'haveOldest');

0 commit comments

Comments
 (0)