diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index 3163cbdee8..967fb2c833 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -28,6 +28,8 @@ - Fixed `StreamMessageComposer`'s send button looking enabled on an empty message when voice recording is off, and showing the microphone instead of send for a staged poll. - Fixed the attachment picker showing an empty commands tab in channels without commands. - Fixed dialogs and bottom sheets opened below `StreamChat` keeping the old colors when the app switched between light and dark mode. +- Fixed `StreamPollOptionVotesSheet` listing votes for other options and answers. +- Fixed `StreamPollCommentsSheet` listing votes for poll options as comments. ## 10.5.0 diff --git a/packages/stream_chat_flutter/lib/src/poll/stream_poll_comments_sheet.dart b/packages/stream_chat_flutter/lib/src/poll/stream_poll_comments_sheet.dart index 938a341c48..8f5f2b37ce 100644 --- a/packages/stream_chat_flutter/lib/src/poll/stream_poll_comments_sheet.dart +++ b/packages/stream_chat_flutter/lib/src/poll/stream_poll_comments_sheet.dart @@ -118,10 +118,9 @@ class _StreamPollCommentsSheetState 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_sheet.dart b/packages/stream_chat_flutter/lib/src/poll/stream_poll_option_votes_sheet.dart index 638133408e..a19b2523b8 100644 --- a/packages/stream_chat_flutter/lib/src/poll/stream_poll_option_votes_sheet.dart +++ b/packages/stream_chat_flutter/lib/src/poll/stream_poll_option_votes_sheet.dart @@ -100,10 +100,10 @@ class _StreamPollOptionVotesSheetState extends State } 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_sheet_test.dart b/packages/stream_chat_flutter/test/src/poll/stream_poll_comments_sheet_test.dart new file mode 100644 index 0000000000..8b2bdc7362 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/poll/stream_poll_comments_sheet_test.dart @@ -0,0 +1,95 @@ +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 _pumpCommentsSheet(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 _pumpCommentsSheet(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 sheet with no comments loaded, and returns a function that emits a channel event to the sheet. +Future _pumpCommentsSheet(WidgetTester tester) async { + final events = StreamController.broadcast(); + addTearDown(events.close); + + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(eventStream: events.stream); + 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.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: StreamPollCommentsSheet( + 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_sheet_test.dart b/packages/stream_chat_flutter/test/src/poll/stream_poll_option_votes_sheet_test.dart new file mode 100644 index 0000000000..8b252143f7 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/poll/stream_poll_option_votes_sheet_test.dart @@ -0,0 +1,102 @@ +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 _pumpOptionVotesSheet(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 _pumpOptionVotesSheet(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 sheet for [option] with [votes] already loaded, and returns a function that emits a channel +// event to the sheet. +Future _pumpOptionVotesSheet( + 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(eventStream: events.stream); + 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.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: StreamPollOptionVotesSheet( + poll: Poll(id: _pollId, name: 'Lunch: pizza or sushi?', options: const [_pizza, _sushi]), + option: option, + ), + ), + ), + ), + ), + ); + 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 8b6c589728..f88ba7b602 100644 --- a/packages/stream_chat_flutter_core/CHANGELOG.md +++ b/packages/stream_chat_flutter_core/CHANGELOG.md @@ -3,6 +3,7 @@ ✅ Added - Added `StreamMessageComposerController.validator` and `canSend`, which decide whether the message being composed can be sent, and `StreamMessageComposerController.defaultValidator`. +- Added `StreamPollVoteListController.forOption` and `StreamPollVoteListController.forAnswers`, which list only a poll's votes for one option or only its answers. 🔄 Changed 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 c931ca85be..5902e43a65 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 @@ -36,6 +36,7 @@ class StreamPollVoteListController extends PagedValueNotifier this.sort = defaultPollVoteListSort, this.limit = defaultPollVotePagedLimit, }) : _eventHandler = eventHandler ?? StreamPollVoteEventHandler(), + _scope = const _AllVotesScope(), _activeFilter = filter, _activeSort = sort, super(const PagedValue.loading()); @@ -50,9 +51,47 @@ class StreamPollVoteListController extends PagedValueNotifier 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; @@ -62,6 +101,9 @@ class StreamPollVoteListController extends PagedValueNotifier /// 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 @@ -271,13 +313,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, @@ -291,7 +337,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, @@ -299,6 +348,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. @@ -321,10 +375,60 @@ 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 8e58215f64..2374bbcd2d 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(() { @@ -97,11 +99,157 @@ 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(); @@ -121,9 +269,62 @@ Future<({StreamPollVoteListController controller, StreamController events ..next = null, ); - final controller = StreamPollVoteListController(channel: channel, pollId: _pollId); + 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?; +}