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

Signed-off-by: Zixuan James Li <[email protected]>
  • Loading branch information
PIG208 committed Sep 20, 2024
1 parent 9f70920 commit 65f4626
Show file tree
Hide file tree
Showing 4 changed files with 261 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"
}
}
28 changes: 28 additions & 0 deletions lib/widgets/content.dart
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,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 +70,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 +98,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 +127,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 +182,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 +201,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 +227,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
116 changes: 116 additions & 0 deletions lib/widgets/poll.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
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 StatefulWidget {
const PollWidget({super.key, required this.poll});

final Poll poll;

@override
State<PollWidget> createState() => _PollWidgetState();
}

class _PollWidgetState extends State<PollWidget> {
@override
void initState() {
super.initState();
widget.poll.addListener(_modelChanged);
}

@override
void didUpdateWidget(covariant PollWidget oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.poll != oldWidget.poll) {
oldWidget.poll.removeListener(_modelChanged);
widget.poll.addListener(_modelChanged);
}
}

@override
void dispose() {
widget.poll.removeListener(_modelChanged);
super.dispose();
}

void _modelChanged() {
setState(() {
// The actual state lives in the [Poll] model.
// This method was called because that just changed.
});
}

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

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

Text question = (widget.poll.question.isNotEmpty)
? Text(widget.poll.question, style: textStyleBold)
: Text(zulipLocalizations.pollWidgetQuestionMissing,
style: textStyleBold.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.baseline,
textBaseline: localizedTextBaseline(context),
children: [
ConstrainedBox(
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: textStyleBold.copyWith(
color: theme.colorPollVoteCountText, fontSize: 13))))),
Expanded(
child: Wrap(
spacing: 5,
children: [
Text(option.text, style: textStyleBold.copyWith(fontSize: 16)),
if (voterNames.isNotEmpty)
// TODO(i18n): Localize parenthesis characters.
Text('($voterNames)', style: textStyleVoterNames),
])),
]));
}

return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(padding: const EdgeInsets.only(bottom: 6), child: question),
if (widget.poll.options.isEmpty)
Text(zulipLocalizations.pollWidgetOptionsMissing,
style: textStyleVoterNames.copyWith(fontStyle: FontStyle.italic)),
for (final option in widget.poll.options)
buildOptionItem(option),
]);
}
}
109 changes: 109 additions & 0 deletions test/widgets/poll_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
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/events.dart';
import 'package:zulip/api/model/model.dart';
import 'package:zulip/api/model/submessage.dart';
import 'package:zulip/model/store.dart';
import 'package:zulip/widgets/poll.dart';

import '../example_data.dart' as eg;
import '../model/binding.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]);

Message message = eg.streamMessage(
sender: eg.selfUser,
submessages: [eg.submessage(content: submessageContent)]);
await store.handleEvent(MessageEvent(id: 0, message: message));
await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id,
child: PollWidget(poll: message.poll!)));
await tester.pump();

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, {required 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', index: 0)).findsOne();
check(findTextAtRow('1', index: 0)).findsOne();
check(findTextAtRow('(${eg.selfUser.fullName})', index: 0)).findsOne();

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

check(findTextAtRow('C', index: 2)).findsOne();
check(findTextAtRow('0', index: 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, index: 0)).findsOne();
check(findTextAtRow('100', index: 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 65f4626

Please sign in to comment.