diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index b1775daf17..f592767ed7 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -3,6 +3,8 @@ 🐞 Fixed - Fixed `StreamMessageInput` polling the audio recorder ten times a second while no recording is in progress, including when voice recording is disabled. +- Fixed `StreamPollOptionVotesDialog` listing votes for other options and answers. +- Fixed `StreamPollCommentsDialog` listing votes for poll options as comments. ## 9.31.0 diff --git a/packages/stream_chat_flutter/lib/src/poll/stream_poll_comments_dialog.dart b/packages/stream_chat_flutter/lib/src/poll/stream_poll_comments_dialog.dart index eb6ba6db92..8257980110 100644 --- a/packages/stream_chat_flutter/lib/src/poll/stream_poll_comments_dialog.dart +++ b/packages/stream_chat_flutter/lib/src/poll/stream_poll_comments_dialog.dart @@ -100,10 +100,9 @@ class _StreamPollCommentsDialogState extends State { } void _initializeController() { - _controller = StreamPollVoteListController( + _controller = StreamPollVoteListController.forAnswers( pollId: widget.poll.id, channel: StreamChannel.of(context).channel, - filter: Filter.equal('is_answer', true), ); } diff --git a/packages/stream_chat_flutter/lib/src/poll/stream_poll_option_votes_dialog.dart b/packages/stream_chat_flutter/lib/src/poll/stream_poll_option_votes_dialog.dart index a22e994c57..636170b2a1 100644 --- a/packages/stream_chat_flutter/lib/src/poll/stream_poll_option_votes_dialog.dart +++ b/packages/stream_chat_flutter/lib/src/poll/stream_poll_option_votes_dialog.dart @@ -91,10 +91,10 @@ class _StreamPollOptionVotesDialogState } void _initializeController() { - _controller = StreamPollVoteListController( + _controller = StreamPollVoteListController.forOption( pollId: widget.poll.id, channel: StreamChannel.of(context).channel, - filter: Filter.equal('option_id', widget.option.id!), + optionId: widget.option.id!, ); } diff --git a/packages/stream_chat_flutter/test/src/poll/stream_poll_comments_dialog_test.dart b/packages/stream_chat_flutter/test/src/poll/stream_poll_comments_dialog_test.dart new file mode 100644 index 0000000000..0a7194da43 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/poll/stream_poll_comments_dialog_test.dart @@ -0,0 +1,104 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import '../mocks.dart'; + +const _pollId = 'poll-a'; +const _pizza = PollOption(id: 'option-pizza', text: 'Pizza'); + +void main() { + setUpAll(() { + registerFallbackValue(const PaginationParams()); + }); + + testWidgets('does not list a vote cast for an option', (tester) async { + final ben = User(id: 'ben', name: 'Ben'); + final emit = await _pumpCommentsDialog(tester); + + emit( + Event( + type: EventType.pollVoteCasted, + pollVote: PollVote( + id: 'vote-ben', pollId: _pollId, optionId: _pizza.id, user: ben), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Ben'), findsNothing); + }); + + testWidgets('lists an answer cast for the poll', (tester) async { + final ben = User(id: 'ben', name: 'Ben'); + final emit = await _pumpCommentsDialog(tester); + + emit( + Event( + type: EventType.pollAnswerCasted, + pollVote: PollVote( + id: 'answer-ben', pollId: _pollId, answerText: 'Pasta!', user: ben), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Pasta!'), findsOneWidget); + }); +} + +// Pumps the comments dialog with no comments loaded, and returns a function +// that emits a channel event to the dialog. +Future _pumpCommentsDialog( + WidgetTester tester) async { + final events = StreamController.broadcast(); + addTearDown(events.close); + + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.currentUser).thenReturn(OwnUser(id: 'salvatore')); + when(() => clientState.currentUserStream) + .thenAnswer((_) => Stream.value(OwnUser(id: 'salvatore'))); + when(channel.on).thenAnswer((_) => events.stream); + when(() => channel.client).thenReturn(client); + when(() => channel.state).thenReturn(channelState); + when( + () => channel.queryPollVotes( + _pollId, + sort: any(named: 'sort'), + filter: any(named: 'filter'), + pagination: any(named: 'pagination'), + ), + ).thenAnswer( + (_) async => QueryPollVotesResponse() + ..votes = [] + ..next = null, + ); + + await tester.pumpWidget( + MaterialApp( + home: StreamChat( + client: client, + child: StreamChannel.value( + channel: channel, + child: Scaffold( + body: StreamPollCommentsDialog( + poll: Poll( + id: _pollId, + name: 'Lunch: pizza or sushi?', + options: const [_pizza]), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + return events.add; +} diff --git a/packages/stream_chat_flutter/test/src/poll/stream_poll_option_votes_dialog_test.dart b/packages/stream_chat_flutter/test/src/poll/stream_poll_option_votes_dialog_test.dart new file mode 100644 index 0000000000..3e08aab671 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/poll/stream_poll_option_votes_dialog_test.dart @@ -0,0 +1,110 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import '../mocks.dart'; + +const _pollId = 'poll-a'; +const _pizza = PollOption(id: 'option-pizza', text: 'Pizza'); +const _sushi = PollOption(id: 'option-sushi', text: 'Sushi'); + +void main() { + setUpAll(() { + registerFallbackValue(const PaginationParams()); + }); + + testWidgets('does not list a vote cast for another option', (tester) async { + final ben = User(id: 'ben', name: 'Ben'); + final emit = await _pumpOptionVotesDialog(tester, option: _pizza); + + emit(Event(type: EventType.pollVoteCasted, pollVote: _voteBy(ben, _sushi))); + await tester.pumpAndSettle(); + + expect(find.text('Ben'), findsNothing); + }); + + testWidgets('stops listing a vote changed to another option', (tester) async { + final pizzaVote = _voteBy(User(id: 'ben', name: 'Ben'), _pizza); + final emit = await _pumpOptionVotesDialog(tester, + option: _pizza, votes: [pizzaVote]); + expect(find.text('Ben'), findsOneWidget); + + emit( + Event( + type: EventType.pollVoteChanged, + pollVote: pizzaVote.copyWith(optionId: _sushi.id), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Ben'), findsNothing); + }); +} + +PollVote _voteBy(User user, PollOption option) { + return PollVote( + id: 'vote-${user.id}', pollId: _pollId, optionId: option.id, user: user); +} + +// Pumps the votes dialog for [option] with [votes] already loaded, and returns +// a function that emits a channel event to the dialog. +Future _pumpOptionVotesDialog( + WidgetTester tester, { + required PollOption option, + List votes = const [], +}) async { + final events = StreamController.broadcast(); + addTearDown(events.close); + + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.currentUser).thenReturn(OwnUser(id: 'salvatore')); + when(() => clientState.currentUserStream) + .thenAnswer((_) => Stream.value(OwnUser(id: 'salvatore'))); + when(channel.on).thenAnswer((_) => events.stream); + when(() => channel.client).thenReturn(client); + when(() => channel.state).thenReturn(channelState); + when( + () => channel.queryPollVotes( + _pollId, + sort: any(named: 'sort'), + filter: any(named: 'filter'), + pagination: any(named: 'pagination'), + ), + ).thenAnswer( + (_) async => QueryPollVotesResponse() + ..votes = votes + ..next = null, + ); + + await tester.pumpWidget( + MaterialApp( + home: StreamChat( + client: client, + child: StreamChannel.value( + channel: channel, + child: Scaffold( + body: StreamPollOptionVotesDialog( + poll: Poll( + id: _pollId, + name: 'Lunch: pizza or sushi?', + options: const [_pizza, _sushi]), + option: option, + pollVotesCount: null, + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + return events.add; +} diff --git a/packages/stream_chat_flutter_core/CHANGELOG.md b/packages/stream_chat_flutter_core/CHANGELOG.md index beee803925..680a4b73ab 100644 --- a/packages/stream_chat_flutter_core/CHANGELOG.md +++ b/packages/stream_chat_flutter_core/CHANGELOG.md @@ -1,5 +1,9 @@ ## Upcoming +✅ Added + +- Added `StreamPollVoteListController.forOption` and `StreamPollVoteListController.forAnswers`, which list only a poll's votes for one option or only its answers. + 🐞 Fixed - Fixed `StreamDraftListController.deleteDraft` removing the wrong draft. diff --git a/packages/stream_chat_flutter_core/lib/src/stream_poll_vote_list_controller.dart b/packages/stream_chat_flutter_core/lib/src/stream_poll_vote_list_controller.dart index a01e445846..bf59f13074 100644 --- a/packages/stream_chat_flutter_core/lib/src/stream_poll_vote_list_controller.dart +++ b/packages/stream_chat_flutter_core/lib/src/stream_poll_vote_list_controller.dart @@ -37,6 +37,7 @@ class StreamPollVoteListController this.sort = defaultPollVoteListSort, this.limit = defaultPollVotePagedLimit, }) : _eventHandler = eventHandler ?? StreamPollVoteEventHandler(), + _scope = const _AllVotesScope(), _activeFilter = filter, _activeSort = sort, super(const PagedValue.loading()); @@ -51,9 +52,48 @@ class StreamPollVoteListController this.sort = defaultPollVoteListSort, this.limit = defaultPollVotePagedLimit, }) : _eventHandler = eventHandler ?? StreamPollVoteEventHandler(), + _scope = const _AllVotesScope(), _activeFilter = filter, _activeSort = sort; + /// Creates a Stream poll vote list controller that lists only the votes for + /// the poll option with `optionId`. + /// + /// Votes for other options and answers cast while the list is loaded are + /// ignored, and a vote changed to another option is removed. Setting [filter] + /// later changes only the query; incoming votes stay limited to this option. + StreamPollVoteListController.forOption({ + required Channel channel, + required String pollId, + required String optionId, + }) : this._scoped(_OptionVotesScope(optionId), + channel: channel, pollId: pollId); + + /// Creates a Stream poll vote list controller that lists only the answers of + /// the poll. + /// + /// Option votes cast while the list is loaded are ignored. Setting [filter] + /// later changes only the query; incoming votes stay limited to answers. + StreamPollVoteListController.forAnswers({ + required Channel channel, + required String pollId, + }) : this._scoped(const _AnswersScope(), channel: channel, pollId: pollId); + + // Creates a controller that queries and keeps only the votes in [scope], + // with the default sort, page limit and [StreamPollVoteEventHandler]. + StreamPollVoteListController._scoped( + _PollVoteListScope scope, { + required this.channel, + required this.pollId, + }) : _eventHandler = StreamPollVoteEventHandler(), + _scope = scope, + filter = scope.filter, + sort = defaultPollVoteListSort, + _activeFilter = scope.filter, + _activeSort = defaultPollVoteListSort, + limit = defaultPollVotePagedLimit, + super(const PagedValue.loading()); + /// The channel to use for the poll votes list. final Channel channel; @@ -63,6 +103,9 @@ class StreamPollVoteListController /// The poll vote event handlers to use for the poll votes list. final StreamPollVoteEventHandler _eventHandler; + // The votes this list is limited to. + final _PollVoteListScope _scope; + /// The query filters to use. /// /// You can query on any of the custom fields you've defined on the @@ -274,13 +317,17 @@ mixin class StreamPollVoteEventHandler { /// /// This event is fired when a new poll vote is casted in a poll. /// - /// By default, this adds the poll vote and moves it to the top of list. + /// By default, this adds or updates the poll vote. A controller created with + /// [StreamPollVoteListController.forOption] or + /// [StreamPollVoteListController.forAnswers] ignores votes for other options + /// or votes that aren't answers, respectively. void onPollVoteCasted( Event event, StreamPollVoteListController controller, ) { final pollVote = event.pollVote; if (pollVote == null) return; + if (!controller._scope.includes(pollVote)) return; final updatedPollVotes = { for (final vote in controller.currentItems) vote.id!: vote, @@ -294,7 +341,10 @@ mixin class StreamPollVoteEventHandler { /// /// This event is fired when a poll vote is changed in a poll. /// - /// By default, this updates the poll vote with the event poll vote. + /// By default, this adds or updates the poll vote. A controller created with + /// [StreamPollVoteListController.forOption] or + /// [StreamPollVoteListController.forAnswers] instead removes a vote that is + /// no longer for that option or isn't an answer, respectively. void onPollVoteChanged( Event event, StreamPollVoteListController controller, @@ -302,6 +352,11 @@ mixin class StreamPollVoteEventHandler { final pollVote = event.pollVote; if (pollVote == null) return; + if (!controller._scope.includes(pollVote)) { + _removePollVote(pollVote, controller); + return; + } + final updatedPollVotes = { for (final vote in controller.currentItems) vote.id!: vote, // Update the poll vote if it exists otherwise add it. @@ -324,10 +379,63 @@ mixin class StreamPollVoteEventHandler { final pollVote = event.pollVote; if (pollVote == null) return; - final ownVotesAndAnswers = { - for (final vote in controller.currentItems) vote.id!: vote, - }..remove(pollVote.id); + _removePollVote(pollVote, controller); + } + + // Removes [pollVote] from the list of [controller], if it's listed. + void _removePollVote( + PollVote pollVote, StreamPollVoteListController controller) { + final currentPollVotes = controller.currentItems; + if (!currentPollVotes.any((vote) => vote.id == pollVote.id)) return; - controller.pollVotes = [...ownVotesAndAnswers.values]; + controller.pollVotes = [ + ...currentPollVotes.where((vote) => vote.id != pollVote.id) + ]; } } + +// The votes a [StreamPollVoteListController] is limited to. +sealed class _PollVoteListScope { + const _PollVoteListScope(); + + // The query filter matching the votes in this scope, or null for every vote. + Filter? get filter; + + // Whether [vote] belongs in a list with this scope. + bool includes(PollVote vote); +} + +// Every vote of the poll, including answers. +final class _AllVotesScope extends _PollVoteListScope { + const _AllVotesScope(); + + @override + Filter? get filter => null; + + @override + bool includes(PollVote vote) => true; +} + +// The votes for a single poll option. +final class _OptionVotesScope extends _PollVoteListScope { + const _OptionVotesScope(this.optionId); + + final String optionId; + + @override + Filter get filter => Filter.equal('option_id', optionId); + + @override + bool includes(PollVote vote) => vote.optionId == optionId; +} + +// The answers of the poll. +final class _AnswersScope extends _PollVoteListScope { + const _AnswersScope(); + + @override + Filter get filter => Filter.equal('is_answer', true); + + @override + bool includes(PollVote vote) => vote.isAnswer; +} diff --git a/packages/stream_chat_flutter_core/test/stream_poll_vote_list_controller_test.dart b/packages/stream_chat_flutter_core/test/stream_poll_vote_list_controller_test.dart index 010f61996f..463af2d15c 100644 --- a/packages/stream_chat_flutter_core/test/stream_poll_vote_list_controller_test.dart +++ b/packages/stream_chat_flutter_core/test/stream_poll_vote_list_controller_test.dart @@ -9,6 +9,8 @@ import 'mocks.dart'; const _pollId = 'poll-a'; const _otherPollId = 'poll-b'; +const _pizza = 'option-pizza'; +const _sushi = 'option-sushi'; void main() { setUpAll(() { @@ -107,16 +109,174 @@ void main() { expect(controller.currentItems, isEmpty); }); + + group('when the list is scoped to an option', () { + test('queries only the votes for the scoped option', () async { + final filter = await _queriedFilter( + (channel) => StreamPollVoteListController.forOption( + channel: channel, pollId: _pollId, optionId: _pizza), + ); + + expect(filter, Filter.equal('option_id', _pizza)); + }); + + test('a vote cast for another option leaves the list unchanged', () async { + final pizzaVote = + PollVote(id: 'vote-1', pollId: _pollId, optionId: _pizza); + final (:controller, :events) = + await _loadedOptionController(_pizza, votes: [pizzaVote]); + + events.add( + Event( + type: EventType.pollVoteCasted, + pollVote: PollVote(id: 'vote-2', pollId: _pollId, optionId: _sushi), + ), + ); + await pumpEventQueue(); + + expect(controller.currentItems, [pizzaVote]); + }); + + test('a vote cast for the scoped option is added', () async { + final (:controller, :events) = await _loadedOptionController(_pizza); + final pizzaVote = + PollVote(id: 'vote-1', pollId: _pollId, optionId: _pizza); + + events.add(Event(type: EventType.pollVoteCasted, pollVote: pizzaVote)); + await pumpEventQueue(); + + expect(controller.currentItems, [pizzaVote]); + }); + + test('an answer cast leaves the list unchanged', () async { + final (:controller, :events) = await _loadedOptionController(_pizza); + + events.add( + Event( + type: EventType.pollAnswerCasted, + pollVote: + PollVote(id: 'answer-1', pollId: _pollId, answerText: 'Pasta!'), + ), + ); + await pumpEventQueue(); + + expect(controller.currentItems, isEmpty); + }); + + test('a vote changed away from the scoped option is removed', () async { + final pizzaVote = + PollVote(id: 'vote-1', pollId: _pollId, optionId: _pizza); + final (:controller, :events) = + await _loadedOptionController(_pizza, votes: [pizzaVote]); + + events.add( + Event( + type: EventType.pollVoteChanged, + pollVote: pizzaVote.copyWith(optionId: _sushi), + ), + ); + await pumpEventQueue(); + + expect(controller.currentItems, isEmpty); + }); + + test('a vote changed to the scoped option is added', () async { + final (:controller, :events) = await _loadedOptionController(_pizza); + final pizzaVote = + PollVote(id: 'vote-1', pollId: _pollId, optionId: _pizza); + + events.add(Event(type: EventType.pollVoteChanged, pollVote: pizzaVote)); + await pumpEventQueue(); + + expect(controller.currentItems, [pizzaVote]); + }); + }); + + group('when the list is scoped to answers', () { + test('queries only the answers', () async { + final filter = await _queriedFilter( + (channel) => StreamPollVoteListController.forAnswers( + channel: channel, pollId: _pollId), + ); + + expect(filter, Filter.equal('is_answer', true)); + }); + + test('a vote cast for an option leaves the list unchanged', () async { + final (:controller, :events) = await _loadedAnswersController(); + + events.add( + Event( + type: EventType.pollVoteCasted, + pollVote: PollVote(id: 'vote-1', pollId: _pollId, optionId: _pizza), + ), + ); + await pumpEventQueue(); + + expect(controller.currentItems, isEmpty); + }); + + test('an answer cast is added', () async { + final (:controller, :events) = await _loadedAnswersController(); + final answer = + PollVote(id: 'answer-1', pollId: _pollId, answerText: 'Pasta!'); + + events.add(Event(type: EventType.pollAnswerCasted, pollVote: answer)); + await pumpEventQueue(); + + expect(controller.currentItems, [answer]); + }); + + test('a changed option vote leaves the list unchanged', () async { + final (:controller, :events) = await _loadedAnswersController(); + + events.add( + Event( + type: EventType.pollVoteChanged, + pollVote: PollVote(id: 'vote-1', pollId: _pollId, optionId: _pizza), + ), + ); + await pumpEventQueue(); + + expect(controller.currentItems, isEmpty); + }); + }); + + test('a removed vote is removed from a whole-poll list', () async { + final pizzaVote = PollVote(id: 'vote-1', pollId: _pollId, optionId: _pizza); + final (:controller, :events) = await _loadedController(votes: [pizzaVote]); + + events.add(Event(type: EventType.pollVoteRemoved, pollVote: pizzaVote)); + await pumpEventQueue(); + + expect(controller.currentItems, isEmpty); + }); + + test('a vote changed to another option stays in a whole-poll list, updated', + () async { + final pizzaVote = PollVote(id: 'vote-1', pollId: _pollId, optionId: _pizza); + final (:controller, :events) = await _loadedController(votes: [pizzaVote]); + final sushiVote = pizzaVote.copyWith(optionId: _sushi); + + events.add(Event(type: EventType.pollVoteChanged, pollVote: sushiVote)); + await pumpEventQueue(); + + expect(controller.currentItems, [sushiVote]); + }); } // Returns a controller for [_pollId] that has loaded [votes] and listens to // the returned channel events. +// +// The controller is built by [create], or with the default constructor when +// [create] is omitted. Future< ({ StreamPollVoteListController controller, StreamController events })> _loadedController({ List votes = const [], + StreamPollVoteListController Function(Channel channel)? create, }) async { final channel = MockChannel(); final events = StreamController.broadcast(); @@ -136,10 +296,72 @@ Future< ..next = null, ); - final controller = + final controller = create?.call(channel) ?? StreamPollVoteListController(channel: channel, pollId: _pollId); addTearDown(controller.dispose); await controller.doInitialLoad(); return (controller: controller, events: events); } + +// Returns a controller scoped to [optionId] that has loaded [votes]. +Future< + ({ + StreamPollVoteListController controller, + StreamController events + })> _loadedOptionController( + String optionId, { + List votes = const [], +}) { + return _loadedController( + votes: votes, + create: (channel) => StreamPollVoteListController.forOption( + channel: channel, + pollId: _pollId, + optionId: optionId, + ), + ); +} + +// Returns a controller scoped to the poll's answers that has loaded nothing. +Future< + ({ + StreamPollVoteListController controller, + StreamController events + })> _loadedAnswersController() { + return _loadedController( + create: (channel) => StreamPollVoteListController.forAnswers( + channel: channel, + pollId: _pollId, + ), + ); +} + +// Returns the filter the controller built by [create] queries its first page +// with. +Future _queriedFilter( + StreamPollVoteListController Function(Channel channel) create) async { + final channel = MockChannel(); + when(channel.on).thenAnswer((_) => const Stream.empty()); + when( + () => channel.queryPollVotes( + _pollId, + sort: any(named: 'sort'), + filter: any(named: 'filter'), + pagination: any(named: 'pagination'), + ), + ).thenAnswer((_) async => QueryPollVotesResponse()..votes = []); + + final controller = create(channel); + addTearDown(controller.dispose); + await controller.doInitialLoad(); + + return verify( + () => channel.queryPollVotes( + _pollId, + sort: any(named: 'sort'), + filter: captureAny(named: 'filter'), + pagination: any(named: 'pagination'), + ), + ).captured.single as Filter?; +}