From 0889ef7f4646c31fb42c64b9a0c1184b49f7a52d Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Wed, 14 Oct 2026 09:34:47 +0200 Subject: [PATCH 1/2] fix(ui): stop the poll option votes and comments sheets listing other votes Co-Authored-By: Claude Opus 5.5 --- packages/stream_chat_flutter/CHANGELOG.md | 2 + .../src/poll/stream_poll_comments_sheet.dart | 3 +- .../poll/stream_poll_option_votes_sheet.dart | 4 +- .../poll/stream_poll_comments_sheet_test.dart | 95 ++++++++ .../stream_poll_option_votes_sheet_test.dart | 102 +++++++++ .../stream_chat_flutter_core/CHANGELOG.md | 1 + .../src/stream_poll_vote_list_controller.dart | 116 +++++++++- ...stream_poll_vote_list_controller_test.dart | 203 +++++++++++++++++- 8 files changed, 515 insertions(+), 11 deletions(-) create mode 100644 packages/stream_chat_flutter/test/src/poll/stream_poll_comments_sheet_test.dart create mode 100644 packages/stream_chat_flutter/test/src/poll/stream_poll_option_votes_sheet_test.dart diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index f9dc4c2fa4..620ec27268 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -24,6 +24,8 @@ - Fixed `StreamMessageComposer` polling the audio recorder ten times a second while no recording is in progress, including when voice recording is disabled. - 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 `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..42bccfbb06 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.answers( 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..32ae9f6a25 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.option( 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..e1b6e04d6b 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.option` and `StreamPollVoteListController.answers`, 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..aa69588035 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`. + /// + /// Live votes for other options and answers are ignored, and a vote changed + /// to another option is removed. Setting [filter] later changes only the + /// query; live votes stay limited to this option. + StreamPollVoteListController.option({ + 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. + /// + /// Live votes for poll options are ignored. Setting [filter] later changes + /// only the query; live votes stay limited to answers. + StreamPollVoteListController.answers({ + 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.option] or + /// [StreamPollVoteListController.answers] 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.option] or + /// [StreamPollVoteListController.answers] 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..d7b1cb2a9e 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.option(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.answers(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.option( + 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.answers( + 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?; +} From 62e70146aeee21fc432c85391d53090e5aa94da1 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Wed, 14 Oct 2026 16:07:40 +0200 Subject: [PATCH 2/2] refactor(core): rename the scoped poll vote list constructors to forOption and forAnswers Co-Authored-By: Claude Opus 5.5 --- .../src/poll/stream_poll_comments_sheet.dart | 2 +- .../poll/stream_poll_option_votes_sheet.dart | 2 +- .../stream_chat_flutter_core/CHANGELOG.md | 2 +- .../src/stream_poll_vote_list_controller.dart | 22 +++++++++---------- ...stream_poll_vote_list_controller_test.dart | 8 +++---- 5 files changed, 18 insertions(+), 18 deletions(-) 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 42bccfbb06..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,7 +118,7 @@ class _StreamPollCommentsSheetState extends State { } void _initializeController() { - _controller = StreamPollVoteListController.answers( + _controller = StreamPollVoteListController.forAnswers( pollId: widget.poll.id, channel: StreamChannel.of(context).channel, ); 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 32ae9f6a25..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,7 +100,7 @@ class _StreamPollOptionVotesSheetState extends State } void _initializeController() { - _controller = StreamPollVoteListController.option( + _controller = StreamPollVoteListController.forOption( pollId: widget.poll.id, channel: StreamChannel.of(context).channel, optionId: widget.option.id!, diff --git a/packages/stream_chat_flutter_core/CHANGELOG.md b/packages/stream_chat_flutter_core/CHANGELOG.md index e1b6e04d6b..f88ba7b602 100644 --- a/packages/stream_chat_flutter_core/CHANGELOG.md +++ b/packages/stream_chat_flutter_core/CHANGELOG.md @@ -3,7 +3,7 @@ ✅ Added - Added `StreamMessageComposerController.validator` and `canSend`, which decide whether the message being composed can be sent, and `StreamMessageComposerController.defaultValidator`. -- Added `StreamPollVoteListController.option` and `StreamPollVoteListController.answers`, which list only a poll's votes for one option or only its answers. +- 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 aa69588035..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 @@ -58,10 +58,10 @@ class StreamPollVoteListController extends PagedValueNotifier /// Creates a Stream poll vote list controller that lists only the votes for /// the poll option with `optionId`. /// - /// Live votes for other options and answers are ignored, and a vote changed - /// to another option is removed. Setting [filter] later changes only the - /// query; live votes stay limited to this option. - StreamPollVoteListController.option({ + /// 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, @@ -70,9 +70,9 @@ class StreamPollVoteListController extends PagedValueNotifier /// Creates a Stream poll vote list controller that lists only the answers of /// the poll. /// - /// Live votes for poll options are ignored. Setting [filter] later changes - /// only the query; live votes stay limited to answers. - StreamPollVoteListController.answers({ + /// 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); @@ -314,8 +314,8 @@ mixin class StreamPollVoteEventHandler { /// This event is fired when a new poll vote is casted in a poll. /// /// By default, this adds or updates the poll vote. A controller created with - /// [StreamPollVoteListController.option] or - /// [StreamPollVoteListController.answers] ignores votes for other options or + /// [StreamPollVoteListController.forOption] or + /// [StreamPollVoteListController.forAnswers] ignores votes for other options or /// votes that aren't answers, respectively. void onPollVoteCasted( Event event, @@ -338,8 +338,8 @@ mixin class StreamPollVoteEventHandler { /// This event is fired when a poll vote is changed in a poll. /// /// By default, this adds or updates the poll vote. A controller created with - /// [StreamPollVoteListController.option] or - /// [StreamPollVoteListController.answers] instead removes a vote that is no + /// [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, 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 d7b1cb2a9e..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 @@ -103,7 +103,7 @@ void main() { 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.option(channel: channel, pollId: _pollId, optionId: _pizza), + (channel) => StreamPollVoteListController.forOption(channel: channel, pollId: _pollId, optionId: _pizza), ); expect(filter, Filter.equal('option_id', _pizza)); @@ -177,7 +177,7 @@ void main() { group('when the list is scoped to answers', () { test('queries only the answers', () async { final filter = await _queriedFilter( - (channel) => StreamPollVoteListController.answers(channel: channel, pollId: _pollId), + (channel) => StreamPollVoteListController.forAnswers(channel: channel, pollId: _pollId), ); expect(filter, Filter.equal('is_answer', true)); @@ -283,7 +283,7 @@ Future<({StreamPollVoteListController controller, StreamController events }) { return _loadedController( votes: votes, - create: (channel) => StreamPollVoteListController.option( + create: (channel) => StreamPollVoteListController.forOption( channel: channel, pollId: _pollId, optionId: optionId, @@ -294,7 +294,7 @@ Future<({StreamPollVoteListController controller, StreamController events // Returns a controller scoped to the poll's answers that has loaded nothing. Future<({StreamPollVoteListController controller, StreamController events})> _loadedAnswersController() { return _loadedController( - create: (channel) => StreamPollVoteListController.answers( + create: (channel) => StreamPollVoteListController.forAnswers( channel: channel, pollId: _pollId, ),