Skip to content

Commit

Permalink
poll: Support read-only poll widget UI
Browse files Browse the repository at this point in the history
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]>
  • Loading branch information
PIG208 committed Sep 19, 2024
1 parent 0c05b42 commit 24dbd41
Show file tree
Hide file tree
Showing 6 changed files with 246 additions and 0 deletions.
8 changes: 8 additions & 0 deletions assets/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -541,5 +541,13 @@
"messageIsMovedLabel": "MOVED",
"@messageIsMovedLabel": {
"description": "Label for a moved message. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)"
},
"pollWidgetQuestionMissing": "No question.",
"@pollWidgetQuestionMissing": {
"description": "Text to display for a poll when the question is missing"
},
"pollWidgetOptionsMissing": "This poll has no options yet.",
"@pollWidgetOptionsMissing": {
"description": "Text to display for a poll when it has no options"
}
}
7 changes: 7 additions & 0 deletions lib/model/content.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart';
import 'package:html/dom.dart' as dom;
import 'package:html/parser.dart';

import '../api/model/submessage.dart';
import 'code_block.dart';

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

sealed class ZulipMessageContent {}

class PollContent implements ZulipMessageContent {
const PollContent(this.poll);

final Poll poll;
}

/// A complete parse tree for a Zulip message's content,
/// or other complete piece of Zulip HTML content.
///
Expand Down
2 changes: 2 additions & 0 deletions lib/model/message_list.dart
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,8 @@ mixin _MessageSequence {
}

ZulipMessageContent _parseMessageContent(Message message) {
final poll = message.poll;
if (poll != null) return PollContent(poll);
return parseContent(message.content);
}

Expand Down
30 changes: 30 additions & 0 deletions lib/widgets/content.dart
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import 'dialog.dart';
import 'icons.dart';
import 'lightbox.dart';
import 'message_list.dart';
import 'poll.dart';
import 'store.dart';
import 'text.dart';

Expand All @@ -41,6 +42,10 @@ class ContentTheme extends ThemeExtension<ContentTheme> {
colorGlobalTimeBorder: const HSLColor.fromAHSL(1, 0, 0, 0.8).toColor(),
colorMathBlockBorder: const HSLColor.fromAHSL(0.15, 240, 0.8, 0.5).toColor(),
colorMessageMediaContainerBackground: const Color.fromRGBO(0, 0, 0, 0.03),
colorPollNames: const HSLColor.fromAHSL(1, 0, 0, .45).toColor(),
colorPollVoteCountBackground: const HSLColor.fromAHSL(1, 0, 0, 1).toColor(),
colorPollVoteCountBorder: const HSLColor.fromAHSL(1, 156, 0.28, 0.7).toColor(),
colorPollVoteCountText: const HSLColor.fromAHSL(1, 156, 0.41, 0.4).toColor(),
colorThematicBreak: const HSLColor.fromAHSL(1, 0, 0, .87).toColor(),
textStylePlainParagraph: _plainParagraphCommon(context).copyWith(
color: const HSLColor.fromAHSL(1, 0, 0, 0.15).toColor(),
Expand All @@ -66,6 +71,10 @@ class ContentTheme extends ThemeExtension<ContentTheme> {
colorGlobalTimeBorder: const HSLColor.fromAHSL(0.4, 0, 0, 0).toColor(),
colorMathBlockBorder: const HSLColor.fromAHSL(1, 240, 0.4, 0.4).toColor(),
colorMessageMediaContainerBackground: const HSLColor.fromAHSL(0.03, 0, 0, 1).toColor(),
colorPollNames: const HSLColor.fromAHSL(1, 236, .15, .7).toColor(),
colorPollVoteCountBackground: const HSLColor.fromAHSL(0.2, 0, 0, 0).toColor(),
colorPollVoteCountBorder: const HSLColor.fromAHSL(1, 185, 0.35, 0.35).toColor(),
colorPollVoteCountText: const HSLColor.fromAHSL(1, 185, 0.35, 0.65).toColor(),
colorThematicBreak: const HSLColor.fromAHSL(1, 0, 0, .87).toColor().withValues(alpha: 0.2),
textStylePlainParagraph: _plainParagraphCommon(context).copyWith(
color: const HSLColor.fromAHSL(0.75, 0, 0, 1).toColor(),
Expand All @@ -90,6 +99,10 @@ class ContentTheme extends ThemeExtension<ContentTheme> {
required this.colorGlobalTimeBorder,
required this.colorMathBlockBorder,
required this.colorMessageMediaContainerBackground,
required this.colorPollNames,
required this.colorPollVoteCountBackground,
required this.colorPollVoteCountBorder,
required this.colorPollVoteCountText,
required this.colorThematicBreak,
required this.textStylePlainParagraph,
required this.codeBlockTextStyles,
Expand All @@ -115,6 +128,10 @@ class ContentTheme extends ThemeExtension<ContentTheme> {
final Color colorGlobalTimeBorder;
final Color colorMathBlockBorder; // TODO(#46) this won't be needed
final Color colorMessageMediaContainerBackground;
final Color colorPollNames;
final Color colorPollVoteCountBackground;
final Color colorPollVoteCountBorder;
final Color colorPollVoteCountText;
final Color colorThematicBreak;

/// The complete [TextStyle] we use for plain, unstyled paragraphs.
Expand Down Expand Up @@ -166,6 +183,10 @@ class ContentTheme extends ThemeExtension<ContentTheme> {
Color? colorGlobalTimeBorder,
Color? colorMathBlockBorder,
Color? colorMessageMediaContainerBackground,
Color? colorPollNames,
Color? colorPollVoteCountBackground,
Color? colorPollVoteCountBorder,
Color? colorPollVoteCountText,
Color? colorThematicBreak,
TextStyle? textStylePlainParagraph,
CodeBlockTextStyles? codeBlockTextStyles,
Expand All @@ -181,6 +202,10 @@ class ContentTheme extends ThemeExtension<ContentTheme> {
colorGlobalTimeBorder: colorGlobalTimeBorder ?? this.colorGlobalTimeBorder,
colorMathBlockBorder: colorMathBlockBorder ?? this.colorMathBlockBorder,
colorMessageMediaContainerBackground: colorMessageMediaContainerBackground ?? this.colorMessageMediaContainerBackground,
colorPollNames: colorPollNames ?? this.colorPollNames,
colorPollVoteCountBackground: colorPollVoteCountBackground ?? this.colorPollVoteCountBackground,
colorPollVoteCountBorder: colorPollVoteCountBorder ?? this.colorPollVoteCountBorder,
colorPollVoteCountText: colorPollVoteCountText ?? this.colorPollVoteCountText,
colorThematicBreak: colorThematicBreak ?? this.colorThematicBreak,
textStylePlainParagraph: textStylePlainParagraph ?? this.textStylePlainParagraph,
codeBlockTextStyles: codeBlockTextStyles ?? this.codeBlockTextStyles,
Expand All @@ -203,6 +228,10 @@ class ContentTheme extends ThemeExtension<ContentTheme> {
colorGlobalTimeBorder: Color.lerp(colorGlobalTimeBorder, other.colorGlobalTimeBorder, t)!,
colorMathBlockBorder: Color.lerp(colorMathBlockBorder, other.colorMathBlockBorder, t)!,
colorMessageMediaContainerBackground: Color.lerp(colorMessageMediaContainerBackground, other.colorMessageMediaContainerBackground, t)!,
colorPollNames: Color.lerp(colorPollNames, other.colorPollNames, t)!,
colorPollVoteCountBackground: Color.lerp(colorPollVoteCountBackground, other.colorPollVoteCountBackground, t)!,
colorPollVoteCountBorder: Color.lerp(colorPollVoteCountBorder, other.colorPollVoteCountBorder, t)!,
colorPollVoteCountText: Color.lerp(colorPollVoteCountText, other.colorPollVoteCountText, t)!,
colorThematicBreak: Color.lerp(colorThematicBreak, other.colorThematicBreak, t)!,
textStylePlainParagraph: TextStyle.lerp(textStylePlainParagraph, other.textStylePlainParagraph, t)!,
codeBlockTextStyles: CodeBlockTextStyles.lerp(codeBlockTextStyles, other.codeBlockTextStyles, t),
Expand Down Expand Up @@ -239,6 +268,7 @@ class MessageContent extends StatelessWidget {
style: ContentTheme.of(context).textStylePlainParagraph,
child: switch(content) {
ZulipContent() => BlockContentList(nodes: content.nodes),
PollContent() => PollWidget(poll: content.poll),
}));
}
}
Expand Down
83 changes: 83 additions & 0 deletions lib/widgets/poll.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/zulip_localizations.dart';

import '../api/model/submessage.dart';
import 'content.dart';
import 'store.dart';
import 'text.dart';

class PollWidget extends StatelessWidget {
const PollWidget({super.key, required this.poll});

final Poll poll;

@override
Widget build(BuildContext context) {
final zulipLocalizations = ZulipLocalizations.of(context);
final store = PerAccountStoreWidget.of(context);
final theme = ContentTheme.of(context);

final textStylePollBold = const TextStyle(fontSize: 18)
.merge(weightVariableTextStyle(context, wght: 600));
final textStylePollNames = TextStyle(
fontSize: 16, color: theme.colorPollNames);

Text question = (poll.question.isNotEmpty)
? Text(poll.question, style: textStylePollBold)
: Text(zulipLocalizations.pollWidgetQuestionMissing,
style: textStylePollBold.copyWith(fontStyle: FontStyle.italic));

Widget buildOptionItem(PollOption option) {
// TODO(i18n): List formatting, like you can do in JavaScript:
// new Intl.ListFormat('ja').format(['Chris', 'Greg', 'Alya', 'Zixuan'])
// // 'Chris、Greg、Alya、Zixuan'
final voterNames = option.voters
.map((userId) =>
store.users[userId]?.fullName ?? zulipLocalizations.unknownUserName)
.join(', ');

return Padding(
padding: const EdgeInsets.only(bottom: 5),
child: Row(
spacing: 5,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ConstrainedBox(
// The box can be stretched when there are more than 99 votes.
constraints: const BoxConstraints(minWidth: 25),
child: Container(
height: 25,
padding: const EdgeInsets.symmetric(horizontal: 4),
decoration: BoxDecoration(
color: theme.colorPollVoteCountBackground,
border: Border.all(color: theme.colorPollVoteCountBorder),
borderRadius: BorderRadius.circular(3)),
child: Center(
child: Text(option.voters.length.toString(),
textAlign: TextAlign.center,
style: textStylePollBold.copyWith(
color: theme.colorPollVoteCountText, fontSize: 13))))),
Expanded(
child: Wrap(
spacing: 5,
children: [
Text(option.text, style: textStylePollBold.copyWith(fontSize: 16)),
if (option.voters.isNotEmpty)
// TODO(i18n): Localize parenthesis characters.
Text('($voterNames)', style: textStylePollNames),
])),
]));
}

return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(padding: const EdgeInsets.only(bottom: 6), child: question),
if (poll.options.isEmpty)
Text(zulipLocalizations.pollWidgetOptionsMissing,
style: textStylePollNames.copyWith(fontStyle: FontStyle.italic)),
for (final option in poll.options)
buildOptionItem(option),
]);
}
}
116 changes: 116 additions & 0 deletions test/widgets/poll_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import 'package:checks/checks.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_checks/flutter_checks.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:zulip/api/model/model.dart';
import 'package:zulip/api/model/submessage.dart';
import 'package:zulip/model/narrow.dart';
import 'package:zulip/model/store.dart';
import 'package:zulip/widgets/message_list.dart';
import 'package:zulip/widgets/poll.dart';

import '../api/fake_api.dart';
import '../example_data.dart' as eg;
import '../model/binding.dart';
import '../model/message_list_test.dart';
import '../model/test_store.dart';
import 'test_app.dart';

void main() {
TestZulipBinding.ensureInitialized();

late PerAccountStore store;

Future<void> preparePollWidget(
WidgetTester tester,
SubmessageData? submessageContent, {
Iterable<User>? users,
Iterable<(User, int)> voterIdxPairs = const [],
}) async {
addTearDown(testBinding.reset);
await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot());
store = await testBinding.globalStore.perAccount(eg.selfAccount.id);
await store.addUsers(users ?? [eg.selfUser, eg.otherUser]);

final message = eg.streamMessage(sender: eg.selfUser);
// Because the Message.toJson does not support dumping submessages,
// we need add the submessage to the JSON object directly.
(store.connection as FakeApiConnection).prepare(
json: newestResult(foundOldest: true, messages: []).toJson()
..['messages'] = [{
...message.toJson(),
'submessages': [eg.submessage(content: submessageContent).toJson()],
}]);
await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id,
child: MessageListPage(initNarrow: TopicNarrow.ofMessage(message))));
await tester.pumpAndSettle();

for (final (voter, idx) in voterIdxPairs) {
await store.handleEvent(eg.submessageEvent(message.id, voter.userId,
content: PollVoteEventSubmessage(
key: PollEventSubmessage.optionKey(senderId: null, idx: idx),
op: PollVoteOp.add)));
}
await tester.pump();
}

Finder findInPoll(Finder matching) =>
find.descendant(of: find.byType(PollWidget), matching: matching);

Finder findTextAtRow(String text, int index) =>
find.descendant(
of: findInPoll(find.byType(Row)).at(index), matching: find.text(text));

testWidgets('smoke', (tester) async {
await preparePollWidget(tester,
eg.pollWidgetData(question: 'favorite letter', options: ['A', 'B', 'C']),
voterIdxPairs: [
(eg.selfUser, 0),
(eg.selfUser, 1),
(eg.otherUser, 1),
]);

check(findInPoll(find.text('favorite letter'))).findsOne();

check(findTextAtRow('A', 0)).findsOne();
check(findTextAtRow('1', 0)).findsOne();
check(findTextAtRow('(${eg.selfUser.fullName})', 0)).findsOne();

check(findTextAtRow('B', 1)).findsOne();
check(findTextAtRow('2', 1)).findsOne();
check(findTextAtRow('(${eg.selfUser.fullName}, ${eg.otherUser.fullName})', 1)).findsOne();

check(findTextAtRow('C', 2)).findsOne();
check(findTextAtRow('0', 2)).findsOne();
});

final pollWidgetData = eg.pollWidgetData(question: 'poll', options: ['A', 'B']);

testWidgets('a lot of voters', (tester) async {
final users = List.generate(100, (i) => eg.user(fullName: 'user#$i'));
await preparePollWidget(tester, pollWidgetData,
users: users, voterIdxPairs: users.map((user) => (user, 0)));

final allUserNames = '(${users.map((user) => user.fullName).join(', ')})';
check(findTextAtRow(allUserNames, 0)).findsOne();
check(findTextAtRow('100', 0)).findsOne();
});

testWidgets('show unknown voter', (tester) async {
await preparePollWidget(tester, pollWidgetData,
users: [eg.selfUser], voterIdxPairs: [(eg.thirdUser, 1)]);
check(findInPoll(find.text('((unknown user))'))).findsOne();
});

testWidgets('poll title missing', (tester) async {
await preparePollWidget(tester, eg.pollWidgetData(
question: '', options: ['A']));
check(findInPoll(find.text('No question.'))).findsOne();
});

testWidgets('poll options missing', (tester) async {
await preparePollWidget(tester, eg.pollWidgetData(
question: 'title', options: []));
check(findInPoll(find.text('This poll has no options yet.'))).findsOne();
});
}

0 comments on commit 24dbd41

Please sign in to comment.