diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index 7cb8907827e..f9647662ef7 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -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" } } diff --git a/lib/model/content.dart b/lib/model/content.dart index d2ef30009a9..f2041d79ab7 100644 --- a/lib/model/content.dart +++ b/lib/model/content.dart @@ -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. @@ -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. /// diff --git a/lib/model/message_list.dart b/lib/model/message_list.dart index ef11ea34a89..73f15265f9b 100644 --- a/lib/model/message_list.dart +++ b/lib/model/message_list.dart @@ -135,6 +135,8 @@ mixin _MessageSequence { } ZulipMessageContent _parseMessageContent(Message message) { + final poll = message.poll; + if (poll != null) return PollContent(poll); return parseContent(message.content); } diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index ec89fc1e0f1..0f71e0ec152 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -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'; @@ -41,6 +42,10 @@ class ContentTheme extends ThemeExtension { 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(), @@ -66,6 +71,10 @@ class ContentTheme extends ThemeExtension { 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(), @@ -90,6 +99,10 @@ class ContentTheme extends ThemeExtension { 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, @@ -115,6 +128,10 @@ class ContentTheme extends ThemeExtension { 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. @@ -166,6 +183,10 @@ class ContentTheme extends ThemeExtension { Color? colorGlobalTimeBorder, Color? colorMathBlockBorder, Color? colorMessageMediaContainerBackground, + Color? colorPollNames, + Color? colorPollVoteCountBackground, + Color? colorPollVoteCountBorder, + Color? colorPollVoteCountText, Color? colorThematicBreak, TextStyle? textStylePlainParagraph, CodeBlockTextStyles? codeBlockTextStyles, @@ -181,6 +202,10 @@ class ContentTheme extends ThemeExtension { 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, @@ -203,6 +228,10 @@ class ContentTheme extends ThemeExtension { 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), @@ -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), })); } } diff --git a/lib/widgets/poll.dart b/lib/widgets/poll.dart new file mode 100644 index 00000000000..61dabe2c80b --- /dev/null +++ b/lib/widgets/poll.dart @@ -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), + ]); + } +} diff --git a/test/widgets/poll_test.dart b/test/widgets/poll_test.dart new file mode 100644 index 00000000000..656557438f4 --- /dev/null +++ b/test/widgets/poll_test.dart @@ -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 preparePollWidget( + WidgetTester tester, + SubmessageData? submessageContent, { + Iterable? 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(); + }); +}