diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index f89e86aa2d..b8af6e155f 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -545,5 +545,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/api/model/submessage.dart b/lib/api/model/submessage.dart index 6538793e8b..4488c96f02 100644 --- a/lib/api/model/submessage.dart +++ b/lib/api/model/submessage.dart @@ -1,5 +1,6 @@ import 'dart:convert'; +import 'package:flutter/foundation.dart'; import 'package:json_annotation/json_annotation.dart'; import '../../log.dart'; @@ -354,7 +355,7 @@ class UnknownPollEventSubmessage extends PollEventSubmessage { /// See also: /// - https://zulip.com/help/create-a-poll /// - https://github.com/zulip/zulip/blob/304d948416465c1a085122af5d752f03d6797003/web/shared/src/poll_data.ts -class Poll { +class Poll extends ChangeNotifier { /// Construct a poll from submessages. /// /// For a poll Zulip widget, the first submessage's content contains a @@ -412,6 +413,7 @@ class Poll { return; } _applyEvent(event.senderId, pollEventSubmessage); + notifyListeners(); } void _applyEvent(int senderId, PollEventSubmessage event) { diff --git a/lib/model/content.dart b/lib/model/content.dart index f717de7434..99f18efde9 100644 --- a/lib/model/content.dart +++ b/lib/model/content.dart @@ -4,6 +4,7 @@ import 'package:html/dom.dart' as dom; import 'package:html/parser.dart'; import '../api/model/model.dart'; +import '../api/model/submessage.dart'; import 'code_block.dart'; /// A node in a parse tree for Zulip message-style content. @@ -73,13 +74,26 @@ mixin UnimplementedNode on ContentNode { } } +/// A parsed, ready-to-render representation of Zulip message content. +sealed class ZulipMessageContent {} + +/// A wrapper around a mutable representation of a Zulip poll message. +/// +/// Consumers are expected to listen for [Poll]'s changes to receive +/// live-updates. +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. /// /// This is a parsed representation for an entire value of [Message.content], /// [Stream.renderedDescription], or other text from a Zulip server that comes /// in the same Zulip HTML format. -class ZulipContent extends ContentNode { +class ZulipContent extends ContentNode implements ZulipMessageContent { const ZulipContent({super.debugHtmlNode, required this.nodes}); final List nodes; diff --git a/lib/model/message.dart b/lib/model/message.dart index 756550852e..f228d6da91 100644 --- a/lib/model/message.dart +++ b/lib/model/message.dart @@ -331,10 +331,8 @@ class MessageStoreImpl with MessageStore { return; } + // Live-updates for polls should not rebuild the message lists. + // [Poll] is responsible for notifying the affected listeners. poll.handleSubmessageEvent(event); - - for (final view in _messageListViews) { - view.notifyListenersIfMessagePresent(event.messageId); - } } } diff --git a/lib/model/message_list.dart b/lib/model/message_list.dart index add55b060d..73f15265f9 100644 --- a/lib/model/message_list.dart +++ b/lib/model/message_list.dart @@ -35,7 +35,7 @@ class MessageListDateSeparatorItem extends MessageListItem { /// A message to show in the message list. class MessageListMessageItem extends MessageListItem { final Message message; - ZulipContent content; + ZulipMessageContent content; bool showSender; bool isLastInBlock; @@ -98,7 +98,7 @@ mixin _MessageSequence { /// /// This information is completely derived from [messages]. /// It exists as an optimization, to memoize the work of parsing. - final List contents = []; + final List contents = []; /// The messages and their siblings in the UI, in order. /// @@ -134,10 +134,16 @@ mixin _MessageSequence { } } + ZulipMessageContent _parseMessageContent(Message message) { + final poll = message.poll; + if (poll != null) return PollContent(poll); + return parseContent(message.content); + } + /// Update data derived from the content of the index-th message. void _reparseContent(int index) { final message = messages[index]; - final content = parseContent(message.content); + final content = _parseMessageContent(message); contents[index] = content; final itemIndex = findItemWithMessageId(message.id); @@ -154,7 +160,7 @@ mixin _MessageSequence { void _addMessage(Message message) { assert(contents.length == messages.length); messages.add(message); - contents.add(parseContent(message.content)); + contents.add(_parseMessageContent(message)); assert(contents.length == messages.length); _processMessage(messages.length - 1); } @@ -197,7 +203,7 @@ mixin _MessageSequence { /// If none of [messageIds] are found, this is a no-op. bool _removeMessagesById(Iterable messageIds) { final messagesToRemoveById = {}; - final contentToRemove = Set.identity(); + final contentToRemove = Set.identity(); for (final messageId in messageIds) { final index = _findMessageWithId(messageId); if (index == -1) continue; @@ -223,7 +229,7 @@ mixin _MessageSequence { assert(contents.length == messages.length); messages.insertAll(index, toInsert); contents.insertAll(index, toInsert.map( - (message) => parseContent(message.content))); + (message) => _parseMessageContent(message))); assert(contents.length == messages.length); _reprocessAll(); } @@ -243,7 +249,7 @@ mixin _MessageSequence { void _recompute() { assert(contents.length == messages.length); contents.clear(); - contents.addAll(messages.map((message) => parseContent(message.content))); + contents.addAll(messages.map((message) => _parseMessageContent(message))); assert(contents.length == messages.length); _reprocessAll(); } diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index 01e22bbc17..5a58b929e2 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(1, 0, 0, 0.85).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), @@ -225,14 +254,18 @@ class MessageContent extends StatelessWidget { const MessageContent({super.key, required this.message, required this.content}); final Message message; - final ZulipContent content; + final ZulipMessageContent content; @override Widget build(BuildContext context) { + final content = this.content; return InheritedMessage(message: message, child: DefaultTextStyle( style: ContentTheme.of(context).textStylePlainParagraph, - child: BlockContentList(nodes: content.nodes))); + 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 0000000000..d4c7b98374 --- /dev/null +++ b/lib/widgets/poll.dart @@ -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 createState() => _PollWidgetState(); +} + +class _PollWidgetState extends State { + @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 (option.voters.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), + ]); + } +} diff --git a/test/model/content_checks.dart b/test/model/content_checks.dart index da993c7423..a907af9df7 100644 --- a/test/model/content_checks.dart +++ b/test/model/content_checks.dart @@ -1,5 +1,7 @@ +import 'package:checks/checks.dart'; import 'package:checks/context.dart'; import 'package:flutter/foundation.dart'; +import 'package:zulip/api/model/submessage.dart'; import 'package:zulip/model/content.dart'; extension ContentNodeChecks on Subject { @@ -115,3 +117,7 @@ Iterable _findLinkNodes(Iterable nodes) { return _findLinkNodes(node.nodes); }); } + +extension PollContentChecks on Subject { + Subject get poll => has((x) => x.poll, 'poll'); +} diff --git a/test/model/message_list_test.dart b/test/model/message_list_test.dart index 983ae18645..01581ffa5e 100644 --- a/test/model/message_list_test.dart +++ b/test/model/message_list_test.dart @@ -1257,12 +1257,13 @@ void main() { model.contents[0] = const ZulipContent(nodes: [ ParagraphNode(links: null, nodes: [TextNode('something outdated')]) ]); - check(model.contents[0]).not((it) => it.equalsNode(correctContent)); + check(model.contents[0]).isA() + .not((it) => it.equalsNode(correctContent)); model.reassemble(); checkNotifiedOnce(); check(model).messages.length.equals(31); - check(model.contents[0]).equalsNode(correctContent); + check(model.contents[0]).isA().equalsNode(correctContent); }); group('stream/topic muting', () { @@ -1487,6 +1488,43 @@ void main() { }); }); + group('handle content parsing into subclasses of ZulipMessageContent', () { + test('ZulipContent', () async { + final stream = eg.stream(); + await prepare(narrow: ChannelNarrow(stream.streamId)); + await prepareMessages(foundOldest: true, messages: []); + + await store.handleEvent(MessageEvent(id: 0, + message: eg.streamMessage(stream: stream))); + // Each [checkNotifiedOnce] call ensures there's been a [checkInvariants] + // call, where the [ContentNode] gets checked. The additional checks to + // make this test explicit. + checkNotifiedOnce(); + check(model).messages.single.poll.isNull(); + check(model).contents.single.isA(); + }); + + test('PollContent', () async { + final stream = eg.stream(); + await prepare(narrow: ChannelNarrow(stream.streamId)); + await prepareMessages(foundOldest: true, messages: []); + + await store.handleEvent(MessageEvent(id: 0, message: eg.streamMessage( + stream: stream, + sender: eg.selfUser, + submessages: [ + eg.submessage(senderId: eg.selfUser.userId, + content: eg.pollWidgetData(question: 'question', options: ['A'])), + ]))); + // Each [checkNotifiedOnce] call ensures there's been a [checkInvariants] + // call, where the value of the [Poll] gets checked. The additional + // checks make this test explicit. + checkNotifiedOnce(); + check(model).messages.single.poll.isNotNull(); + check(model).contents.single.isA(); + }); + }); + test('recipient headers are maintained consistently', () async { // TODO test date separators are maintained consistently too // This tests the code that maintains the invariant that recipient headers @@ -1740,7 +1778,12 @@ void checkInvariants(MessageListView model) { check(model).contents.length.equals(model.messages.length); for (int i = 0; i < model.contents.length; i++) { - check(model.contents[i]) + final poll = model.messages[i].poll; + if (poll != null) { + check(model).contents[i].isA().poll.identicalTo(poll); + continue; + } + check(model.contents[i]).isA() .equalsNode(parseContent(model.messages[i].content)); } @@ -1790,7 +1833,7 @@ extension MessageListDateSeparatorItemChecks on Subject { Subject get message => has((x) => x.message, 'message'); - Subject get content => has((x) => x.content, 'content'); + Subject get content => has((x) => x.content, 'content'); Subject get showSender => has((x) => x.showSender, 'showSender'); Subject get isLastInBlock => has((x) => x.isLastInBlock, 'isLastInBlock'); } @@ -1799,7 +1842,7 @@ extension MessageListViewChecks on Subject { Subject get store => has((x) => x.store, 'store'); Subject get narrow => has((x) => x.narrow, 'narrow'); Subject> get messages => has((x) => x.messages, 'messages'); - Subject> get contents => has((x) => x.contents, 'contents'); + Subject> get contents => has((x) => x.contents, 'contents'); Subject> get items => has((x) => x.items, 'items'); Subject get fetched => has((x) => x.fetched, 'fetched'); Subject get haveOldest => has((x) => x.haveOldest, 'haveOldest'); diff --git a/test/model/message_test.dart b/test/model/message_test.dart index 6c0c5c4017..b1ac0f7006 100644 --- a/test/model/message_test.dart +++ b/test/model/message_test.dart @@ -604,6 +604,18 @@ void main() { Subject checkPoll(Message message) => check(store.messages[message.id]).isNotNull().poll.isNotNull(); + late int pollNotifiedCount; + + void checkPollNotified({required int count}) { + check(pollNotifiedCount).equals(count); + pollNotifiedCount = 0; + // This captures any unchecked [messageList] notifications, to verify + // that poll live-updates do not trigger broader rebuilds. + checkNotNotified(); + } + void checkPollNotNotified() => checkPollNotified(count: 0); + void checkPollNotifiedOnce() => checkPollNotified(count: 1); + group('handleSubmessageEvent', () { Future preparePollMessage({ String? question, @@ -640,6 +652,10 @@ void main() { }]); await messageList.fetchInitial(); checkNotifiedOnce(); + pollNotifiedCount = 0; + store.messages[message.id]!.poll!.addListener(() { + pollNotifiedCount++; + }); return message; } @@ -671,7 +687,7 @@ void main() { // Invalid type for question 'question': 100, }))); - checkNotifiedOnce(); + checkPollNotNotified(); checkPoll(message).question.equals('Old question'); }); @@ -680,7 +696,7 @@ void main() { final message = await preparePollMessage(question: 'Old question'); await store.handleEvent(eg.submessageEvent(message.id, eg.selfUser.userId, content: PollQuestionEventSubmessage(question: 'New question'))); - checkNotifiedOnce(); + checkPollNotifiedOnce(); checkPoll(message).question.equals('New question'); }); @@ -705,7 +721,7 @@ void main() { }) async { await store.handleEvent(eg.submessageEvent(message.id, sender.userId, content: PollNewOptionEventSubmessage(option: option, idx: idx))); - checkNotifiedOnce(); + checkPollNotifiedOnce(); } test('add option', () async { @@ -743,7 +759,7 @@ void main() { Future handleVoteEvent(String key, PollVoteOp op, User voter) async { await store.handleEvent(eg.submessageEvent(message.id, voter.userId, content: PollVoteEventSubmessage(key: key, op: op))); - checkNotifiedOnce(); + checkPollNotifiedOnce(); } test('add votes', () async { @@ -821,9 +837,11 @@ void main() { message = await preparePollMessage( options: [eg.pollOption(text: 'foo', voters: [])]); checkPoll(message).options.deepEquals([conditionPollOption('foo')]); - await handleVoteEvent( - PollEventSubmessage.optionKey(senderId: null, idx: 0), - PollVoteOp.unknown, eg.otherUser); + await store.handleEvent(eg.submessageEvent(message.id, eg.otherUser.userId, + content: PollVoteEventSubmessage( + key: PollEventSubmessage.optionKey(senderId: null, idx: 0), + op: PollVoteOp.unknown))); + checkPollNotNotified(); checkPoll(message).options.deepEquals([conditionPollOption('foo')]); }); }); diff --git a/test/widgets/poll_test.dart b/test/widgets/poll_test.dart new file mode 100644 index 0000000000..913e5a4065 --- /dev/null +++ b/test/widgets/poll_test.dart @@ -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 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]); + + 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(); + }); +}