Skip to content

Commit 0f7e5fd

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 Signed-off-by: Zixuan James Li <[email protected]>
1 parent 9f70920 commit 0f7e5fd

File tree

4 files changed

+261
-0
lines changed

4 files changed

+261
-0
lines changed

assets/l10n/app_en.arb

Lines changed: 8 additions & 0 deletions
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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ class ContentTheme extends ThemeExtension<ContentTheme> {
4141
colorGlobalTimeBorder: const HSLColor.fromAHSL(1, 0, 0, 0.8).toColor(),
4242
colorMathBlockBorder: const HSLColor.fromAHSL(0.15, 240, 0.8, 0.5).toColor(),
4343
colorMessageMediaContainerBackground: const Color.fromRGBO(0, 0, 0, 0.03),
44+
colorPollNames: const HSLColor.fromAHSL(1, 0, 0, .45).toColor(),
45+
colorPollVoteCountBackground: const HSLColor.fromAHSL(1, 0, 0, 1).toColor(),
46+
colorPollVoteCountBorder: const HSLColor.fromAHSL(1, 156, 0.28, 0.7).toColor(),
47+
colorPollVoteCountText: const HSLColor.fromAHSL(1, 156, 0.41, 0.4).toColor(),
4448
colorThematicBreak: const HSLColor.fromAHSL(1, 0, 0, .87).toColor(),
4549
textStylePlainParagraph: _plainParagraphCommon(context).copyWith(
4650
color: const HSLColor.fromAHSL(1, 0, 0, 0.15).toColor(),
@@ -66,6 +70,10 @@ class ContentTheme extends ThemeExtension<ContentTheme> {
6670
colorGlobalTimeBorder: const HSLColor.fromAHSL(0.4, 0, 0, 0).toColor(),
6771
colorMathBlockBorder: const HSLColor.fromAHSL(1, 240, 0.4, 0.4).toColor(),
6872
colorMessageMediaContainerBackground: const HSLColor.fromAHSL(0.03, 0, 0, 1).toColor(),
73+
colorPollNames: const HSLColor.fromAHSL(1, 236, .15, .7).toColor(),
74+
colorPollVoteCountBackground: const HSLColor.fromAHSL(0.2, 0, 0, 0).toColor(),
75+
colorPollVoteCountBorder: const HSLColor.fromAHSL(1, 185, 0.35, 0.35).toColor(),
76+
colorPollVoteCountText: const HSLColor.fromAHSL(1, 185, 0.35, 0.65).toColor(),
6977
colorThematicBreak: const HSLColor.fromAHSL(1, 0, 0, .87).toColor().withValues(alpha: 0.2),
7078
textStylePlainParagraph: _plainParagraphCommon(context).copyWith(
7179
color: const HSLColor.fromAHSL(0.75, 0, 0, 1).toColor(),
@@ -90,6 +98,10 @@ class ContentTheme extends ThemeExtension<ContentTheme> {
9098
required this.colorGlobalTimeBorder,
9199
required this.colorMathBlockBorder,
92100
required this.colorMessageMediaContainerBackground,
101+
required this.colorPollNames,
102+
required this.colorPollVoteCountBackground,
103+
required this.colorPollVoteCountBorder,
104+
required this.colorPollVoteCountText,
93105
required this.colorThematicBreak,
94106
required this.textStylePlainParagraph,
95107
required this.codeBlockTextStyles,
@@ -115,6 +127,10 @@ class ContentTheme extends ThemeExtension<ContentTheme> {
115127
final Color colorGlobalTimeBorder;
116128
final Color colorMathBlockBorder; // TODO(#46) this won't be needed
117129
final Color colorMessageMediaContainerBackground;
130+
final Color colorPollNames;
131+
final Color colorPollVoteCountBackground;
132+
final Color colorPollVoteCountBorder;
133+
final Color colorPollVoteCountText;
118134
final Color colorThematicBreak;
119135

120136
/// The complete [TextStyle] we use for plain, unstyled paragraphs.
@@ -166,6 +182,10 @@ class ContentTheme extends ThemeExtension<ContentTheme> {
166182
Color? colorGlobalTimeBorder,
167183
Color? colorMathBlockBorder,
168184
Color? colorMessageMediaContainerBackground,
185+
Color? colorPollNames,
186+
Color? colorPollVoteCountBackground,
187+
Color? colorPollVoteCountBorder,
188+
Color? colorPollVoteCountText,
169189
Color? colorThematicBreak,
170190
TextStyle? textStylePlainParagraph,
171191
CodeBlockTextStyles? codeBlockTextStyles,
@@ -181,6 +201,10 @@ class ContentTheme extends ThemeExtension<ContentTheme> {
181201
colorGlobalTimeBorder: colorGlobalTimeBorder ?? this.colorGlobalTimeBorder,
182202
colorMathBlockBorder: colorMathBlockBorder ?? this.colorMathBlockBorder,
183203
colorMessageMediaContainerBackground: colorMessageMediaContainerBackground ?? this.colorMessageMediaContainerBackground,
204+
colorPollNames: colorPollNames ?? this.colorPollNames,
205+
colorPollVoteCountBackground: colorPollVoteCountBackground ?? this.colorPollVoteCountBackground,
206+
colorPollVoteCountBorder: colorPollVoteCountBorder ?? this.colorPollVoteCountBorder,
207+
colorPollVoteCountText: colorPollVoteCountText ?? this.colorPollVoteCountText,
184208
colorThematicBreak: colorThematicBreak ?? this.colorThematicBreak,
185209
textStylePlainParagraph: textStylePlainParagraph ?? this.textStylePlainParagraph,
186210
codeBlockTextStyles: codeBlockTextStyles ?? this.codeBlockTextStyles,
@@ -203,6 +227,10 @@ class ContentTheme extends ThemeExtension<ContentTheme> {
203227
colorGlobalTimeBorder: Color.lerp(colorGlobalTimeBorder, other.colorGlobalTimeBorder, t)!,
204228
colorMathBlockBorder: Color.lerp(colorMathBlockBorder, other.colorMathBlockBorder, t)!,
205229
colorMessageMediaContainerBackground: Color.lerp(colorMessageMediaContainerBackground, other.colorMessageMediaContainerBackground, t)!,
230+
colorPollNames: Color.lerp(colorPollNames, other.colorPollNames, t)!,
231+
colorPollVoteCountBackground: Color.lerp(colorPollVoteCountBackground, other.colorPollVoteCountBackground, t)!,
232+
colorPollVoteCountBorder: Color.lerp(colorPollVoteCountBorder, other.colorPollVoteCountBorder, t)!,
233+
colorPollVoteCountText: Color.lerp(colorPollVoteCountText, other.colorPollVoteCountText, t)!,
206234
colorThematicBreak: Color.lerp(colorThematicBreak, other.colorThematicBreak, t)!,
207235
textStylePlainParagraph: TextStyle.lerp(textStylePlainParagraph, other.textStylePlainParagraph, t)!,
208236
codeBlockTextStyles: CodeBlockTextStyles.lerp(codeBlockTextStyles, other.codeBlockTextStyles, t),

lib/widgets/poll.dart

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

test/widgets/poll_test.dart

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
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/events.dart';
6+
import 'package:zulip/api/model/model.dart';
7+
import 'package:zulip/api/model/submessage.dart';
8+
import 'package:zulip/model/store.dart';
9+
import 'package:zulip/widgets/poll.dart';
10+
11+
import '../example_data.dart' as eg;
12+
import '../model/binding.dart';
13+
import '../model/test_store.dart';
14+
import 'test_app.dart';
15+
16+
void main() {
17+
TestZulipBinding.ensureInitialized();
18+
19+
late PerAccountStore store;
20+
21+
Future<void> preparePollWidget(
22+
WidgetTester tester,
23+
SubmessageData? submessageContent, {
24+
Iterable<User>? users,
25+
Iterable<(User, int)> voterIdxPairs = const [],
26+
}) async {
27+
addTearDown(testBinding.reset);
28+
await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot());
29+
store = await testBinding.globalStore.perAccount(eg.selfAccount.id);
30+
await store.addUsers(users ?? [eg.selfUser, eg.otherUser]);
31+
32+
Message message = eg.streamMessage(
33+
sender: eg.selfUser,
34+
submessages: [eg.submessage(content: submessageContent)]);
35+
await store.handleEvent(MessageEvent(id: 0, message: message));
36+
await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id,
37+
child: PollWidget(poll: message.poll!)));
38+
await tester.pumpAndSettle();
39+
40+
for (final (voter, idx) in voterIdxPairs) {
41+
await store.handleEvent(eg.submessageEvent(message.id, voter.userId,
42+
content: PollVoteEventSubmessage(
43+
key: PollEventSubmessage.optionKey(senderId: null, idx: idx),
44+
op: PollVoteOp.add)));
45+
}
46+
await tester.pump();
47+
}
48+
49+
Finder findInPoll(Finder matching) =>
50+
find.descendant(of: find.byType(PollWidget), matching: matching);
51+
52+
Finder findTextAtRow(String text, {required int index}) =>
53+
find.descendant(
54+
of: findInPoll(find.byType(Row)).at(index), matching: find.text(text));
55+
56+
testWidgets('smoke', (tester) async {
57+
await preparePollWidget(tester,
58+
eg.pollWidgetData(question: 'favorite letter', options: ['A', 'B', 'C']),
59+
voterIdxPairs: [
60+
(eg.selfUser, 0),
61+
(eg.selfUser, 1),
62+
(eg.otherUser, 1),
63+
]);
64+
65+
check(findInPoll(find.text('favorite letter'))).findsOne();
66+
67+
check(findTextAtRow('A', index: 0)).findsOne();
68+
check(findTextAtRow('1', index: 0)).findsOne();
69+
check(findTextAtRow('(${eg.selfUser.fullName})', index: 0)).findsOne();
70+
71+
check(findTextAtRow('B', index: 1)).findsOne();
72+
check(findTextAtRow('2', index: 1)).findsOne();
73+
check(findTextAtRow(
74+
'(${eg.selfUser.fullName}, ${eg.otherUser.fullName})', index: 1)).findsOne();
75+
76+
check(findTextAtRow('C', index: 2)).findsOne();
77+
check(findTextAtRow('0', index: 2)).findsOne();
78+
});
79+
80+
final pollWidgetData = eg.pollWidgetData(question: 'poll', options: ['A', 'B']);
81+
82+
testWidgets('a lot of voters', (tester) async {
83+
final users = List.generate(100, (i) => eg.user(fullName: 'user#$i'));
84+
await preparePollWidget(tester, pollWidgetData,
85+
users: users, voterIdxPairs: users.map((user) => (user, 0)));
86+
87+
final allUserNames = '(${users.map((user) => user.fullName).join(', ')})';
88+
check(findTextAtRow(allUserNames, index: 0)).findsOne();
89+
check(findTextAtRow('100', index: 0)).findsOne();
90+
});
91+
92+
testWidgets('show unknown voter', (tester) async {
93+
await preparePollWidget(tester, pollWidgetData,
94+
users: [eg.selfUser], voterIdxPairs: [(eg.thirdUser, 1)]);
95+
check(findInPoll(find.text('((unknown user))'))).findsOne();
96+
});
97+
98+
testWidgets('poll title missing', (tester) async {
99+
await preparePollWidget(tester, eg.pollWidgetData(
100+
question: '', options: ['A']));
101+
check(findInPoll(find.text('No question.'))).findsOne();
102+
});
103+
104+
testWidgets('poll options missing', (tester) async {
105+
await preparePollWidget(tester, eg.pollWidgetData(
106+
question: 'title', options: []));
107+
check(findInPoll(find.text('This poll has no options yet.'))).findsOne();
108+
});
109+
}

0 commit comments

Comments
 (0)