Skip to content

Commit e0df9f0

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 zulip#165. Signed-off-by: Zixuan James Li <[email protected]>
1 parent 039a3f9 commit e0df9f0

File tree

6 files changed

+254
-0
lines changed

6 files changed

+254
-0
lines changed

assets/l10n/app_en.arb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -533,5 +533,13 @@
533533
"manyPeopleTyping": "Several people are typing…",
534534
"@manyPeopleTyping": {
535535
"description": "Text to display when there are multiple users typing."
536+
},
537+
"pollWidgetQuestionMissing": "No question.",
538+
"@pollWidgetQuestionMissing": {
539+
"description": "Text to display for a poll when the question is missing"
540+
},
541+
"pollWidgetOptionsMissing": "This poll has no options yet.",
542+
"@pollWidgetOptionsMissing": {
543+
"description": "Text to display for a poll when it has no options"
536544
}
537545
}

lib/model/content.dart

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart';
33
import 'package:html/dom.dart' as dom;
44
import 'package:html/parser.dart';
55

6+
import '../api/model/submessage.dart';
67
import 'code_block.dart';
78

89
/// A node in a parse tree for Zulip message-style content.
@@ -74,6 +75,12 @@ mixin UnimplementedNode on ContentNode {
7475

7576
sealed class ZulipMessageContent {}
7677

78+
class PollContent implements ZulipMessageContent {
79+
const PollContent(this.poll);
80+
81+
final Poll poll;
82+
}
83+
7784
/// A complete parse tree for a Zulip message's content,
7885
/// or other complete piece of Zulip HTML content.
7986
///

lib/model/message_list.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,8 @@ mixin _MessageSequence {
135135
}
136136

137137
ZulipMessageContent _parseMessageContent(Message message) {
138+
final poll = message.poll;
139+
if (poll != null) return PollContent(poll);
138140
return parseContent(message.content);
139141
}
140142

lib/widgets/content.dart

Lines changed: 30 additions & 0 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().withValues(alpha: 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,
@@ -203,6 +228,10 @@ class ContentTheme extends ThemeExtension<ContentTheme> {
203228
colorGlobalTimeBorder: Color.lerp(colorGlobalTimeBorder, other.colorGlobalTimeBorder, t)!,
204229
colorMathBlockBorder: Color.lerp(colorMathBlockBorder, other.colorMathBlockBorder, t)!,
205230
colorMessageMediaContainerBackground: Color.lerp(colorMessageMediaContainerBackground, other.colorMessageMediaContainerBackground, t)!,
231+
colorPollNames: Color.lerp(colorPollNames, other.colorPollNames, t)!,
232+
colorPollVoteCountBackground: Color.lerp(colorPollVoteCountBackground, other.colorPollVoteCountBackground, t)!,
233+
colorPollVoteCountBorder: Color.lerp(colorPollVoteCountBorder, other.colorPollVoteCountBorder, t)!,
234+
colorPollVoteCountText: Color.lerp(colorPollVoteCountText, other.colorPollVoteCountText, t)!,
206235
colorThematicBreak: Color.lerp(colorThematicBreak, other.colorThematicBreak, t)!,
207236
textStylePlainParagraph: TextStyle.lerp(textStylePlainParagraph, other.textStylePlainParagraph, t)!,
208237
codeBlockTextStyles: CodeBlockTextStyles.lerp(codeBlockTextStyles, other.codeBlockTextStyles, t),
@@ -234,6 +263,7 @@ class MessageContent extends StatelessWidget {
234263
style: ContentTheme.of(context).textStylePlainParagraph,
235264
child: switch(_content) {
236265
ZulipContent() => BlockContentList(nodes: _content.nodes),
266+
PollContent() => PollWidget(poll: _content.poll),
237267
}));
238268
}
239269
}

lib/widgets/poll.dart

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
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 textStylePollBold = const TextStyle(fontSize: 18)
21+
.merge(weightVariableTextStyle(context, wght: 600));
22+
final textStylePollNames = TextStyle(
23+
fontSize: 16, color: theme.colorPollNames);
24+
25+
// TODO(i18n): List formatting, like you can do in JavaScript:
26+
// new Intl.ListFormat('ja').format(['Chris', 'Greg', 'Alya', 'Zixuan'])
27+
// // 'Chris、Greg、Alya、Zixuan'
28+
String getOptionVoterNames(PollOption option) => option.voters.map((userId) =>
29+
store.users[userId]?.fullName
30+
?? zulipLocalizations.unknownUserName).join(', ');
31+
32+
final optionTiles = [
33+
for (final option in poll.options)
34+
Padding(
35+
padding: const EdgeInsets.only(bottom: 5.0),
36+
child: Row(
37+
crossAxisAlignment: CrossAxisAlignment.start,
38+
children: [
39+
Container(
40+
width: 25,
41+
height: 25,
42+
decoration: BoxDecoration(
43+
color: theme.colorPollVoteCountBackground,
44+
border: Border.all(color: theme.colorPollVoteCountBorder),
45+
borderRadius: BorderRadius.circular(3)),
46+
child: Center(
47+
child: Text(option.voters.length.toString(),
48+
style: textStylePollBold
49+
.copyWith(color: theme.colorPollVoteCountText, fontSize: 13),
50+
textAlign: TextAlign.center))),
51+
Expanded(
52+
child: Padding(
53+
padding: const EdgeInsetsDirectional.only(start: 5.0),
54+
child: Wrap(children: [
55+
Padding(
56+
padding: const EdgeInsetsDirectional.only(end: 5.0),
57+
child: Text(option.text,
58+
style: textStylePollBold.copyWith(fontSize: 16))),
59+
if (option.voters.isNotEmpty)
60+
// TODO(i18n): Localize parenthesis characters.
61+
Text('(${getOptionVoterNames(option)})', style: textStylePollNames),
62+
]))),
63+
])),
64+
];
65+
66+
Text question = (poll.question.isNotEmpty)
67+
? Text(poll.question, style: textStylePollBold)
68+
: Text(zulipLocalizations.pollWidgetQuestionMissing,
69+
style: textStylePollBold.copyWith(fontStyle: FontStyle.italic));
70+
71+
return Column(
72+
crossAxisAlignment: CrossAxisAlignment.start,
73+
children: [
74+
Padding(padding: const EdgeInsets.only(bottom: 6.0), child: question),
75+
if (optionTiles.isEmpty)
76+
Text(zulipLocalizations.pollWidgetOptionsMissing,
77+
style: textStylePollNames.copyWith(fontStyle: FontStyle.italic)),
78+
...optionTiles
79+
]);
80+
}
81+
}

test/widgets/poll_test.dart

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import 'package:checks/checks.dart';
2+
import 'package:flutter/widgets.dart';
3+
import 'package:flutter_test/flutter_test.dart';
4+
import 'package:zulip/api/model/model.dart';
5+
import 'package:zulip/api/model/submessage.dart';
6+
import 'package:zulip/model/narrow.dart';
7+
import 'package:zulip/model/store.dart';
8+
import 'package:zulip/widgets/message_list.dart';
9+
import 'package:zulip/widgets/poll.dart';
10+
11+
import '../api/fake_api.dart';
12+
import '../example_data.dart' as eg;
13+
import '../model/binding.dart';
14+
import '../model/message_list_test.dart';
15+
import '../model/test_store.dart';
16+
import 'test_app.dart';
17+
18+
void main() {
19+
TestZulipBinding.ensureInitialized();
20+
21+
late PerAccountStore store;
22+
23+
Future<void> prepare({Iterable<User>? users}) async {
24+
addTearDown(testBinding.reset);
25+
26+
await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot());
27+
store = await testBinding.globalStore.perAccount(eg.selfAccount.id);
28+
await store.addUsers(users ?? [eg.selfUser, eg.otherUser]);
29+
}
30+
31+
Future<void> prepareZulipWidget(WidgetTester tester,
32+
SubmessageData? submessageContent, {
33+
Iterable<(User, int)> voterIdxPairs = const [],
34+
}) async {
35+
final message = eg.streamMessage(id: 123, sender: eg.selfUser);
36+
final submessage = eg.submessage(content: submessageContent);
37+
(store.connection as FakeApiConnection).prepare(json:
38+
newestResult(foundOldest: true, messages: []).toJson()
39+
..['messages'] = [{
40+
...message.toJson(),
41+
'submessages': [submessage.toJson()],
42+
}]);
43+
await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id,
44+
child: MessageListPage(initNarrow: TopicNarrow.ofMessage(message))));
45+
46+
await tester.pumpAndSettle();
47+
48+
for (final (voter, idx) in voterIdxPairs) {
49+
await store.handleEvent(eg.submessageEvent(123, voter.userId,
50+
content: PollVoteEventSubmessage(
51+
key: PollEventSubmessage.optionKey(senderId: null, idx: idx),
52+
op: PollVoteOp.add)));
53+
}
54+
await tester.pump();
55+
}
56+
57+
Finder findInPoll(Finder matching) => find.descendant(
58+
of: find.byType(PollWidget), matching: matching);
59+
60+
final pollWidgetData = eg.pollWidgetData(
61+
question: 'favorite letter', options: ['A', 'B', 'C']);
62+
63+
testWidgets('smoke', (tester) async {
64+
await prepare();
65+
await prepareZulipWidget(tester,
66+
pollWidgetData,
67+
voterIdxPairs: [
68+
(eg.selfUser, 0),
69+
(eg.selfUser, 1),
70+
(eg.otherUser, 1),
71+
]);
72+
73+
check(findInPoll(find.text('favorite letter')).evaluate()).single;
74+
int optionIndex = 0;
75+
for (final (text, vote, names) in [
76+
('A', '1', '(${eg.selfUser.fullName})'),
77+
('B', '2', '(${eg.selfUser.fullName}, ${eg.otherUser.fullName})'),
78+
('C', '0', ''),
79+
]) {
80+
final optionFinder = findInPoll(find.byType(Row)).at(optionIndex++);
81+
check(find.descendant(of: optionFinder, matching: find.text(text)).evaluate()).single;
82+
check(find.descendant(of: optionFinder, matching: find.text(vote)).evaluate()).single;
83+
if (names.isNotEmpty) {
84+
check(find.descendant(of: optionFinder, matching: find.text(names)).evaluate()).single;
85+
}
86+
}
87+
});
88+
89+
testWidgets('a lot of voters', (tester) async {
90+
final users = List.generate(100, (i) => eg.user(fullName: 'user#$i'));
91+
await prepare(users: users);
92+
await prepareZulipWidget(tester, pollWidgetData,
93+
voterIdxPairs: users.map((user) => (user, 0)));
94+
final optionFinder = findInPoll(find.byType(Row)).at(0);
95+
final allUserNames = '(${users.map((user) => user.fullName).join(', ')})';
96+
check(find.descendant(
97+
of: optionFinder, matching: find.text(allUserNames)).evaluate()).single;
98+
check(find.descendant(
99+
of: optionFinder, matching: find.text('100')).evaluate()).single;
100+
});
101+
102+
testWidgets('show unknown voter', (tester) async {
103+
await prepare();
104+
await prepareZulipWidget(tester, pollWidgetData);
105+
await store.handleEvent(eg.submessageEvent(123, eg.thirdUser.userId,
106+
content: PollVoteEventSubmessage(
107+
key: PollEventSubmessage.optionKey(senderId: null, idx: 1),
108+
op: PollVoteOp.add)));
109+
await tester.pump();
110+
check(findInPoll(find.text('((unknown user))')).evaluate()).single;
111+
});
112+
113+
testWidgets('poll title missing', (tester) async {
114+
await prepare();
115+
await prepareZulipWidget(tester, PollWidgetData(
116+
extraData: const PollWidgetExtraData(question: '', options: ['A', 'B'])));
117+
check(findInPoll(find.text('No question.')).evaluate()).single;
118+
});
119+
120+
testWidgets('poll options missing', (tester) async {
121+
await prepare();
122+
await prepareZulipWidget(tester, PollWidgetData(
123+
extraData: const PollWidgetExtraData(question: 'title', options: [])));
124+
check(findInPoll(find.text('This poll has no options yet.')).evaluate()).single;
125+
});
126+
}

0 commit comments

Comments
 (0)