Skip to content

Commit c9c7ed0

Browse files
committed
squash! 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 9161e40 commit c9c7ed0

File tree

4 files changed

+261
-1
lines changed

4 files changed

+261
-1
lines changed

assets/l10n/app_en.arb

+8
Original file line numberDiff line numberDiff line change
@@ -541,5 +541,13 @@
541541
"messageIsMovedLabel": "MOVED",
542542
"@messageIsMovedLabel": {
543543
"description": "Label for a moved message. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)"
544+
},
545+
"pollWidgetQuestionMissing": "No question.",
546+
"@pollWidgetQuestionMissing": {
547+
"description": "Text to display for a poll when the question is missing"
548+
},
549+
"pollWidgetOptionsMissing": "This poll has no options yet.",
550+
"@pollWidgetOptionsMissing": {
551+
"description": "Text to display for a poll when it has no options"
544552
}
545553
}

lib/widgets/content.dart

+30-1
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),
@@ -235,7 +264,7 @@ class MessageContent extends StatelessWidget {
235264
style: ContentTheme.of(context).textStylePlainParagraph,
236265
child: switch (content) {
237266
ZulipContent() => BlockContentList(nodes: content.nodes),
238-
PollContent() => throw UnimplementedError(),
267+
PollContent() => PollWidget(poll: content.poll),
239268
}));
240269
}
241270
}

lib/widgets/poll.dart

+107
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
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 StatefulWidget {
10+
const PollWidget({super.key, required this.poll});
11+
12+
final Poll poll;
13+
14+
@override
15+
State<PollWidget> createState() => _PollWidgetState();
16+
}
17+
18+
class _PollWidgetState extends State<PollWidget> {
19+
@override
20+
void initState() {
21+
super.initState();
22+
widget.poll.addListener(_modelChanged);
23+
}
24+
25+
@override
26+
void dispose() {
27+
widget.poll.removeListener(_modelChanged);
28+
super.dispose();
29+
}
30+
31+
void _modelChanged() {
32+
setState(() {
33+
// The actual state lives in the [Poll] model.
34+
// This method was called because that just changed.
35+
});
36+
}
37+
38+
@override
39+
Widget build(BuildContext context) {
40+
final zulipLocalizations = ZulipLocalizations.of(context);
41+
final theme = ContentTheme.of(context);
42+
final store = PerAccountStoreWidget.of(context);
43+
44+
final textStylePollBold = const TextStyle(fontSize: 18)
45+
.merge(weightVariableTextStyle(context, wght: 600));
46+
final textStylePollNames = TextStyle(
47+
fontSize: 16, color: theme.colorPollNames);
48+
49+
Text question = (widget.poll.question.isNotEmpty)
50+
? Text(widget.poll.question, style: textStylePollBold)
51+
: Text(zulipLocalizations.pollWidgetQuestionMissing,
52+
style: textStylePollBold.copyWith(fontStyle: FontStyle.italic));
53+
54+
Widget buildOptionItem(PollOption option) {
55+
// TODO(i18n): List formatting, like you can do in JavaScript:
56+
// new Intl.ListFormat('ja').format(['Chris', 'Greg', 'Alya', 'Zixuan'])
57+
// // 'Chris、Greg、Alya、Zixuan'
58+
final voterNames = option.voters
59+
.map((userId) =>
60+
store.users[userId]?.fullName ?? zulipLocalizations.unknownUserName)
61+
.join(', ');
62+
63+
return Padding(
64+
padding: const EdgeInsets.only(bottom: 5),
65+
child: Row(
66+
spacing: 5,
67+
crossAxisAlignment: CrossAxisAlignment.baseline,
68+
textBaseline: localizedTextBaseline(context),
69+
children: [
70+
ConstrainedBox(
71+
constraints: const BoxConstraints(minWidth: 25),
72+
child: Container(
73+
height: 25,
74+
padding: const EdgeInsets.symmetric(horizontal: 4),
75+
decoration: BoxDecoration(
76+
color: theme.colorPollVoteCountBackground,
77+
border: Border.all(color: theme.colorPollVoteCountBorder),
78+
borderRadius: BorderRadius.circular(3)),
79+
child: Center(
80+
child: Text(option.voters.length.toString(),
81+
textAlign: TextAlign.center,
82+
style: textStylePollBold.copyWith(
83+
color: theme.colorPollVoteCountText, fontSize: 13))))),
84+
Expanded(
85+
child: Wrap(
86+
spacing: 5,
87+
children: [
88+
Text(option.text, style: textStylePollBold.copyWith(fontSize: 16)),
89+
if (voterNames.isNotEmpty)
90+
// TODO(i18n): Localize parenthesis characters.
91+
Text('($voterNames)', style: textStylePollNames),
92+
])),
93+
]));
94+
}
95+
96+
return Column(
97+
crossAxisAlignment: CrossAxisAlignment.start,
98+
children: [
99+
Padding(padding: const EdgeInsets.only(bottom: 6), child: question),
100+
if (widget.poll.options.isEmpty)
101+
Text(zulipLocalizations.pollWidgetOptionsMissing,
102+
style: textStylePollNames.copyWith(fontStyle: FontStyle.italic)),
103+
for (final option in widget.poll.options)
104+
buildOptionItem(option),
105+
]);
106+
}
107+
}

test/widgets/poll_test.dart

+116
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import 'package:checks/checks.dart';
2+
import 'package:flutter/widgets.dart';
3+
import 'package:flutter_checks/flutter_checks.dart';
4+
import 'package:flutter_test/flutter_test.dart';
5+
import 'package:zulip/api/model/model.dart';
6+
import 'package:zulip/api/model/submessage.dart';
7+
import 'package:zulip/model/narrow.dart';
8+
import 'package:zulip/model/store.dart';
9+
import 'package:zulip/widgets/message_list.dart';
10+
import 'package:zulip/widgets/poll.dart';
11+
12+
import '../api/fake_api.dart';
13+
import '../example_data.dart' as eg;
14+
import '../model/binding.dart';
15+
import '../model/message_list_test.dart';
16+
import '../model/test_store.dart';
17+
import 'test_app.dart';
18+
19+
void main() {
20+
TestZulipBinding.ensureInitialized();
21+
22+
late PerAccountStore store;
23+
24+
Future<void> preparePollWidget(
25+
WidgetTester tester,
26+
SubmessageData? submessageContent, {
27+
Iterable<User>? users,
28+
Iterable<(User, int)> voterIdxPairs = const [],
29+
}) async {
30+
addTearDown(testBinding.reset);
31+
await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot());
32+
store = await testBinding.globalStore.perAccount(eg.selfAccount.id);
33+
await store.addUsers(users ?? [eg.selfUser, eg.otherUser]);
34+
35+
final message = eg.streamMessage(sender: eg.selfUser);
36+
// Because the Message.toJson does not support dumping submessages,
37+
// we need add the submessage to the JSON object directly.
38+
(store.connection as FakeApiConnection).prepare(
39+
json: newestResult(foundOldest: true, messages: []).toJson()
40+
..['messages'] = [{
41+
...message.toJson(),
42+
'submessages': [eg.submessage(content: submessageContent).toJson()],
43+
}]);
44+
await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id,
45+
child: MessageListPage(initNarrow: TopicNarrow.ofMessage(message))));
46+
await tester.pumpAndSettle();
47+
48+
for (final (voter, idx) in voterIdxPairs) {
49+
await store.handleEvent(eg.submessageEvent(message.id, 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) =>
58+
find.descendant(of: find.byType(PollWidget), matching: matching);
59+
60+
Finder findTextAtRow(String text, int index) =>
61+
find.descendant(
62+
of: findInPoll(find.byType(Row)).at(index), matching: find.text(text));
63+
64+
testWidgets('smoke', (tester) async {
65+
await preparePollWidget(tester,
66+
eg.pollWidgetData(question: 'favorite letter', options: ['A', 'B', 'C']),
67+
voterIdxPairs: [
68+
(eg.selfUser, 0),
69+
(eg.selfUser, 1),
70+
(eg.otherUser, 1),
71+
]);
72+
73+
check(findInPoll(find.text('favorite letter'))).findsOne();
74+
75+
check(findTextAtRow('A', 0)).findsOne();
76+
check(findTextAtRow('1', 0)).findsOne();
77+
check(findTextAtRow('(${eg.selfUser.fullName})', 0)).findsOne();
78+
79+
check(findTextAtRow('B', 1)).findsOne();
80+
check(findTextAtRow('2', 1)).findsOne();
81+
check(findTextAtRow('(${eg.selfUser.fullName}, ${eg.otherUser.fullName})', 1)).findsOne();
82+
83+
check(findTextAtRow('C', 2)).findsOne();
84+
check(findTextAtRow('0', 2)).findsOne();
85+
});
86+
87+
final pollWidgetData = eg.pollWidgetData(question: 'poll', options: ['A', 'B']);
88+
89+
testWidgets('a lot of voters', (tester) async {
90+
final users = List.generate(100, (i) => eg.user(fullName: 'user#$i'));
91+
await preparePollWidget(tester, pollWidgetData,
92+
users: users, voterIdxPairs: users.map((user) => (user, 0)));
93+
94+
final allUserNames = '(${users.map((user) => user.fullName).join(', ')})';
95+
check(findTextAtRow(allUserNames, 0)).findsOne();
96+
check(findTextAtRow('100', 0)).findsOne();
97+
});
98+
99+
testWidgets('show unknown voter', (tester) async {
100+
await preparePollWidget(tester, pollWidgetData,
101+
users: [eg.selfUser], voterIdxPairs: [(eg.thirdUser, 1)]);
102+
check(findInPoll(find.text('((unknown user))'))).findsOne();
103+
});
104+
105+
testWidgets('poll title missing', (tester) async {
106+
await preparePollWidget(tester, eg.pollWidgetData(
107+
question: '', options: ['A']));
108+
check(findInPoll(find.text('No question.'))).findsOne();
109+
});
110+
111+
testWidgets('poll options missing', (tester) async {
112+
await preparePollWidget(tester, eg.pollWidgetData(
113+
question: 'title', options: []));
114+
check(findInPoll(find.text('This poll has no options yet.'))).findsOne();
115+
});
116+
}

0 commit comments

Comments
 (0)