Skip to content

Commit 2d98452

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 1e6c9e1 commit 2d98452

File tree

6 files changed

+243
-0
lines changed

6 files changed

+243
-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: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
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> prepareZulipWidget(
24+
WidgetTester tester,
25+
SubmessageData? submessageContent, {
26+
Iterable<User>? users,
27+
Iterable<(User, int)> voterIdxPairs = const [],
28+
}) async {
29+
addTearDown(testBinding.reset);
30+
await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot());
31+
store = await testBinding.globalStore.perAccount(eg.selfAccount.id);
32+
await store.addUsers(users ?? [eg.selfUser, eg.otherUser]);
33+
34+
final message = eg.streamMessage(sender: eg.selfUser);
35+
// Because the Message.toJson does not support dumping submessages,
36+
// we need add the submessage to the JSON object directly.
37+
(store.connection as FakeApiConnection).prepare(
38+
json: newestResult(foundOldest: true, messages: []).toJson()
39+
..['messages'] = [{
40+
...message.toJson(),
41+
'submessages': [eg.submessage(content: submessageContent).toJson()],
42+
}]);
43+
await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id,
44+
child: MessageListPage(initNarrow: TopicNarrow.ofMessage(message))));
45+
await tester.pumpAndSettle();
46+
47+
for (final (voter, idx) in voterIdxPairs) {
48+
await store.handleEvent(eg.submessageEvent(message.id, voter.userId,
49+
content: PollVoteEventSubmessage(
50+
key: PollEventSubmessage.optionKey(senderId: null, idx: idx),
51+
op: PollVoteOp.add)));
52+
}
53+
await tester.pump();
54+
}
55+
56+
Finder findInPoll(Finder matching) =>
57+
find.descendant(of: find.byType(PollWidget), matching: matching);
58+
59+
Finder findTextAtRow(String text, int index) =>
60+
find.descendant(
61+
of: findInPoll(find.byType(Row)).at(index), matching: find.text(text));
62+
63+
testWidgets('smoke', (tester) async {
64+
await prepareZulipWidget(tester,
65+
eg.pollWidgetData(question: 'favorite letter', options: ['A', 'B', 'C']),
66+
voterIdxPairs: [
67+
(eg.selfUser, 0),
68+
(eg.selfUser, 1),
69+
(eg.otherUser, 1),
70+
]);
71+
72+
check(findInPoll(find.text('favorite letter')).evaluate()).single;
73+
74+
check(findTextAtRow('A', 0).evaluate()).single;
75+
check(findTextAtRow('1', 0).evaluate()).single;
76+
check(findTextAtRow('(${eg.selfUser.fullName})', 0).evaluate()).single;
77+
78+
check(findTextAtRow('B', 1).evaluate()).single;
79+
check(findTextAtRow('2', 1).evaluate()).single;
80+
check(findTextAtRow('(${eg.selfUser.fullName}, ${eg.otherUser.fullName})', 1).evaluate()).single;
81+
82+
check(findTextAtRow('C', 2).evaluate()).single;
83+
check(findTextAtRow('0', 2).evaluate()).single;
84+
});
85+
86+
final pollWidgetData = eg.pollWidgetData(question: 'poll', options: ['A', 'B']);
87+
88+
testWidgets('a lot of voters', (tester) async {
89+
final users = List.generate(100, (i) => eg.user(fullName: 'user#$i'));
90+
await prepareZulipWidget(tester, pollWidgetData,
91+
users: users, voterIdxPairs: users.map((user) => (user, 0)));
92+
93+
final allUserNames = '(${users.map((user) => user.fullName).join(', ')})';
94+
check(findTextAtRow(allUserNames, 0).evaluate()).single;
95+
check(findTextAtRow('100', 0).evaluate()).single;
96+
});
97+
98+
testWidgets('show unknown voter', (tester) async {
99+
await prepareZulipWidget(tester, pollWidgetData,
100+
users: [eg.selfUser], voterIdxPairs: [(eg.thirdUser, 1)]);
101+
check(findInPoll(find.text('((unknown user))')).evaluate()).single;
102+
});
103+
104+
testWidgets('poll title missing', (tester) async {
105+
await prepareZulipWidget(tester, eg.pollWidgetData(
106+
question: '', options: ['A']));
107+
check(findInPoll(find.text('No question.')).evaluate()).single;
108+
});
109+
110+
testWidgets('poll options missing', (tester) async {
111+
await prepareZulipWidget(tester, eg.pollWidgetData(
112+
question: 'title', options: []));
113+
check(findInPoll(find.text('This poll has no options yet.')).evaluate()).single;
114+
});
115+
}

0 commit comments

Comments
 (0)