Skip to content

Commit c6edba9

Browse files
committed
poll: Support read-only poll widget UI.
The UI follows the webapp until we get a new design. The dark theme colors were tentatively picked. The `TextStyle`s are the same for both light and dark theme. All the styling are based on values taken from the webapp. References: - light theme: https://github.com/zulip/zulip/blob/2011e0df760cea52c31914e7b77d9b4e38e9ee74/web/styles/widgets.css#L138-L185 https://github.com/zulip/zulip/blob/2011e0df760cea52c31914e7b77d9b4e38e9ee74/web/styles/dark_theme.css#L358 - dark theme: https://github.com/zulip/zulip/blob/2011e0df760cea52c31914e7b77d9b4e38e9ee74/web/styles/dark_theme.css#L966-L987 Fixes #165. Signed-off-by: Zixuan James Li <[email protected]>
1 parent 5ef916d commit c6edba9

File tree

8 files changed

+285
-17
lines changed

8 files changed

+285
-17
lines changed

assets/l10n/app_en.arb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -510,5 +510,13 @@
510510
"manyPeopleTyping": "Several people are typing…",
511511
"@manyPeopleTyping": {
512512
"description": "Text to display when there are multiple users typing."
513+
},
514+
"pollWidgetQuestionMissing": "No question.",
515+
"@pollWidgetQuestionMissing": {
516+
"description": "Text to display for a poll when the question is missing"
517+
},
518+
"pollWidgetOptionsMissing": "This poll has no options yet.",
519+
"@pollWidgetOptionsMissing": {
520+
"description": "Text to display for a poll when it has no options"
513521
}
514522
}

lib/api/model/submessage.dart

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import 'dart:convert';
33
import 'package:json_annotation/json_annotation.dart';
44

55
import '../../log.dart';
6+
import '../../model/store.dart';
67

78
part 'submessage.g.dart';
89

@@ -374,6 +375,9 @@ class PollOption {
374375
final String text;
375376
final Set<int> voters = {};
376377

378+
Iterable<String> getVoterNames(PerAccountStore store, String fallback) =>
379+
voters.map((userId) => store.users[userId]?.fullName ?? fallback);
380+
377381
@override
378382
bool operator ==(Object other) {
379383
if (other is! PollOption) 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

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

102102
/// The messages and their siblings in the UI, in order.
103103
///
@@ -133,10 +133,18 @@ mixin _MessageSequence {
133133
}
134134
}
135135

136+
ZulipMessageContent _parseMessage(Message message) {
137+
if (message.poll != null) {
138+
return PollContent();
139+
} else {
140+
return parseContent(message.content);
141+
}
142+
}
143+
136144
/// Update data derived from the content of the index-th message.
137145
void _reparseContent(int index) {
138146
final message = messages[index];
139-
final content = parseContent(message.content);
147+
final content = _parseMessage(message);
140148
contents[index] = content;
141149

142150
final itemIndex = findItemWithMessageId(message.id);
@@ -153,7 +161,7 @@ mixin _MessageSequence {
153161
void _addMessage(Message message) {
154162
assert(contents.length == messages.length);
155163
messages.add(message);
156-
contents.add(parseContent(message.content));
164+
contents.add(_parseMessage(message));
157165
assert(contents.length == messages.length);
158166
_processMessage(messages.length - 1);
159167
}
@@ -164,7 +172,7 @@ mixin _MessageSequence {
164172
/// If none of [messageIds] are found, this is a no-op.
165173
bool _removeMessagesById(Iterable<int> messageIds) {
166174
final messagesToRemoveById = <int>{};
167-
final contentToRemove = Set<ZulipContent>.identity();
175+
final contentToRemove = Set<ZulipMessageContent>.identity();
168176
for (final messageId in messageIds) {
169177
final index = _findMessageWithId(messageId);
170178
if (index == -1) continue;
@@ -190,7 +198,7 @@ mixin _MessageSequence {
190198
assert(contents.length == messages.length);
191199
messages.insertAll(index, toInsert);
192200
contents.insertAll(index, toInsert.map(
193-
(message) => parseContent(message.content)));
201+
(message) => _parseMessage(message)));
194202
assert(contents.length == messages.length);
195203
_reprocessAll();
196204
}
@@ -210,7 +218,7 @@ mixin _MessageSequence {
210218
void _recompute() {
211219
assert(contents.length == messages.length);
212220
contents.clear();
213-
contents.addAll(messages.map((message) => parseContent(message.content)));
221+
contents.addAll(messages.map((message) => _parseMessage(message)));
214222
assert(contents.length == messages.length);
215223
_reprocessAll();
216224
}

lib/widgets/content.dart

Lines changed: 35 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

@@ -41,6 +42,10 @@ class ContentTheme extends ThemeExtension<ContentTheme> {
4142
colorGlobalTimeBorder: const HSLColor.fromAHSL(1, 0, 0, 0.8).toColor(),
4243
colorMathBlockBorder: const HSLColor.fromAHSL(0.15, 240, 0.8, 0.5).toColor(),
4344
colorMessageMediaContainerBackground: const Color.fromRGBO(0, 0, 0, 0.03),
45+
colorPollNames: const HSLColor.fromAHSL(1, 0, 0, .45).toColor(),
46+
colorPollVoteCountBackground: const HSLColor.fromAHSL(1, 0, 0, 1).toColor(),
47+
colorPollVoteCountBorder: const HSLColor.fromAHSL(1, 156, 0.28, 0.7).toColor(),
48+
colorPollVoteCountText: const HSLColor.fromAHSL(1, 156, 0.41, 0.4).toColor(),
4449
colorThematicBreak: const HSLColor.fromAHSL(1, 0, 0, .87).toColor(),
4550
textStylePlainParagraph: _plainParagraphCommon(context).copyWith(
4651
color: const HSLColor.fromAHSL(1, 0, 0, 0.15).toColor(),
@@ -66,6 +71,10 @@ class ContentTheme extends ThemeExtension<ContentTheme> {
6671
colorGlobalTimeBorder: const HSLColor.fromAHSL(0.4, 0, 0, 0).toColor(),
6772
colorMathBlockBorder: const HSLColor.fromAHSL(1, 240, 0.4, 0.4).toColor(),
6873
colorMessageMediaContainerBackground: const HSLColor.fromAHSL(0.03, 0, 0, 1).toColor(),
74+
colorPollNames: const HSLColor.fromAHSL(1, 236, .15, .7).toColor(),
75+
colorPollVoteCountBackground: const HSLColor.fromAHSL(0.2, 0, 0, 0).toColor(),
76+
colorPollVoteCountBorder: const HSLColor.fromAHSL(1, 185, 0.35, 0.35).toColor(),
77+
colorPollVoteCountText: const HSLColor.fromAHSL(1, 185, 0.35, 0.65).toColor(),
6978
colorThematicBreak: const HSLColor.fromAHSL(1, 0, 0, .87).toColor().withOpacity(0.2),
7079
textStylePlainParagraph: _plainParagraphCommon(context).copyWith(
7180
color: const HSLColor.fromAHSL(0.75, 0, 0, 1).toColor(),
@@ -90,6 +99,10 @@ class ContentTheme extends ThemeExtension<ContentTheme> {
9099
required this.colorGlobalTimeBorder,
91100
required this.colorMathBlockBorder,
92101
required this.colorMessageMediaContainerBackground,
102+
required this.colorPollNames,
103+
required this.colorPollVoteCountBackground,
104+
required this.colorPollVoteCountBorder,
105+
required this.colorPollVoteCountText,
93106
required this.colorThematicBreak,
94107
required this.textStylePlainParagraph,
95108
required this.codeBlockTextStyles,
@@ -115,6 +128,10 @@ class ContentTheme extends ThemeExtension<ContentTheme> {
115128
final Color colorGlobalTimeBorder;
116129
final Color colorMathBlockBorder; // TODO(#46) this won't be needed
117130
final Color colorMessageMediaContainerBackground;
131+
final Color colorPollNames;
132+
final Color colorPollVoteCountBackground;
133+
final Color colorPollVoteCountBorder;
134+
final Color colorPollVoteCountText;
118135
final Color colorThematicBreak;
119136

120137
/// The complete [TextStyle] we use for plain, unstyled paragraphs.
@@ -166,6 +183,10 @@ class ContentTheme extends ThemeExtension<ContentTheme> {
166183
Color? colorGlobalTimeBorder,
167184
Color? colorMathBlockBorder,
168185
Color? colorMessageMediaContainerBackground,
186+
Color? colorPollNames,
187+
Color? colorPollVoteCountBackground,
188+
Color? colorPollVoteCountBorder,
189+
Color? colorPollVoteCountText,
169190
Color? colorThematicBreak,
170191
TextStyle? textStylePlainParagraph,
171192
CodeBlockTextStyles? codeBlockTextStyles,
@@ -181,6 +202,10 @@ class ContentTheme extends ThemeExtension<ContentTheme> {
181202
colorGlobalTimeBorder: colorGlobalTimeBorder ?? this.colorGlobalTimeBorder,
182203
colorMathBlockBorder: colorMathBlockBorder ?? this.colorMathBlockBorder,
183204
colorMessageMediaContainerBackground: colorMessageMediaContainerBackground ?? this.colorMessageMediaContainerBackground,
205+
colorPollNames: colorPollNames ?? this.colorPollNames,
206+
colorPollVoteCountBackground: colorPollVoteCountBackground ?? this.colorPollVoteCountBackground,
207+
colorPollVoteCountBorder: colorPollVoteCountBorder ?? this.colorPollVoteCountBorder,
208+
colorPollVoteCountText: colorPollVoteCountText ?? this.colorPollVoteCountText,
184209
colorThematicBreak: colorThematicBreak ?? this.colorThematicBreak,
185210
textStylePlainParagraph: textStylePlainParagraph ?? this.textStylePlainParagraph,
186211
codeBlockTextStyles: codeBlockTextStyles ?? this.codeBlockTextStyles,
@@ -204,6 +229,10 @@ class ContentTheme extends ThemeExtension<ContentTheme> {
204229
colorMathBlockBorder: Color.lerp(colorMathBlockBorder, other.colorMathBlockBorder, t)!,
205230
colorMessageMediaContainerBackground: Color.lerp(colorMessageMediaContainerBackground, other.colorMessageMediaContainerBackground, t)!,
206231
colorThematicBreak: Color.lerp(colorThematicBreak, other.colorThematicBreak, t)!,
232+
colorPollNames: Color.lerp(colorPollNames, other.colorPollNames, t)!,
233+
colorPollVoteCountBackground: Color.lerp(colorPollVoteCountBackground, other.colorPollVoteCountBackground, t)!,
234+
colorPollVoteCountBorder: Color.lerp(colorPollVoteCountBorder, other.colorPollVoteCountBorder, t)!,
235+
colorPollVoteCountText: Color.lerp(colorPollVoteCountText, other.colorPollVoteCountText, t)!,
207236
textStylePlainParagraph: TextStyle.lerp(textStylePlainParagraph, other.textStylePlainParagraph, t)!,
208237
codeBlockTextStyles: CodeBlockTextStyles.lerp(codeBlockTextStyles, other.codeBlockTextStyles, t),
209238
textStyleError: TextStyle.lerp(textStyleError, other.textStyleError, t)!,
@@ -222,17 +251,20 @@ const double kBaseFontSize = 17;
222251
/// This does not include metadata like the sender's name and avatar, the time,
223252
/// or the message's status as starred or edited.
224253
class MessageContent extends StatelessWidget {
225-
const MessageContent({super.key, required this.message, required this.content});
254+
const MessageContent({super.key, required this.message, required ZulipMessageContent content}) : _content = content;
226255

227256
final Message message;
228-
final ZulipContent content;
257+
final ZulipMessageContent _content;
229258

230259
@override
231260
Widget build(BuildContext context) {
232261
return InheritedMessage(message: message,
233262
child: DefaultTextStyle(
234263
style: ContentTheme.of(context).textStylePlainParagraph,
235-
child: BlockContentList(nodes: content.nodes)));
264+
child: switch(_content) {
265+
ZulipContent() => BlockContentList(nodes: _content.nodes),
266+
PollContent() => PollWidget(poll: message.poll!),
267+
}));
236268
}
237269
}
238270

lib/widgets/poll.dart

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import 'package:flutter/material.dart';
2+
import 'package:flutter_gen/gen_l10n/zulip_localizations.dart';
3+
4+
import '../api/model/submessage.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+
final theme = ContentTheme.of(context);
19+
20+
final textStylePollPrimary = const TextStyle(fontSize: 16)
21+
.merge(weightVariableTextStyle(context, wght: 600));
22+
final textStylePollSecondary = TextStyle(
23+
fontSize: 16, color: theme.colorPollNames);
24+
25+
final optionTiles = [
26+
for (final option in poll.options)
27+
Padding(
28+
padding: const EdgeInsets.only(bottom: 5.0),
29+
child: Row(
30+
crossAxisAlignment: CrossAxisAlignment.start,
31+
children: [
32+
Container(
33+
width: 25,
34+
height: 25,
35+
decoration: BoxDecoration(
36+
color: theme.colorPollVoteCountBackground,
37+
border: Border.all(color: theme.colorPollVoteCountBorder),
38+
borderRadius: BorderRadius.circular(3),
39+
),
40+
child: Center(
41+
child: Text(option.voters.length.toString(),
42+
style: textStylePollPrimary
43+
.copyWith(color: theme.colorPollVoteCountText, fontSize: 13),
44+
textAlign: TextAlign.center,
45+
),
46+
),
47+
),
48+
Expanded(
49+
child: Padding(
50+
padding: const EdgeInsetsDirectional.only(start: 5.0),
51+
child: Wrap(children: [
52+
Padding(
53+
padding: const EdgeInsetsDirectional.only(end: 5.0),
54+
child: Text(
55+
option.text,
56+
style: textStylePollPrimary,
57+
),
58+
),
59+
if (option.voters.isNotEmpty)
60+
Text(
61+
'(${option.getVoterNames(store, zulipLocalizations.unknownUserName).join(', ')})',
62+
style: textStylePollSecondary,
63+
),
64+
]))),
65+
])),
66+
];
67+
68+
Text question = (poll.question.isNotEmpty)
69+
? Text(
70+
poll.question,
71+
style: textStylePollPrimary.copyWith(fontSize: 18))
72+
: Text(
73+
zulipLocalizations.pollWidgetQuestionMissing,
74+
style: textStylePollPrimary.copyWith(fontSize: 18, fontStyle: FontStyle.italic));
75+
76+
return Column(
77+
crossAxisAlignment: CrossAxisAlignment.start,
78+
children: [
79+
Padding(padding: const EdgeInsets.only(bottom: 6.0), child: question),
80+
if (optionTiles.isEmpty)
81+
Text(zulipLocalizations.pollWidgetOptionsMissing,
82+
style: textStylePollSecondary.copyWith(fontStyle: FontStyle.italic)),
83+
...optionTiles
84+
],
85+
);
86+
}
87+
}

test/model/message_list_test.dart

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

10891089
model.reassemble();
10901090
checkNotifiedOnce();
10911091
check(model).messages.length.equals(31);
1092-
check(model.contents[0]).equalsNode(correctContent);
1092+
check(model.contents[0]).isA<ZulipContent>().equalsNode(correctContent);
10931093
});
10941094

10951095
group('stream/topic muting', () {
@@ -1528,7 +1528,7 @@ void checkInvariants(MessageListView model) {
15281528

15291529
check(model).contents.length.equals(model.messages.length);
15301530
for (int i = 0; i < model.contents.length; i++) {
1531-
check(model.contents[i])
1531+
check(model.contents[i]).isA<ZulipContent>()
15321532
.equalsNode(parseContent(model.messages[i].content));
15331533
}
15341534

@@ -1553,7 +1553,7 @@ void checkInvariants(MessageListView model) {
15531553
}
15541554
check(model.items[i++]).isA<MessageListMessageItem>()
15551555
..message.identicalTo(model.messages[j])
1556-
..content.identicalTo(model.contents[j])
1556+
..content.identicalTo(model.contents[j] as ZulipContent)
15571557
..showSender.equals(
15581558
forcedShowSender || model.messages[j].senderId != model.messages[j-1].senderId)
15591559
..isLastInBlock.equals(
@@ -1578,7 +1578,7 @@ extension MessageListDateSeparatorItemChecks on Subject<MessageListDateSeparator
15781578

15791579
extension MessageListMessageItemChecks on Subject<MessageListMessageItem> {
15801580
Subject<Message> get message => has((x) => x.message, 'message');
1581-
Subject<ZulipContent> get content => has((x) => x.content, 'content');
1581+
Subject<ZulipMessageContent> get content => has((x) => x.content, 'content');
15821582
Subject<bool> get showSender => has((x) => x.showSender, 'showSender');
15831583
Subject<bool> get isLastInBlock => has((x) => x.isLastInBlock, 'isLastInBlock');
15841584
}
@@ -1587,7 +1587,7 @@ extension MessageListViewChecks on Subject<MessageListView> {
15871587
Subject<PerAccountStore> get store => has((x) => x.store, 'store');
15881588
Subject<Narrow> get narrow => has((x) => x.narrow, 'narrow');
15891589
Subject<List<Message>> get messages => has((x) => x.messages, 'messages');
1590-
Subject<List<ZulipContent>> get contents => has((x) => x.contents, 'contents');
1590+
Subject<List<ZulipMessageContent>> get contents => has((x) => x.contents, 'contents');
15911591
Subject<List<MessageListItem>> get items => has((x) => x.items, 'items');
15921592
Subject<bool> get fetched => has((x) => x.fetched, 'fetched');
15931593
Subject<bool> get haveOldest => has((x) => x.haveOldest, 'haveOldest');

0 commit comments

Comments
 (0)