diff --git a/packages/stream_feeds/CHANGELOG.md b/packages/stream_feeds/CHANGELOG.md index 27db6e9b..a82327d5 100644 --- a/packages/stream_feeds/CHANGELOG.md +++ b/packages/stream_feeds/CHANGELOG.md @@ -1,2 +1,5 @@ +## NEXT RELEASE +- Fix for updating poll votes from web socket events. + ## 0.1.0 - Initial release of Feeds V3 SDK for Dart and Flutter. \ No newline at end of file diff --git a/packages/stream_feeds/lib/src/models.dart b/packages/stream_feeds/lib/src/models.dart index c72f60da..c55014d4 100644 --- a/packages/stream_feeds/lib/src/models.dart +++ b/packages/stream_feeds/lib/src/models.dart @@ -8,6 +8,8 @@ export 'models/feed_member_request_data.dart'; export 'models/feeds_config.dart'; export 'models/follow_data.dart'; export 'models/poll_data.dart'; +export 'models/poll_option_data.dart'; +export 'models/poll_vote_data.dart'; export 'models/push_notifications_config.dart'; export 'models/request/activity_add_comment_request.dart' show ActivityAddCommentRequest; diff --git a/packages/stream_feeds/lib/src/resolvers/poll/poll_answer_casted.dart b/packages/stream_feeds/lib/src/resolvers/poll/poll_answer_casted.dart index e086ec1e..af6e5e42 100644 --- a/packages/stream_feeds/lib/src/resolvers/poll/poll_answer_casted.dart +++ b/packages/stream_feeds/lib/src/resolvers/poll/poll_answer_casted.dart @@ -7,7 +7,7 @@ import '../../generated/api/models.dart'; core.WsEvent? pollAnswerCastedFeedEventResolver(core.WsEvent event) { if (event is PollVoteCastedFeedEvent) { final pollVote = event.pollVote; - if (pollVote.isAnswer ?? false) return null; + if (!(pollVote.isAnswer ?? false)) return null; // If the event is a PollVoteCastedFeedEvent and the pollVote indicates // an answer was casted, we can resolve it to a PollAnswerCastedFeedEvent. @@ -24,7 +24,7 @@ core.WsEvent? pollAnswerCastedFeedEventResolver(core.WsEvent event) { if (event is PollVoteChangedFeedEvent) { final pollVote = event.pollVote; - if (pollVote.isAnswer ?? false) return null; + if (!(pollVote.isAnswer ?? false)) return null; // If the event is a PollVoteChangedFeedEvent and the pollVote indicates // an answer was casted, we can resolve it to a PollAnswerCastedFeedEvent. diff --git a/packages/stream_feeds/lib/src/resolvers/poll/poll_answer_removed.dart b/packages/stream_feeds/lib/src/resolvers/poll/poll_answer_removed.dart index 9d532865..59cbc886 100644 --- a/packages/stream_feeds/lib/src/resolvers/poll/poll_answer_removed.dart +++ b/packages/stream_feeds/lib/src/resolvers/poll/poll_answer_removed.dart @@ -7,7 +7,7 @@ import '../../generated/api/models.dart'; core.WsEvent? pollAnswerRemovedFeedEventResolver(core.WsEvent event) { if (event is PollVoteRemovedFeedEvent) { final pollVote = event.pollVote; - if (pollVote.isAnswer ?? false) return null; + if (!(pollVote.isAnswer ?? false)) return null; // If the event is a PollVoteRemovedFeedEvent and the poll vote indicates an // answer was removed, we can resolve it to a PollAnswerRemovedFeedEvent. diff --git a/packages/stream_feeds/test/resolvers/poll/poll_answer_casted_test.dart b/packages/stream_feeds/test/resolvers/poll/poll_answer_casted_test.dart new file mode 100644 index 00000000..84a9f569 --- /dev/null +++ b/packages/stream_feeds/test/resolvers/poll/poll_answer_casted_test.dart @@ -0,0 +1,119 @@ +// ignore_for_file: avoid_redundant_argument_values + +import 'package:stream_feeds/src/resolvers/resolvers.dart'; +import 'package:stream_feeds/stream_feeds.dart'; +import 'package:test/test.dart'; + +void main() { + group('pollAnswerCastedFeedEventResolver PollVoteCastedFeedEvent', () { + test('resolves Answer when answer is true', () { + final event = createPollVoteCastedFeedEvent(isAnswer: true); + final resolvedEvent = pollAnswerCastedFeedEventResolver(event); + expect(resolvedEvent, isA()); + }); + + test('does not resolve Answer when answer is false', () { + final event = createPollVoteCastedFeedEvent(isAnswer: false); + final resolvedEvent = pollAnswerCastedFeedEventResolver(event); + expect(resolvedEvent, isNull); + }); + test('does not resolve Answer when answer is null', () { + final event = createPollVoteCastedFeedEvent(isAnswer: null); + final resolvedEvent = pollAnswerCastedFeedEventResolver(event); + expect(resolvedEvent, isNull); + }); + }); + group('pollAnswerCastedFeedEventResolver PollVoteChangedFeedEvent', () { + test('resolves Answer when answer is true', () { + final event = createPollVoteChangedFeedEvent(isAnswer: true); + final resolvedEvent = pollAnswerCastedFeedEventResolver(event); + expect(resolvedEvent, isA()); + }); + test('does not resolve Answer when answer is false', () { + final event = createPollVoteChangedFeedEvent(isAnswer: false); + final resolvedEvent = pollAnswerCastedFeedEventResolver(event); + expect(resolvedEvent, isNull); + }); + test('does not resolve Answer when answer is null', () { + final event = createPollVoteChangedFeedEvent(isAnswer: null); + final resolvedEvent = pollAnswerCastedFeedEventResolver(event); + expect(resolvedEvent, isNull); + }); + }); +} + +PollVoteCastedFeedEvent createPollVoteCastedFeedEvent({bool? isAnswer}) { + return PollVoteCastedFeedEvent( + createdAt: DateTime.now(), + custom: const {}, + fid: '1', + poll: PollResponseData( + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + id: '1', + allowAnswers: true, + allowUserSuggestedOptions: true, + answersCount: 1, + createdById: '1', + custom: const {}, + description: '1', + enforceUniqueVote: true, + latestAnswers: const [], + latestVotesByOption: const {}, + maxVotesAllowed: 1, + name: '1', + options: const [], + ownVotes: const [], + voteCount: 1, + voteCountsByOption: const {}, + votingVisibility: '1', + ), + type: 'poll.vote.casted', + pollVote: PollVoteResponseData( + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + id: '1', + optionId: '1', + pollId: '1', + isAnswer: isAnswer, + ), + ); +} + +PollVoteChangedFeedEvent createPollVoteChangedFeedEvent({bool? isAnswer}) { + return PollVoteChangedFeedEvent( + createdAt: DateTime.now(), + custom: const {}, + fid: '1', + poll: PollResponseData( + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + id: '1', + allowAnswers: true, + allowUserSuggestedOptions: true, + answersCount: 1, + createdById: '1', + custom: const {}, + description: '1', + enforceUniqueVote: true, + latestAnswers: const [], + latestVotesByOption: const {}, + maxVotesAllowed: 1, + name: '1', + options: const [], + ownVotes: const [], + voteCount: 1, + voteCountsByOption: const {}, + votingVisibility: '1', + ), + pollVote: PollVoteResponseData( + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + id: '1', + optionId: '1', + pollId: '1', + isAnswer: isAnswer, + ), + type: 'poll.vote.changed', + ); +} diff --git a/packages/stream_feeds/test/resolvers/poll/poll_answer_removed_test.dart b/packages/stream_feeds/test/resolvers/poll/poll_answer_removed_test.dart new file mode 100644 index 00000000..705578fd --- /dev/null +++ b/packages/stream_feeds/test/resolvers/poll/poll_answer_removed_test.dart @@ -0,0 +1,64 @@ +// ignore_for_file: avoid_redundant_argument_values + +import 'package:stream_feeds/src/resolvers/resolvers.dart'; +import 'package:stream_feeds/stream_feeds.dart'; +import 'package:test/test.dart'; + +void main() { + group('pollAnswerRemovedFeedEventResolver', () { + test('resolves Answer when answer is true', () { + final event = createPollVoteRemovedFeedEvent(isAnswer: true); + final resolvedEvent = pollAnswerRemovedFeedEventResolver(event); + expect(resolvedEvent, isA()); + }); + }); + + test('does not resolve Answer when answer is false', () { + final event = createPollVoteRemovedFeedEvent(isAnswer: false); + final resolvedEvent = pollAnswerRemovedFeedEventResolver(event); + expect(resolvedEvent, isNull); + }); + test('does not resolve Answer when answer is null', () { + final event = createPollVoteRemovedFeedEvent(isAnswer: null); + final resolvedEvent = pollAnswerRemovedFeedEventResolver(event); + expect(resolvedEvent, isNull); + }); +} + +PollVoteRemovedFeedEvent createPollVoteRemovedFeedEvent({bool? isAnswer}) { + return PollVoteRemovedFeedEvent( + createdAt: DateTime.now(), + custom: const {}, + fid: '1', + poll: PollResponseData( + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + id: '1', + allowAnswers: true, + allowUserSuggestedOptions: true, + answersCount: 1, + createdById: '1', + custom: const {}, + description: '1', + enforceUniqueVote: true, + latestAnswers: const [], + latestVotesByOption: const {}, + maxVotesAllowed: 1, + name: '1', + options: const [], + ownVotes: const [], + voteCount: 1, + voteCountsByOption: const {}, + votingVisibility: '1', + ), + pollVote: PollVoteResponseData( + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + id: '1', + optionId: '1', + pollId: '1', + isAnswer: isAnswer, + ), + type: 'poll.vote.removed', + ); +} diff --git a/sample_app/lib/screens/user_feed/feed/user_feed.dart b/sample_app/lib/screens/user_feed/feed/user_feed.dart index 5c55fc4d..cc793c9a 100644 --- a/sample_app/lib/screens/user_feed/feed/user_feed.dart +++ b/sample_app/lib/screens/user_feed/feed/user_feed.dart @@ -70,6 +70,7 @@ class UserFeed extends StatelessWidget { ), ], UserFeedItem( + feed: userFeed, data: activity, user: baseActivity.user, text: baseActivity.text ?? '', diff --git a/sample_app/lib/screens/user_feed/feed/user_feed_item.dart b/sample_app/lib/screens/user_feed/feed/user_feed_item.dart index 06205b50..42cc59a3 100644 --- a/sample_app/lib/screens/user_feed/feed/user_feed_item.dart +++ b/sample_app/lib/screens/user_feed/feed/user_feed_item.dart @@ -9,10 +9,12 @@ import '../../../widgets/action_button.dart'; import '../../../widgets/attachment_gallery/attachment_metadata.dart'; import '../../../widgets/attachments/attachments.dart'; import '../../../widgets/user_avatar.dart'; +import '../polls/show_poll/show_poll_widget.dart'; class UserFeedItem extends StatelessWidget { const UserFeedItem({ super.key, + required this.feed, required this.user, required this.text, required this.attachments, @@ -37,6 +39,7 @@ class UserFeedItem extends StatelessWidget { final VoidCallback? onBookmarkClick; final VoidCallback? onDeleteClick; final ValueChanged? onEditSave; + final Feed feed; @override Widget build(BuildContext context) { @@ -48,6 +51,7 @@ class UserFeedItem extends StatelessWidget { data: data, text: text, attachments: attachments, + feed: feed, ), const SizedBox(height: 8), Center( @@ -72,12 +76,14 @@ class _UserContent extends StatelessWidget { required this.data, required this.text, required this.attachments, + required this.feed, }); final UserData user; final ActivityData data; final String text; final List attachments; + final Feed feed; @override Widget build(BuildContext context) { @@ -102,6 +108,8 @@ class _UserContent extends StatelessWidget { ), const SizedBox(height: 8), _ActivityBody( + feed: feed, + activity: data, user: user, text: text, attachments: attachments, @@ -117,12 +125,16 @@ class _UserContent extends StatelessWidget { class _ActivityBody extends StatelessWidget { const _ActivityBody({ + required this.activity, required this.user, required this.text, required this.attachments, required this.data, + required this.feed, }); + final Feed feed; + final ActivityData activity; final UserData user; final String text; final List attachments; @@ -136,6 +148,8 @@ class _ActivityBody extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ if (text.isNotEmpty) Text(text), + if (data.poll case final poll?) + ShowPollWidget(poll: poll, activity: activity, feed: feed), if (attachments.isNotEmpty) ...[ AttachmentGrid( attachments: attachments, diff --git a/sample_app/lib/screens/user_feed/polls/create_poll/create_poll_state.dart b/sample_app/lib/screens/user_feed/polls/create_poll/create_poll_state.dart new file mode 100644 index 00000000..f15e2557 --- /dev/null +++ b/sample_app/lib/screens/user_feed/polls/create_poll/create_poll_state.dart @@ -0,0 +1,87 @@ +import 'package:flutter/widgets.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'create_poll_state.freezed.dart'; + +@freezed +@immutable +class CreatePollState with _$CreatePollState { + const CreatePollState({ + required this.allowAnswers, + required this.allowUserSuggestedOptions, + required this.description, + required this.enforceUniqueVote, + required this.id, + required this.isClosed, + required this.maxVotesAllowed, + required this.name, + required this.options, + required this.votingVisibility, + }); + + CreatePollState.empty() + : allowAnswers = false, + allowUserSuggestedOptions = false, + description = '', + enforceUniqueVote = true, + id = null, + isClosed = false, + maxVotesAllowed = null, + name = '', + options = [PollOptionInputState(text: '')], + votingVisibility = VotingVisibility.public; + + @override + final bool allowAnswers; + + @override + final bool allowUserSuggestedOptions; + + @override + final String description; + + @override + final bool enforceUniqueVote; + + @override + final String? id; + + @override + final bool isClosed; + + @override + final int? maxVotesAllowed; + + @override + final String name; + + @override + final List options; + + @override + final VotingVisibility? votingVisibility; +} + +@freezed +@immutable +class PollOptionInputState with _$PollOptionInputState { + PollOptionInputState({ + Key? key, + this.originalId, + required this.text, + }) : key = key ?? UniqueKey(); + + @override + final Key key; + + @override + final String? originalId; + + @override + final String text; +} + +enum VotingVisibility { + anonymous, + public, +} diff --git a/sample_app/lib/screens/user_feed/polls/create_poll/create_poll_state.freezed.dart b/sample_app/lib/screens/user_feed/polls/create_poll/create_poll_state.freezed.dart new file mode 100644 index 00000000..68586efa --- /dev/null +++ b/sample_app/lib/screens/user_feed/polls/create_poll/create_poll_state.freezed.dart @@ -0,0 +1,246 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'create_poll_state.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$CreatePollState { + bool get allowAnswers; + bool get allowUserSuggestedOptions; + String get description; + bool get enforceUniqueVote; + String? get id; + bool get isClosed; + int? get maxVotesAllowed; + String get name; + List get options; + VotingVisibility? get votingVisibility; + + /// Create a copy of CreatePollState + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $CreatePollStateCopyWith get copyWith => + _$CreatePollStateCopyWithImpl( + this as CreatePollState, _$identity); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is CreatePollState && + (identical(other.allowAnswers, allowAnswers) || + other.allowAnswers == allowAnswers) && + (identical(other.allowUserSuggestedOptions, + allowUserSuggestedOptions) || + other.allowUserSuggestedOptions == allowUserSuggestedOptions) && + (identical(other.description, description) || + other.description == description) && + (identical(other.enforceUniqueVote, enforceUniqueVote) || + other.enforceUniqueVote == enforceUniqueVote) && + (identical(other.id, id) || other.id == id) && + (identical(other.isClosed, isClosed) || + other.isClosed == isClosed) && + (identical(other.maxVotesAllowed, maxVotesAllowed) || + other.maxVotesAllowed == maxVotesAllowed) && + (identical(other.name, name) || other.name == name) && + const DeepCollectionEquality().equals(other.options, options) && + (identical(other.votingVisibility, votingVisibility) || + other.votingVisibility == votingVisibility)); + } + + @override + int get hashCode => Object.hash( + runtimeType, + allowAnswers, + allowUserSuggestedOptions, + description, + enforceUniqueVote, + id, + isClosed, + maxVotesAllowed, + name, + const DeepCollectionEquality().hash(options), + votingVisibility); + + @override + String toString() { + return 'CreatePollState(allowAnswers: $allowAnswers, allowUserSuggestedOptions: $allowUserSuggestedOptions, description: $description, enforceUniqueVote: $enforceUniqueVote, id: $id, isClosed: $isClosed, maxVotesAllowed: $maxVotesAllowed, name: $name, options: $options, votingVisibility: $votingVisibility)'; + } +} + +/// @nodoc +abstract mixin class $CreatePollStateCopyWith<$Res> { + factory $CreatePollStateCopyWith( + CreatePollState value, $Res Function(CreatePollState) _then) = + _$CreatePollStateCopyWithImpl; + @useResult + $Res call( + {bool allowAnswers, + bool allowUserSuggestedOptions, + String description, + bool enforceUniqueVote, + String? id, + bool isClosed, + int? maxVotesAllowed, + String name, + List options, + VotingVisibility? votingVisibility}); +} + +/// @nodoc +class _$CreatePollStateCopyWithImpl<$Res> + implements $CreatePollStateCopyWith<$Res> { + _$CreatePollStateCopyWithImpl(this._self, this._then); + + final CreatePollState _self; + final $Res Function(CreatePollState) _then; + + /// Create a copy of CreatePollState + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? allowAnswers = null, + Object? allowUserSuggestedOptions = null, + Object? description = null, + Object? enforceUniqueVote = null, + Object? id = freezed, + Object? isClosed = null, + Object? maxVotesAllowed = freezed, + Object? name = null, + Object? options = null, + Object? votingVisibility = freezed, + }) { + return _then(CreatePollState( + allowAnswers: null == allowAnswers + ? _self.allowAnswers + : allowAnswers // ignore: cast_nullable_to_non_nullable + as bool, + allowUserSuggestedOptions: null == allowUserSuggestedOptions + ? _self.allowUserSuggestedOptions + : allowUserSuggestedOptions // ignore: cast_nullable_to_non_nullable + as bool, + description: null == description + ? _self.description + : description // ignore: cast_nullable_to_non_nullable + as String, + enforceUniqueVote: null == enforceUniqueVote + ? _self.enforceUniqueVote + : enforceUniqueVote // ignore: cast_nullable_to_non_nullable + as bool, + id: freezed == id + ? _self.id + : id // ignore: cast_nullable_to_non_nullable + as String?, + isClosed: null == isClosed + ? _self.isClosed + : isClosed // ignore: cast_nullable_to_non_nullable + as bool, + maxVotesAllowed: freezed == maxVotesAllowed + ? _self.maxVotesAllowed + : maxVotesAllowed // ignore: cast_nullable_to_non_nullable + as int?, + name: null == name + ? _self.name + : name // ignore: cast_nullable_to_non_nullable + as String, + options: null == options + ? _self.options + : options // ignore: cast_nullable_to_non_nullable + as List, + votingVisibility: freezed == votingVisibility + ? _self.votingVisibility + : votingVisibility // ignore: cast_nullable_to_non_nullable + as VotingVisibility?, + )); + } +} + +/// @nodoc +mixin _$PollOptionInputState { + Key get key; + String? get originalId; + String get text; + + /// Create a copy of PollOptionInputState + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $PollOptionInputStateCopyWith get copyWith => + _$PollOptionInputStateCopyWithImpl( + this as PollOptionInputState, _$identity); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is PollOptionInputState && + (identical(other.key, key) || other.key == key) && + (identical(other.originalId, originalId) || + other.originalId == originalId) && + (identical(other.text, text) || other.text == text)); + } + + @override + int get hashCode => Object.hash(runtimeType, key, originalId, text); + + @override + String toString() { + return 'PollOptionInputState(key: $key, originalId: $originalId, text: $text)'; + } +} + +/// @nodoc +abstract mixin class $PollOptionInputStateCopyWith<$Res> { + factory $PollOptionInputStateCopyWith(PollOptionInputState value, + $Res Function(PollOptionInputState) _then) = + _$PollOptionInputStateCopyWithImpl; + @useResult + $Res call({Key? key, String? originalId, String text}); +} + +/// @nodoc +class _$PollOptionInputStateCopyWithImpl<$Res> + implements $PollOptionInputStateCopyWith<$Res> { + _$PollOptionInputStateCopyWithImpl(this._self, this._then); + + final PollOptionInputState _self; + final $Res Function(PollOptionInputState) _then; + + /// Create a copy of PollOptionInputState + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? key = freezed, + Object? originalId = freezed, + Object? text = null, + }) { + return _then(PollOptionInputState( + key: freezed == key + ? _self.key! + : key // ignore: cast_nullable_to_non_nullable + as Key?, + originalId: freezed == originalId + ? _self.originalId + : originalId // ignore: cast_nullable_to_non_nullable + as String?, + text: null == text + ? _self.text + : text // ignore: cast_nullable_to_non_nullable + as String, + )); + } +} + +// dart format on diff --git a/sample_app/lib/screens/user_feed/polls/create_poll/poll_config.dart b/sample_app/lib/screens/user_feed/polls/create_poll/poll_config.dart new file mode 100644 index 00000000..1f18f891 --- /dev/null +++ b/sample_app/lib/screens/user_feed/polls/create_poll/poll_config.dart @@ -0,0 +1,33 @@ +typedef Range = ({T? min, T? max}); + +class PollConfig { + const PollConfig({ + this.nameRange = const (min: 1, max: 80), + this.optionsRange = const (min: 1, max: 10), + this.allowDuplicateOptions = false, + this.allowedVotesRange = const (min: 2, max: 10), + }); + + /// The minimum and maximum length of the poll question. + /// if `null`, there is no limit to the length of the question. + /// + /// Defaults to `1` and `80`. + final Range? nameRange; + + /// The minimum and maximum length of the poll options. + /// if `null`, there is no limit to the length of the options. + /// + /// Defaults to `2` and `10`. + final Range? optionsRange; + + /// Whether the poll allows duplicate options. + /// + /// Defaults to `false`. + final bool allowDuplicateOptions; + + /// The minimum and maximum number of votes allowed. + /// if `null`, there is no limit to the number of votes allowed. + /// + /// Defaults to `2` and `10`. + final Range? allowedVotesRange; +} diff --git a/sample_app/lib/screens/user_feed/polls/create_poll/poll_option_reorderable_list_view.dart b/sample_app/lib/screens/user_feed/polls/create_poll/poll_option_reorderable_list_view.dart new file mode 100644 index 00000000..ccde730f --- /dev/null +++ b/sample_app/lib/screens/user_feed/polls/create_poll/poll_option_reorderable_list_view.dart @@ -0,0 +1,482 @@ +import 'dart:math' as math; +import 'dart:ui'; + +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; + +import '../../../../theme/extensions/theme_extensions.dart'; +import 'poll_config.dart'; +import 'poll_text_field.dart'; + +class _NullConst { + const _NullConst(); +} + +const _nullConst = _NullConst(); + +/// {@template pollOptionItem} +/// A data class that represents a poll option item +/// {@endtemplate} +class PollOptionItem { + /// {@macro pollOptionItem} + PollOptionItem({ + required this.key, + this.originalId, + this.text = '', + this.error, + }); + + /// The unique id of the poll option item. + final Key key; + + /// The text of the poll option item. + final String text; + + final String? originalId; + + /// Optional error message based on the validation of the poll option item. + /// + /// If the poll option item is valid, this will be `null`. + final String? error; + + /// A copy of the current [PollOptionItem] with the provided values. + PollOptionItem copyWith({ + String? text, + Object? error = _nullConst, + }) { + return PollOptionItem( + key: key, + text: text ?? this.text, + originalId: originalId, + error: error == _nullConst ? this.error : error as String?, + ); + } +} + +/// {@template pollOptionListItem} +/// A widget that represents a poll option list item. +/// {@endtemplate} +class PollOptionListItem extends StatelessWidget { + /// {@macro pollOptionListItem} + const PollOptionListItem({ + super.key, + required this.option, + this.hintText, + this.focusNode, + this.onChanged, + }); + + /// The poll option item. + final PollOptionItem option; + + /// Hint to be displayed in the poll option list item. + final String? hintText; + + /// The focus node for the text field. + final FocusNode? focusNode; + + /// Callback called when the poll option item is changed. + final ValueSetter? onChanged; + + @override + Widget build(BuildContext context) { + final fillColor = context.appColors.inputBg; + final borderRadius = BorderRadius.circular(12); + + return DecoratedBox( + decoration: BoxDecoration( + color: fillColor, + borderRadius: borderRadius, + ), + child: Row( + children: [ + Expanded( + child: StreamPollTextField( + initialValue: option.text, + hintText: hintText, + borderRadius: borderRadius, + errorText: option.error, + focusNode: focusNode, + onChanged: (text) => onChanged?.call(option.copyWith(text: text)), + ), + ), + const SizedBox( + width: 48, + height: 48, + child: Icon(Icons.drag_handle_rounded), + ), + ], + ), + ); + } +} + +/// {@template pollOptionReorderableListView} +/// A widget that represents a reorderable list view of poll options. +/// {@endtemplate} +class PollOptionReorderableListView extends StatefulWidget { + /// {@macro pollOptionReorderableListView} + const PollOptionReorderableListView({ + super.key, + this.title, + this.itemHintText, + this.allowDuplicate = false, + this.initialOptions = const [], + this.optionsRange, + this.onOptionsChanged, + }); + + /// An optional title to be displayed above the list of poll options. + final String? title; + + /// The hint text to be displayed in the poll option list item. + final String? itemHintText; + + /// Whether the poll options allow duplicates. + /// + /// If `true`, the poll options can be duplicated. + final bool allowDuplicate; + + /// The initial list of poll options. + final List initialOptions; + + /// The range of allowed options (min and max). + /// + /// If `null`, there are no limits. If only min or max is specified, + /// the other bound is unlimited. + final Range? optionsRange; + + /// Callback called when the items are updated or reordered. + final ValueSetter>? onOptionsChanged; + + @override + State createState() => + _PollOptionReorderableListViewState(); +} + +class _PollOptionReorderableListViewState + extends State { + late Map _focusNodes; + late Map _options; + + @override + void initState() { + super.initState(); + _initializeOptions(); + } + + @override + void dispose() { + _disposeOptions(); + super.dispose(); + } + + void _initializeOptions() { + _focusNodes = {}; + _options = {}; + + for (final option in widget.initialOptions) { + _options[option.key] = option; + _focusNodes[option.key] = FocusNode(); + } + + // Ensure we have at least the minimum number of options + _ensureMinimumOptions(notifyParent: true); + } + + void _ensureMinimumOptions({bool notifyParent = false}) { + // Ensure we have at least the minimum number of options + final minOptions = widget.optionsRange?.min ?? 1; + + var optionsAdded = false; + while (_options.length < minOptions) { + final option = PollOptionItem(key: UniqueKey()); + _options[option.key] = option; + _focusNodes[option.key] = FocusNode(); + optionsAdded = true; + } + + // Notify parent if we added options and it's requested + if (optionsAdded && notifyParent) { + WidgetsBinding.instance.addPostFrameCallback((_) { + widget.onOptionsChanged?.call([..._options.values]); + }); + } + } + + void _disposeOptions() { + for (final it in _focusNodes.values) { + it.dispose(); + } + _focusNodes.clear(); + _options.clear(); + } + + @override + void didUpdateWidget(covariant PollOptionReorderableListView oldWidget) { + super.didUpdateWidget(oldWidget); + // Update the options if the updated options are different from the current + // set of options. + final currOptions = [..._options.values]; + final newOptions = widget.initialOptions; + + final optionItemEquality = ListEquality( + EqualityBy((it) => (it.key, it.text)), + ); + + if (optionItemEquality.equals(currOptions, newOptions) case false) { + _disposeOptions(); + _initializeOptions(); + } + } + + Widget _proxyDecorator(Widget child, int index, Animation animation) { + return AnimatedBuilder( + animation: animation, + builder: (BuildContext context, Widget? child) { + final animValue = Curves.easeInOut.transform(animation.value); + final elevation = lerpDouble(0, 6, animValue)!; + return Material( + borderRadius: BorderRadius.circular(12), + elevation: elevation, + child: child, + ); + }, + child: child, + ); + } + + String? _validateOption(PollOptionItem option) { + // Check if the option is empty. + if (option.text.isEmpty) return 'Option cannot be empty'; + + // Check for duplicate options if duplicates are not allowed. + if (widget.allowDuplicate case false) { + if (_options.values.any((it) { + // Skip if it's the same option + if (it.key == option.key) return false; + + return it.text == option.text; + })) { + return 'This is already an option'; + } + } + + return null; + } + + void _onOptionChanged(PollOptionItem option) { + setState(() { + // Update the changed option. + _options[option.key] = option.copyWith( + error: _validateOption(option), + ); + + // Validate every other option to check for duplicates. + _options.updateAll((key, value) { + // Skip the changed option as it's already validated. + if (key == option.key) return value; + + return value.copyWith(error: _validateOption(value)); + }); + }); + + // Notify the parent widget about the change + WidgetsBinding.instance.addPostFrameCallback((_) { + widget.onOptionsChanged?.call([..._options.values]); + }); + } + + void _onOptionReorder(int oldIndex, int newIndex) { + setState(() { + final options = [..._options.values]; + + // Move the dragged option to the new index + final option = options.removeAt(oldIndex); + options.insert(newIndex, option); + + // Update the options map + _options = { + for (final option in options) option.key: option, + }; + }); + + // Notify the parent widget about the change + WidgetsBinding.instance.addPostFrameCallback((_) { + widget.onOptionsChanged?.call([..._options.values]); + }); + } + + void _onAddOptionPressed() { + // Check if we've reached the maximum number of options allowed + if (widget.optionsRange?.max case final maxOptions?) { + // Don't add more options if we've reached the limit + if (_options.length >= maxOptions) return; + } + + // Create a new option item + final option = PollOptionItem(key: UniqueKey()); + + setState(() { + _options[option.key] = option; + _focusNodes[option.key] = FocusNode(); + }); + + // Notify the parent widget about the change and request focus on the + // newly added option text field. + WidgetsBinding.instance.addPostFrameCallback((_) { + widget.onOptionsChanged?.call([..._options.values]); + _focusNodes[option.key]?.requestFocus(); + }); + } + + bool get _canAddMoreOptions { + // Don't allow adding if there's already an empty option + final hasEmptyOption = _options.values.any((it) => it.text.isEmpty); + if (hasEmptyOption) return false; + + // Check max options limit + if (widget.optionsRange?.max case final maxOptions?) { + return _options.length < maxOptions; + } + + return true; + } + + @override + Widget build(BuildContext context) { + final borderRadius = BorderRadius.circular(12); + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (widget.title case final title?) ...[ + Text(title, style: context.appTextStyles.headlineBold), + const SizedBox(height: 8), + ], + Flexible( + child: SeparatedReorderableListView( + shrinkWrap: true, + itemCount: _options.length, + physics: const NeverScrollableScrollPhysics(), + proxyDecorator: _proxyDecorator, + separatorBuilder: (_, __) => const SizedBox(height: 8), + onReorderStart: (_) => FocusScope.of(context).unfocus(), + onReorder: _onOptionReorder, + itemBuilder: (context, index) { + final option = _options.values.elementAt(index); + return PollOptionListItem( + key: option.key, + option: option, + hintText: widget.itemHintText, + focusNode: _focusNodes[option.key], + onChanged: _onOptionChanged, + ); + }, + ), + ), + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: FilledButton.tonal( + onPressed: _canAddMoreOptions ? _onAddOptionPressed : null, + style: TextButton.styleFrom( + alignment: Alignment.centerLeft, + textStyle: context.appTextStyles.body, + shape: RoundedRectangleBorder( + borderRadius: borderRadius, + ), + padding: const EdgeInsets.symmetric( + vertical: 18, + horizontal: 16, + ), + backgroundColor: context.appColors.inputBg, + foregroundColor: context.appColors.textHighEmphasis, + ), + child: const Text('Add an option'), + ), + ), + ], + ); + } +} + +class SeparatedReorderableListView extends ReorderableListView { + /// {@macro streamSeparatedReorderableListView} + SeparatedReorderableListView({ + super.key, + required IndexedWidgetBuilder itemBuilder, + required IndexedWidgetBuilder separatorBuilder, + required int itemCount, + required ReorderCallback onReorder, + super.onReorderStart, + super.onReorderEnd, + super.itemExtent, + super.prototypeItem, + super.proxyDecorator, + super.padding, + super.header, + super.scrollDirection, + super.reverse, + super.scrollController, + super.primary, + super.physics, + super.shrinkWrap, + super.anchor, + super.cacheExtent, + super.dragStartBehavior, + super.keyboardDismissBehavior, + super.restorationId, + super.clipBehavior, + }) : super.builder( + buildDefaultDragHandles: false, + itemCount: math.max(0, itemCount * 2 - 1), + itemBuilder: (BuildContext context, int index) { + final itemIndex = index ~/ 2; + if (index.isEven) { + final listItem = itemBuilder(context, itemIndex); + return ReorderableDelayedDragStartListener( + key: listItem.key, + index: index, + child: listItem, + ); + } + + final separator = separatorBuilder(context, itemIndex); + if (separator.key == null) { + return KeyedSubtree( + key: ValueKey('reorderable_separator_$itemIndex'), + child: IgnorePointer(child: separator), + ); + } + + return separator; + }, + onReorder: (int oldIndex, int newIndex) { + // Adjust the indexes due to an issue in the ReorderableListView + // which isn't going to be fixed in the near future. + // + // issue: https://github.com/flutter/flutter/issues/24786 + if (newIndex > oldIndex) { + newIndex -= 1; + } + + // Ideally should never happen as separators are wrapped in the + // IgnorePointer widget. This is just a safety check. + if (oldIndex.isOdd) return; + + // The item moved behind the top/bottom separator we should not + // reorder it. + if ((oldIndex - newIndex).abs() == 1) return; + + // Calculate the updated indexes + final updatedOldIndex = oldIndex ~/ 2; + final updatedNewIndex = oldIndex > newIndex && newIndex.isOdd + ? (newIndex + 1) ~/ 2 + : newIndex ~/ 2; + + onReorder(updatedOldIndex, updatedNewIndex); + }, + ); +} diff --git a/sample_app/lib/screens/user_feed/polls/create_poll/poll_question_text_field.dart b/sample_app/lib/screens/user_feed/polls/create_poll/poll_question_text_field.dart new file mode 100644 index 00000000..64faa1f3 --- /dev/null +++ b/sample_app/lib/screens/user_feed/polls/create_poll/poll_question_text_field.dart @@ -0,0 +1,162 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; + +import '../../../../theme/extensions/theme_extensions.dart'; +import 'poll_config.dart'; +import 'poll_text_field.dart' show StreamPollTextField; + +class _NullConst { + const _NullConst(); +} + +const _nullConst = _NullConst(); + +/// {@template pollQuestion} +/// A data class that represents a poll question. +/// {@endtemplate} +class PollQuestion { + /// {@macro pollQuestion} + PollQuestion({ + this.originalId, + this.text = '', + this.error, + }); + + /// The unique id of the poll this question belongs to. + final String? originalId; + + /// The text of the poll question. + final String text; + + /// Optional error message based on the validation of the poll question. + /// + /// If the poll question is valid, this will be `null`. + final String? error; + + /// A copy of the current [PollQuestion] with the provided values. + PollQuestion copyWith({ + String? originalId, + String? text, + Object? error = _nullConst, + }) { + return PollQuestion( + originalId: originalId ?? this.originalId, + text: text ?? this.text, + error: error == _nullConst ? this.error : error as String?, + ); + } +} + +/// {@template pollQuestionTextField} +/// A widget that represents a text field for poll question input. +/// {@endtemplate} +class PollQuestionTextField extends StatefulWidget { + /// {@macro pollQuestionTextField} + const PollQuestionTextField({ + super.key, + required this.initialQuestion, + this.title, + this.hintText, + this.questionRange = const (min: 1, max: 80), + this.onChanged, + }); + + /// An optional title to be displayed above the text field. + final String? title; + + /// The hint text to be displayed in the text field. + final String? hintText; + + /// The length constraints of the poll question. + /// + /// If `null`, there are no constraints on the length of the question. + final Range? questionRange; + + /// The poll question. + final PollQuestion initialQuestion; + + /// Callback called when the poll question is changed. + final ValueChanged? onChanged; + + @override + State createState() => _PollQuestionTextFieldState(); +} + +class _PollQuestionTextFieldState extends State { + late var _question = widget.initialQuestion; + + @override + void didUpdateWidget(covariant PollQuestionTextField oldWidget) { + super.didUpdateWidget(oldWidget); + + // Update the question if the updated initial question is different from + // the current question. + final currQuestion = _question; + final newQuestion = widget.initialQuestion; + final questionEquality = EqualityBy( + (it) => (it.originalId, it.text), + ); + + if (questionEquality.equals(currQuestion, newQuestion) case false) { + _question = newQuestion; + } + } + + String? _validateQuestion(String question) { + final range = widget.questionRange; + if (range == null) return null; + final (:min, :max) = range; + + // Check if the question is too short. + if (min != null && question.length < min) { + return 'Question must be at least $min characters long'; + } + + // Check if the question is too long. + if (max != null && question.length > max) { + return 'Question must be at most $max characters long'; + } + + return null; + } + + @override + Widget build(BuildContext context) { + final colorTheme = context.appColors; + final textTheme = context.appTextStyles; + final fillColor = colorTheme.inputBg; + final borderRadius = BorderRadius.circular(12); + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (widget.title case final title?) ...[ + Text(title, style: textTheme.bodyBold), + const SizedBox(height: 8), + ], + DecoratedBox( + decoration: BoxDecoration( + color: fillColor, + borderRadius: borderRadius, + ), + child: StreamPollTextField( + initialValue: _question.text, + hintText: widget.hintText, + fillColor: fillColor, + borderRadius: borderRadius, + errorText: _question.error, + onChanged: (text) { + _question = _question.copyWith( + text: text, + error: _validateQuestion(text), + ); + + widget.onChanged?.call(_question); + }, + ), + ), + ], + ); + } +} diff --git a/sample_app/lib/screens/user_feed/polls/create_poll/poll_switch_list_tile.dart b/sample_app/lib/screens/user_feed/polls/create_poll/poll_switch_list_tile.dart new file mode 100644 index 00000000..854f5342 --- /dev/null +++ b/sample_app/lib/screens/user_feed/polls/create_poll/poll_switch_list_tile.dart @@ -0,0 +1,238 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; + +import '../../../../theme/extensions/theme_extensions.dart'; +import 'poll_text_field.dart'; + +class _NullConst { + const _NullConst(); +} + +const _nullConst = _NullConst(); + +/// {@template pollSwitchListTile} +/// A widget that represents a switch list tile for poll input. +/// +/// The switch list tile contains a title and a switch. +/// +/// Optionally, it can contain a list of children widgets that are displayed +/// below the switch when the switch is enabled. +/// +/// see also: +/// - [PollSwitchTextField], a widget that represents a toggleable text field +/// for poll input. +/// {@endtemplate} +class PollSwitchListTile extends StatelessWidget { + /// {@macro pollSwitchListTile} + const PollSwitchListTile({ + super.key, + this.value = false, + required this.title, + this.children = const [], + this.onChanged, + }); + + /// The current value of the switch. + final bool value; + + /// The title of the switch list tile. + final String title; + + /// Optional list of children widgets to be displayed when the switch is + /// enabled. + /// + /// If `null`, no children will be displayed. + final List children; + + /// Callback called when the switch value is changed. + final ValueSetter? onChanged; + + @override + Widget build(BuildContext context) { + final textTheme = context.appTextStyles; + final colorTheme = context.appColors; + final fillColor = colorTheme.inputBg; + final borderRadius = BorderRadius.circular(12); + + final listTile = SwitchListTile( + value: value, + onChanged: onChanged, + tileColor: fillColor, + title: Text(title, style: textTheme.bodyBold), + contentPadding: const EdgeInsets.only(left: 16, right: 8), + shape: RoundedRectangleBorder(borderRadius: borderRadius), + ); + + return DecoratedBox( + decoration: BoxDecoration( + color: fillColor, + borderRadius: borderRadius, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + listTile, + if (value) ...children, + ], + ), + ); + } +} + +/// {@template pollSwitchItem} +/// A data class that represents a poll boolean item. +/// {@endtemplate} +class PollSwitchItem { + /// {@macro pollSwitchItem} + PollSwitchItem({ + this.id, + this.value = false, + this.inputValue, + this.error, + }); + + /// The unique id of the poll option item. + final String? id; + + /// The boolean value of the poll switch item. + final bool value; + + /// Optional input value linked to the poll switch item. + final T? inputValue; + + /// Optional error message based on the validation of the poll switch item + /// and its input value. + /// + /// If the poll switch item is valid, this will be `null`. + final String? error; + + /// A copy of the current [PollSwitchItem] with the provided values. + PollSwitchItem copyWith({ + String? id, + bool? value, + Object? error = _nullConst, + Object? inputValue = _nullConst, + }) { + return PollSwitchItem( + id: id ?? this.id, + value: value ?? this.value, + error: error == _nullConst ? this.error : error as String?, + inputValue: inputValue == _nullConst ? this.inputValue : inputValue as T?, + ); + } +} + +/// {@template pollSwitchTextField} +/// A widget that represents a toggleable text field for poll input. +/// +/// Generally used as one of the children of [PollSwitchListTile]. +/// {@endtemplate} +class PollSwitchTextField extends StatefulWidget { + /// {@macro pollSwitchTextField} + const PollSwitchTextField({ + super.key, + required this.item, + this.hintText, + this.keyboardType, + this.onChanged, + this.validator, + }); + + /// The current value of the switch text field. + final PollSwitchItem item; + + /// The hint text to be displayed in the text field. + final String? hintText; + + /// The keyboard type of the text field. + final TextInputType? keyboardType; + + /// Callback called when the switch text field is changed. + final ValueChanged>? onChanged; + + /// The validator function to validate the input value. + final String? Function(PollSwitchItem)? validator; + + @override + State createState() => _PollSwitchTextFieldState(); +} + +class _PollSwitchTextFieldState extends State { + late var _item = widget.item.copyWith( + error: widget.validator?.call(widget.item), + ); + + @override + void didUpdateWidget(covariant PollSwitchTextField oldWidget) { + super.didUpdateWidget(oldWidget); + // Update the item if the updated item is different from the current item. + final currItem = _item; + final newItem = widget.item; + final itemEquality = EqualityBy, (bool, int?)>( + (it) => (it.value, it.inputValue), + ); + + if (itemEquality.equals(currItem, newItem) case false) { + _item = newItem; + } + } + + void _onSwitchToggled(bool value) { + setState(() { + // Update the switch value. + _item = _item.copyWith(value: value); + // Validate the switch value. + _item = _item.copyWith(error: widget.validator?.call(_item)); + + // Notify the parent widget about the change + widget.onChanged?.call(_item); + }); + } + + void _onFieldChanged(String text) { + setState(() { + // Update the input value. + _item = _item.copyWith(inputValue: int.tryParse(text)); + // Validate the input value. + _item = _item.copyWith(error: widget.validator?.call(_item)); + + // Notify the parent widget about the change + widget.onChanged?.call(_item); + }); + } + + @override + Widget build(BuildContext context) { + final colorTheme = context.appColors; + final fillColor = colorTheme.inputBg; + final borderRadius = BorderRadius.circular(12); + + return DecoratedBox( + decoration: BoxDecoration( + color: fillColor, + borderRadius: borderRadius, + ), + child: Row( + children: [ + Expanded( + child: StreamPollTextField( + hintText: widget.hintText, + enabled: _item.value, + keyboardType: widget.keyboardType, + borderRadius: borderRadius, + errorText: _item.value ? _item.error : null, + initialValue: _item.inputValue?.toString(), + onChanged: _onFieldChanged, + ), + ), + Switch( + value: _item.value, + onChanged: _onSwitchToggled, + ), + const SizedBox(width: 8), + ], + ), + ); + } +} diff --git a/sample_app/lib/screens/user_feed/polls/create_poll/poll_text_field.dart b/sample_app/lib/screens/user_feed/polls/create_poll/poll_text_field.dart new file mode 100644 index 00000000..cc9bbc4c --- /dev/null +++ b/sample_app/lib/screens/user_feed/polls/create_poll/poll_text_field.dart @@ -0,0 +1,271 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../../../theme/extensions/theme_extensions.dart'; + +const _kTransitionDuration = Duration(milliseconds: 167); + +/// {@template streamPollTextField} +/// A widget that represents a text field for poll input. +/// {@endtemplate} +class StreamPollTextField extends StatefulWidget { + /// {@macro streamPollTextField} + const StreamPollTextField({ + super.key, + this.initialValue, + this.style, + this.enabled = true, + this.hintText, + this.fillColor, + this.errorText, + this.errorStyle, + this.contentPadding = const EdgeInsets.symmetric( + vertical: 18, + horizontal: 16, + ), + this.borderRadius, + this.focusNode, + this.keyboardType, + this.autoFocus = false, + this.onChanged, + }); + + /// The initial value of the text field. + /// + /// If `null`, the text field will be empty. + final String? initialValue; + + /// The style to use for the text field. + final TextStyle? style; + + /// Whether the text field is enabled. + final bool enabled; + + /// The hint text to be displayed in the text field. + final String? hintText; + + /// The fill color of the text field. + final Color? fillColor; + + /// The error text to be displayed below the text field. + /// + /// If `null`, no error text will be displayed. + final String? errorText; + + /// The style to use for the error text. + final TextStyle? errorStyle; + + /// The padding around the text field content. + final EdgeInsetsGeometry contentPadding; + + /// The border radius of the text field. + final BorderRadius? borderRadius; + + /// The keyboard type of the text field. + final TextInputType? keyboardType; + + /// Whether the text field should autofocus. + final bool autoFocus; + + /// The focus node of the text field. + final FocusNode? focusNode; + + /// Callback called when the text field value is changed. + final ValueChanged? onChanged; + + @override + State createState() => _StreamPollTextFieldState(); +} + +class _StreamPollTextFieldState extends State { + late final _controller = TextEditingController(text: widget.initialValue); + + @override + void didUpdateWidget(covariant StreamPollTextField oldWidget) { + super.didUpdateWidget(oldWidget); + // Update the controller value if the updated initial value is different + // from the current value. + final currValue = _controller.text; + final newValue = widget.initialValue; + if (currValue != newValue) { + _controller.value = switch (newValue) { + final value? => TextEditingValue( + text: value, + selection: TextSelection.collapsed(offset: value.length), + ), + _ => TextEditingValue.empty, + }; + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final textTheme = context.appTextStyles; + final colorTheme = context.appColors; + + // Reduce vertical padding if there is an error text. + var contentPadding = widget.contentPadding; + final verticalPadding = contentPadding.vertical; + final horizontalPadding = contentPadding.horizontal; + if (widget.errorText != null) { + contentPadding = contentPadding.subtract( + EdgeInsets.symmetric(vertical: verticalPadding / 4), + ); + } + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + PollTextFieldError( + padding: EdgeInsets.only( + top: verticalPadding / 4, + left: horizontalPadding / 2, + right: horizontalPadding / 2, + ), + errorText: widget.errorText, + errorStyle: widget.errorStyle ?? + textTheme.footnote.copyWith( + color: colorTheme.accentError, + ), + ), + TextField( + autocorrect: false, + controller: _controller, + focusNode: widget.focusNode, + onChanged: widget.onChanged, + style: widget.style ?? textTheme.headline, + keyboardType: widget.keyboardType, + autofocus: widget.autoFocus, + inputFormatters: [FilteringTextInputFormatter.deny(RegExp(r'^\s'))], + decoration: InputDecoration( + filled: true, + isCollapsed: true, + enabled: widget.enabled, + fillColor: widget.fillColor, + hintText: widget.hintText, + hintStyle: (widget.style ?? textTheme.headline).copyWith( + color: colorTheme.textLowEmphasis, + ), + contentPadding: contentPadding, + border: OutlineInputBorder( + borderRadius: widget.borderRadius ?? BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), + ], + ); + } +} + +/// {@template pollTextFieldError} +/// A widget that displays an error text around a text field with a fade +/// transition. +/// +/// Usually used with [StreamPollTextField]. +/// {@endtemplate} +class PollTextFieldError extends StatefulWidget { + /// {@macro pollTextFieldError} + const PollTextFieldError({ + super.key, + this.errorText, + this.errorStyle, + this.errorMaxLines, + this.textAlign, + this.padding, + }); + + /// The error text to be displayed. + final String? errorText; + + /// The maximum number of lines for the error text. + final int? errorMaxLines; + + /// The alignment of the error text. + final TextAlign? textAlign; + + /// The style of the error text. + final TextStyle? errorStyle; + + /// The padding around the error text. + final EdgeInsetsGeometry? padding; + + @override + State createState() => _PollTextFieldErrorState(); +} + +class _PollTextFieldErrorState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: _kTransitionDuration, + vsync: this, + )..addListener(() => setState(() {})); + + if (widget.errorText != null) { + _controller.value = 1.0; + } + } + + @override + void didUpdateWidget(covariant PollTextFieldError oldWidget) { + super.didUpdateWidget(oldWidget); + // Animate the error text if the error text state has changed. + final newError = widget.errorText; + final currError = oldWidget.errorText; + final errorTextStateChanged = (newError != null) != (currError != null); + if (errorTextStateChanged) { + if (newError != null) { + _controller.forward(); + } else { + _controller.reverse(); + } + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final errorText = widget.errorText; + if (errorText == null) return const SizedBox.shrink(); + + return Container( + padding: widget.padding, + child: Semantics( + container: true, + child: FadeTransition( + opacity: _controller, + child: FractionalTranslation( + translation: Tween( + begin: const Offset(0, 0.25), + end: Offset.zero, + ).evaluate(_controller.view), + child: Text( + errorText, + style: widget.errorStyle, + textAlign: widget.textAlign, + overflow: TextOverflow.ellipsis, + maxLines: widget.errorMaxLines, + ), + ), + ), + ), + ); + } +} diff --git a/sample_app/lib/screens/user_feed/polls/create_poll/stream_poll_controller.dart b/sample_app/lib/screens/user_feed/polls/create_poll/stream_poll_controller.dart new file mode 100644 index 00000000..2676352c --- /dev/null +++ b/sample_app/lib/screens/user_feed/polls/create_poll/stream_poll_controller.dart @@ -0,0 +1,210 @@ +import 'package:flutter/material.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +import 'create_poll_state.dart'; +import 'poll_config.dart'; + +part 'stream_poll_controller.freezed.dart'; + +/// {@template streamPollController} +/// Controller used to manage the state of a poll. +/// {@endtemplate} +class StreamPollController extends ValueNotifier { + /// {@macro streamPollController} + factory StreamPollController({ + CreatePollState? poll, + PollConfig? config, + }) => + StreamPollController._( + config ?? const PollConfig(), + poll ?? CreatePollState.empty(), + ); + + StreamPollController._(this.config, super._value); + + /// The configuration used to validate the poll. + final PollConfig config; + + /// Returns `true` if the poll is valid. + /// + /// The poll is considered valid if it passes all the validations specified + /// in the [config]. + /// + /// See also: + /// * [validateGranularly], which returns a [Set] of [PollValidationError] if + /// there are any errors. + bool validate() => validateGranularly().isEmpty; + + /// Validates the poll with the validation specified in the [config], and + /// returns a [Set] of [PollValidationError] only, if any. + /// + /// See also: + /// * [validate], which also validates the poll and returns true if there are + /// no errors. + Set validateGranularly() { + final invalidErrors = {}; + + // Validate the name length + if (config.nameRange case final nameRange?) { + final name = value.name; + final (:min, :max) = nameRange; + + if (min != null && name.length < min || + max != null && name.length > max) { + invalidErrors.add( + PollValidationError.nameRange(name, range: nameRange), + ); + } + } + + // Validate if the poll options are unique. + if (config.allowDuplicateOptions case false) { + final options = value.options; + final uniqueOptions = options.map((it) => it.text).toSet(); + if (uniqueOptions.length != options.length) { + invalidErrors.add( + PollValidationError.duplicateOptions(options), + ); + } + } + + // Validate the poll options count + if (config.optionsRange case final optionsRange?) { + final options = value.options; + final nonEmptyOptions = [...options.where((it) => it.text.isNotEmpty)]; + final (:min, :max) = optionsRange; + + if (min != null && nonEmptyOptions.length < min || + max != null && nonEmptyOptions.length > max) { + invalidErrors.add( + PollValidationError.optionsRange(options, range: optionsRange), + ); + } + } + + // Validate the max number of votes allowed if enforceUniqueVote is false. + if (value.enforceUniqueVote case false) { + if (value.maxVotesAllowed case final maxVotesAllowed?) { + if (config.allowedVotesRange case final allowedVotesRange?) { + final (:min, :max) = allowedVotesRange; + + if (min != null && maxVotesAllowed < min || + max != null && maxVotesAllowed > max) { + invalidErrors.add( + PollValidationError.maxVotesAllowed( + maxVotesAllowed, + range: allowedVotesRange, + ), + ); + } + } + } + } + + return invalidErrors; + } + + /// Adds a new option with the provided [text] and [extraData]. + /// + /// The new option will be added to the end of the list of options. + void addOption( + String text, { + Map extraData = const {}, + }) { + final options = [...value.options]; + final newOption = PollOptionInputState(text: text); + value = value.copyWith(options: [...options, newOption]); + } + + /// Updates the option at the provided [index] with the provided [text] and + /// [extraData]. + void updateOption( + String text, { + required int index, + Map extraData = const {}, + }) { + final options = [...value.options]; + options[index] = options[index].copyWith( + text: text, + ); + + value = value.copyWith(options: options); + } + + /// Removes the option at the provided [index]. + PollOptionInputState removeOption(int index) { + final options = [...value.options]; + final removed = options.removeAt(index); + value = value.copyWith(options: options); + + return removed; + } + + /// Sets the poll question. + set question(String question) { + value = value.copyWith(name: question); + } + + /// Sets the poll options. + set options(List options) { + value = value.copyWith(options: options); + } + + /// Sets the poll enforce unique vote. + set enforceUniqueVote(bool enforceUniqueVote) { + value = value.copyWith(enforceUniqueVote: enforceUniqueVote); + } + + /// Sets the poll max votes allowed. + /// + /// If `null`, there is no limit to the number of votes allowed. + set maxVotesAllowed(int? maxVotesAllowed) { + value = value.copyWith(maxVotesAllowed: maxVotesAllowed); + } + + set allowSuggestions(bool allowSuggestions) { + value = value.copyWith(allowUserSuggestedOptions: allowSuggestions); + } + + /// Sets the poll voting visibility. + set votingVisibility(VotingVisibility visibility) { + value = value.copyWith(votingVisibility: visibility); + } + + /// Sets whether the poll allows comments. + set allowComments(bool allowComments) { + value = value.copyWith(allowAnswers: allowComments); + } +} + +/// {@template pollValidationError} +/// Union representing the possible validation errors while creating a poll. +/// +/// The errors are used to provide feedback to the user about what went wrong +/// while creating a poll. +/// {@endtemplate} +@freezed +sealed class PollValidationError with _$PollValidationError { + /// Occurs when the poll contains duplicate options. + const factory PollValidationError.duplicateOptions( + List options, + ) = _PollValidationErrorDuplicateOptions; + + /// Occurs when the poll question length is not within the allowed range. + const factory PollValidationError.nameRange( + String name, { + required Range range, + }) = _PollValidationErrorNameRange; + + /// Occurs when the poll options count is not within the allowed range. + const factory PollValidationError.optionsRange( + List options, { + required Range range, + }) = _PollValidationErrorOptionsRange; + + /// Occurs when the poll max votes allowed is not within the allowed range. + const factory PollValidationError.maxVotesAllowed( + int maxVotesAllowed, { + required Range range, + }) = _PollValidationErrorMaxVotesAllowed; +} diff --git a/sample_app/lib/screens/user_feed/polls/create_poll/stream_poll_controller.freezed.dart b/sample_app/lib/screens/user_feed/polls/create_poll/stream_poll_controller.freezed.dart new file mode 100644 index 00000000..e839cdc2 --- /dev/null +++ b/sample_app/lib/screens/user_feed/polls/create_poll/stream_poll_controller.freezed.dart @@ -0,0 +1,345 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'stream_poll_controller.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$PollValidationError { + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && other is PollValidationError); + } + + @override + int get hashCode => runtimeType.hashCode; + + @override + String toString() { + return 'PollValidationError()'; + } +} + +/// @nodoc +class $PollValidationErrorCopyWith<$Res> { + $PollValidationErrorCopyWith( + PollValidationError _, $Res Function(PollValidationError) __); +} + +/// @nodoc + +class _PollValidationErrorDuplicateOptions implements PollValidationError { + const _PollValidationErrorDuplicateOptions( + final List options) + : _options = options; + + final List _options; + List get options { + if (_options is EqualUnmodifiableListView) return _options; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_options); + } + + /// Create a copy of PollValidationError + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + _$PollValidationErrorDuplicateOptionsCopyWith< + _PollValidationErrorDuplicateOptions> + get copyWith => __$PollValidationErrorDuplicateOptionsCopyWithImpl< + _PollValidationErrorDuplicateOptions>(this, _$identity); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _PollValidationErrorDuplicateOptions && + const DeepCollectionEquality().equals(other._options, _options)); + } + + @override + int get hashCode => + Object.hash(runtimeType, const DeepCollectionEquality().hash(_options)); + + @override + String toString() { + return 'PollValidationError.duplicateOptions(options: $options)'; + } +} + +/// @nodoc +abstract mixin class _$PollValidationErrorDuplicateOptionsCopyWith<$Res> + implements $PollValidationErrorCopyWith<$Res> { + factory _$PollValidationErrorDuplicateOptionsCopyWith( + _PollValidationErrorDuplicateOptions value, + $Res Function(_PollValidationErrorDuplicateOptions) _then) = + __$PollValidationErrorDuplicateOptionsCopyWithImpl; + @useResult + $Res call({List options}); +} + +/// @nodoc +class __$PollValidationErrorDuplicateOptionsCopyWithImpl<$Res> + implements _$PollValidationErrorDuplicateOptionsCopyWith<$Res> { + __$PollValidationErrorDuplicateOptionsCopyWithImpl(this._self, this._then); + + final _PollValidationErrorDuplicateOptions _self; + final $Res Function(_PollValidationErrorDuplicateOptions) _then; + + /// Create a copy of PollValidationError + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + $Res call({ + Object? options = null, + }) { + return _then(_PollValidationErrorDuplicateOptions( + null == options + ? _self._options + : options // ignore: cast_nullable_to_non_nullable + as List, + )); + } +} + +/// @nodoc + +class _PollValidationErrorNameRange implements PollValidationError { + const _PollValidationErrorNameRange(this.name, {required this.range}); + + final String name; + final Range range; + + /// Create a copy of PollValidationError + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + _$PollValidationErrorNameRangeCopyWith<_PollValidationErrorNameRange> + get copyWith => __$PollValidationErrorNameRangeCopyWithImpl< + _PollValidationErrorNameRange>(this, _$identity); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _PollValidationErrorNameRange && + (identical(other.name, name) || other.name == name) && + (identical(other.range, range) || other.range == range)); + } + + @override + int get hashCode => Object.hash(runtimeType, name, range); + + @override + String toString() { + return 'PollValidationError.nameRange(name: $name, range: $range)'; + } +} + +/// @nodoc +abstract mixin class _$PollValidationErrorNameRangeCopyWith<$Res> + implements $PollValidationErrorCopyWith<$Res> { + factory _$PollValidationErrorNameRangeCopyWith( + _PollValidationErrorNameRange value, + $Res Function(_PollValidationErrorNameRange) _then) = + __$PollValidationErrorNameRangeCopyWithImpl; + @useResult + $Res call({String name, Range range}); +} + +/// @nodoc +class __$PollValidationErrorNameRangeCopyWithImpl<$Res> + implements _$PollValidationErrorNameRangeCopyWith<$Res> { + __$PollValidationErrorNameRangeCopyWithImpl(this._self, this._then); + + final _PollValidationErrorNameRange _self; + final $Res Function(_PollValidationErrorNameRange) _then; + + /// Create a copy of PollValidationError + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + $Res call({ + Object? name = null, + Object? range = null, + }) { + return _then(_PollValidationErrorNameRange( + null == name + ? _self.name + : name // ignore: cast_nullable_to_non_nullable + as String, + range: null == range + ? _self.range + : range // ignore: cast_nullable_to_non_nullable + as Range, + )); + } +} + +/// @nodoc + +class _PollValidationErrorOptionsRange implements PollValidationError { + const _PollValidationErrorOptionsRange( + final List options, + {required this.range}) + : _options = options; + + final List _options; + List get options { + if (_options is EqualUnmodifiableListView) return _options; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_options); + } + + final Range range; + + /// Create a copy of PollValidationError + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + _$PollValidationErrorOptionsRangeCopyWith<_PollValidationErrorOptionsRange> + get copyWith => __$PollValidationErrorOptionsRangeCopyWithImpl< + _PollValidationErrorOptionsRange>(this, _$identity); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _PollValidationErrorOptionsRange && + const DeepCollectionEquality().equals(other._options, _options) && + (identical(other.range, range) || other.range == range)); + } + + @override + int get hashCode => Object.hash( + runtimeType, const DeepCollectionEquality().hash(_options), range); + + @override + String toString() { + return 'PollValidationError.optionsRange(options: $options, range: $range)'; + } +} + +/// @nodoc +abstract mixin class _$PollValidationErrorOptionsRangeCopyWith<$Res> + implements $PollValidationErrorCopyWith<$Res> { + factory _$PollValidationErrorOptionsRangeCopyWith( + _PollValidationErrorOptionsRange value, + $Res Function(_PollValidationErrorOptionsRange) _then) = + __$PollValidationErrorOptionsRangeCopyWithImpl; + @useResult + $Res call({List options, Range range}); +} + +/// @nodoc +class __$PollValidationErrorOptionsRangeCopyWithImpl<$Res> + implements _$PollValidationErrorOptionsRangeCopyWith<$Res> { + __$PollValidationErrorOptionsRangeCopyWithImpl(this._self, this._then); + + final _PollValidationErrorOptionsRange _self; + final $Res Function(_PollValidationErrorOptionsRange) _then; + + /// Create a copy of PollValidationError + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + $Res call({ + Object? options = null, + Object? range = null, + }) { + return _then(_PollValidationErrorOptionsRange( + null == options + ? _self._options + : options // ignore: cast_nullable_to_non_nullable + as List, + range: null == range + ? _self.range + : range // ignore: cast_nullable_to_non_nullable + as Range, + )); + } +} + +/// @nodoc + +class _PollValidationErrorMaxVotesAllowed implements PollValidationError { + const _PollValidationErrorMaxVotesAllowed(this.maxVotesAllowed, + {required this.range}); + + final int maxVotesAllowed; + final Range range; + + /// Create a copy of PollValidationError + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + _$PollValidationErrorMaxVotesAllowedCopyWith< + _PollValidationErrorMaxVotesAllowed> + get copyWith => __$PollValidationErrorMaxVotesAllowedCopyWithImpl< + _PollValidationErrorMaxVotesAllowed>(this, _$identity); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _PollValidationErrorMaxVotesAllowed && + (identical(other.maxVotesAllowed, maxVotesAllowed) || + other.maxVotesAllowed == maxVotesAllowed) && + (identical(other.range, range) || other.range == range)); + } + + @override + int get hashCode => Object.hash(runtimeType, maxVotesAllowed, range); + + @override + String toString() { + return 'PollValidationError.maxVotesAllowed(maxVotesAllowed: $maxVotesAllowed, range: $range)'; + } +} + +/// @nodoc +abstract mixin class _$PollValidationErrorMaxVotesAllowedCopyWith<$Res> + implements $PollValidationErrorCopyWith<$Res> { + factory _$PollValidationErrorMaxVotesAllowedCopyWith( + _PollValidationErrorMaxVotesAllowed value, + $Res Function(_PollValidationErrorMaxVotesAllowed) _then) = + __$PollValidationErrorMaxVotesAllowedCopyWithImpl; + @useResult + $Res call({int maxVotesAllowed, Range range}); +} + +/// @nodoc +class __$PollValidationErrorMaxVotesAllowedCopyWithImpl<$Res> + implements _$PollValidationErrorMaxVotesAllowedCopyWith<$Res> { + __$PollValidationErrorMaxVotesAllowedCopyWithImpl(this._self, this._then); + + final _PollValidationErrorMaxVotesAllowed _self; + final $Res Function(_PollValidationErrorMaxVotesAllowed) _then; + + /// Create a copy of PollValidationError + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + $Res call({ + Object? maxVotesAllowed = null, + Object? range = null, + }) { + return _then(_PollValidationErrorMaxVotesAllowed( + null == maxVotesAllowed + ? _self.maxVotesAllowed + : maxVotesAllowed // ignore: cast_nullable_to_non_nullable + as int, + range: null == range + ? _self.range + : range // ignore: cast_nullable_to_non_nullable + as Range, + )); + } +} + +// dart format on diff --git a/sample_app/lib/screens/user_feed/polls/create_poll/stream_poll_creator_dialog.dart b/sample_app/lib/screens/user_feed/polls/create_poll/stream_poll_creator_dialog.dart new file mode 100644 index 00000000..fd2526e7 --- /dev/null +++ b/sample_app/lib/screens/user_feed/polls/create_poll/stream_poll_creator_dialog.dart @@ -0,0 +1,274 @@ +import 'package:flutter/material.dart'; + +import '../../../../theme/extensions/theme_extensions.dart'; +import 'create_poll_state.dart'; +import 'poll_config.dart'; +import 'stream_poll_controller.dart'; +import 'stream_poll_creator_widget.dart'; + +/// {@template showStreamPollCreatorDialog} +/// Shows the poll creator dialog based on the screen size. +/// +/// The regular dialog is shown on larger screens such as tablets and desktops +/// and a full screen dialog is shown on smaller screens such as mobile phones. +/// +/// The [poll] and [config] parameters can be used to provide an initial poll +/// and a configuration to validate the poll. +/// {@endtemplate} +Future showStreamPollCreatorDialog({ + required BuildContext context, + CreatePollState? poll, + PollConfig? config, + bool barrierDismissible = true, + Color? barrierColor, + String? barrierLabel, + bool useSafeArea = true, + bool useRootNavigator = false, + RouteSettings? routeSettings, + Offset? anchorPoint, + EdgeInsets padding = const EdgeInsets.all(16), + TraversalEdgeBehavior? traversalEdgeBehavior, +}) { + final size = MediaQuery.sizeOf(context); + final isTabletOrDesktop = size.width > 600; + + final colorTheme = context.appColors; + + // Open it as a regular dialog on bigger screens such as tablets and desktops. + if (isTabletOrDesktop) { + return showDialog( + context: context, + barrierColor: barrierColor ?? colorTheme.overlay, + barrierDismissible: barrierDismissible, + barrierLabel: barrierLabel, + useSafeArea: useSafeArea, + useRootNavigator: useRootNavigator, + routeSettings: routeSettings, + builder: (context) => StreamPollCreatorDialog( + poll: poll, + config: config, + padding: padding, + ), + ); + } + + // Open it as a full screen dialog on smaller screens such as mobile phones. + final navigator = Navigator.of(context, rootNavigator: useRootNavigator); + return navigator.push( + MaterialPageRoute( + fullscreenDialog: true, + barrierDismissible: barrierDismissible, + builder: (context) => StreamPollCreatorFullScreenDialog( + poll: poll, + config: config, + padding: padding, + ), + ), + ); +} + +/// {@template streamPollCreatorDialog} +/// A dialog that allows users to create a poll. +/// +/// The dialog provides a form to create a poll with a question and multiple +/// options. +/// +/// This widget is intended to be used on larger screens such as tablets and +/// desktops. +/// +/// For smaller screens, consider using [StreamPollCreatorFullScreenDialog]. +/// {@endtemplate} +class StreamPollCreatorDialog extends StatefulWidget { + /// {@macro streamPollCreatorDialog} + const StreamPollCreatorDialog({ + super.key, + this.poll, + this.config, + this.padding = const EdgeInsets.all(16), + }); + + /// The initial poll to be used in the poll creator. + final CreatePollState? poll; + + /// The configuration used to validate the poll. + final PollConfig? config; + + /// The padding around the poll creator. + final EdgeInsets padding; + + @override + State createState() => + _StreamPollCreatorDialogState(); +} + +class _StreamPollCreatorDialogState extends State { + late final _controller = StreamPollController( + poll: widget.poll, + config: widget.config, + ); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = context.appTextStyles; + final colorTheme = context.appColors; + + final actions = [ + TextButton( + onPressed: Navigator.of(context).pop, + style: TextButton.styleFrom( + textStyle: theme.headlineBold, + foregroundColor: colorTheme.accentPrimary, + disabledForegroundColor: colorTheme.disabled, + ), + child: Text('Cancel'.toUpperCase()), + ), + ValueListenableBuilder( + valueListenable: _controller, + builder: (context, poll, child) { + final isValid = _controller.validate(); + return TextButton( + onPressed: isValid + ? () { + final errors = _controller.validateGranularly(); + if (errors.isNotEmpty) { + return; + } + + return Navigator.of(context).pop(_controller.value); + } + : null, + style: TextButton.styleFrom( + textStyle: theme.headlineBold, + foregroundColor: colorTheme.accentPrimary, + disabledForegroundColor: colorTheme.disabled, + ), + child: Text('Create'.toUpperCase()), + ); + }, + ), + ]; + + return AlertDialog( + title: Text( + 'Create Poll', + style: theme.headlineBold, + ), + titlePadding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + actions: actions, + contentPadding: EdgeInsets.zero, + actionsPadding: const EdgeInsets.all(8), + backgroundColor: colorTheme.appBg, + content: SizedBox( + width: 640, // Similar to BottomSheet default width on M3 + child: StreamPollCreatorWidget( + shrinkWrap: true, + padding: widget.padding, + controller: _controller, + ), + ), + ); + } +} + +/// {@template streamPollCreatorFullScreenDialog} +/// A page that allows users to create a poll. +/// +/// The page provides a form to create a poll with a question and multiple +/// options. +/// +/// This widget is intended to be used on smaller screens such as mobile phones. +/// +/// For larger screens, consider using [StreamPollCreatorDialog]. +/// {@endtemplate} +class StreamPollCreatorFullScreenDialog extends StatefulWidget { + /// {@macro streamPollCreatorFullScreenDialog} + const StreamPollCreatorFullScreenDialog({ + super.key, + this.poll, + this.config, + this.padding = const EdgeInsets.all(16), + }); + + /// The initial poll to be used in the poll creator. + final CreatePollState? poll; + + /// The configuration used to validate the poll. + final PollConfig? config; + + /// The padding around the poll creator. + final EdgeInsets padding; + + @override + State createState() => + _StreamPollCreatorFullScreenDialogState(); +} + +class _StreamPollCreatorFullScreenDialogState + extends State { + late final _controller = StreamPollController( + poll: widget.poll, + config: widget.config, + ); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = context.appTextStyles; + final colorTheme = context.appColors; + + return Scaffold( + backgroundColor: colorTheme.appBg, + appBar: AppBar( + elevation: 0, + backgroundColor: colorTheme.appBg, + title: Text( + 'Create Poll', + style: theme.headlineBold, + ), + actions: [ + ValueListenableBuilder( + valueListenable: _controller, + builder: (context, poll, child) { + final colorTheme = context.appColors; + + final isValid = _controller.validate(); + + return IconButton( + color: colorTheme.accentPrimary, + disabledColor: colorTheme.disabled, + icon: const Icon(Icons.send), + onPressed: isValid + ? () { + final errors = _controller.validateGranularly(); + if (errors.isNotEmpty) { + return; + } + + return Navigator.of(context).pop(_controller.value); + } + : null, + ); + }, + ), + ], + ), + body: StreamPollCreatorFullScreenDialog( + padding: widget.padding, + poll: _controller.value, + config: widget.config, + ), + ); + } +} diff --git a/sample_app/lib/screens/user_feed/polls/create_poll/stream_poll_creator_widget.dart b/sample_app/lib/screens/user_feed/polls/create_poll/stream_poll_creator_widget.dart new file mode 100644 index 00000000..5767afd0 --- /dev/null +++ b/sample_app/lib/screens/user_feed/polls/create_poll/stream_poll_creator_widget.dart @@ -0,0 +1,156 @@ +import 'package:flutter/material.dart'; + +import 'create_poll_state.dart'; +import 'poll_option_reorderable_list_view.dart'; +import 'poll_question_text_field.dart'; +import 'poll_switch_list_tile.dart'; +import 'stream_poll_controller.dart'; + +/// {@template streamPollCreator} +/// A widget that allows users to create a poll. +/// +/// The widget provides a form to create a poll with a question and multiple +/// options. +/// +/// {@endtemplate} +class StreamPollCreatorWidget extends StatelessWidget { + /// {@macro streamPollCreator} + const StreamPollCreatorWidget({ + super.key, + required this.controller, + this.shrinkWrap = false, + this.physics, + this.padding = const EdgeInsets.all(16), + }); + + /// The padding around the poll creator. + final EdgeInsets padding; + + /// Whether the scroll view should shrink-wrap its content. + final bool shrinkWrap; + + /// The physics of the scroll view. + final ScrollPhysics? physics; + + /// The controller used to manage the state of the poll. + final StreamPollController controller; + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: controller, + builder: (context, poll, child) { + final config = controller.config; + + // Using a combination of SingleChildScrollView and Column instead of + // ListView to avoid the item color overflow issue. + // + // More info: https://github.com/flutter/flutter/issues/86584 + return SingleChildScrollView( + padding: padding, + physics: physics, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + PollQuestionTextField( + questionRange: config.nameRange, + title: 'Questions', + hintText: 'Ask a question', + initialQuestion: + PollQuestion(originalId: poll.id, text: poll.name), + onChanged: (question) => controller.question = question.text, + ), + const SizedBox(height: 32), + PollOptionReorderableListView( + title: 'Options', + itemHintText: 'Option', + allowDuplicate: config.allowDuplicateOptions, + optionsRange: config.optionsRange, + initialOptions: [ + for (final option in poll.options) + PollOptionItem( + key: option.key, + originalId: option.originalId, + text: option.text, + ), + ], + onOptionsChanged: (options) => controller.options = [ + for (final option in options) + PollOptionInputState( + key: option.key, + originalId: option.originalId, + text: option.text, + ), + ], + ), + const SizedBox(height: 32), + PollSwitchListTile( + title: 'Multiple answers', + value: !poll.enforceUniqueVote, + onChanged: (value) { + controller.enforceUniqueVote = !value; + // We also need to reset maxVotesAllowed if disabled. + if (value case false) controller.maxVotesAllowed = null; + }, + children: [ + PollSwitchTextField( + hintText: 'Maximum votes per person', + item: PollSwitchItem( + value: poll.maxVotesAllowed != null, + inputValue: poll.maxVotesAllowed, + ), + keyboardType: TextInputType.number, + validator: (item) { + final allowedRange = config.allowedVotesRange; + final votes = item.inputValue; + if (votes == null || allowedRange == null) return null; + + final (:min, :max) = allowedRange; + + if (min != null && votes < min) { + return 'Vote count must be at least $min'; + } + + if (max != null && votes > max) { + return 'Vote count must be at most $max'; + } + + return null; + }, + onChanged: (option) { + final enabled = option.value; + final maxVotes = option.inputValue; + + controller.maxVotesAllowed = enabled ? maxVotes : null; + }, + ), + ], + ), + const SizedBox(height: 8), + PollSwitchListTile( + title: 'Anonymous poll', + value: poll.votingVisibility == VotingVisibility.anonymous, + onChanged: (anon) => controller.votingVisibility = anon // + ? VotingVisibility.anonymous + : VotingVisibility.public, + ), + const SizedBox(height: 8), + PollSwitchListTile( + title: 'Suggest an option', + value: poll.allowUserSuggestedOptions, + onChanged: (allow) => controller.allowSuggestions = allow, + ), + const SizedBox(height: 8), + PollSwitchListTile( + title: 'Add a comment', + value: poll.allowAnswers, + onChanged: (allow) => controller.allowComments = allow, + ), + ], + ), + ); + }, + ); + } +} diff --git a/sample_app/lib/screens/user_feed/polls/create_poll/stream_poll_text_field.dart b/sample_app/lib/screens/user_feed/polls/create_poll/stream_poll_text_field.dart new file mode 100644 index 00000000..981fc49c --- /dev/null +++ b/sample_app/lib/screens/user_feed/polls/create_poll/stream_poll_text_field.dart @@ -0,0 +1,271 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../../../theme/extensions/theme_extensions.dart'; + +const _kTransitionDuration = Duration(milliseconds: 167); + +/// {@template streamPollTextField} +/// A widget that represents a text field for poll input. +/// {@endtemplate} +class StreamPollTextField extends StatefulWidget { + /// {@macro streamPollTextField} + const StreamPollTextField({ + super.key, + this.initialValue, + this.style, + this.enabled = true, + this.hintText, + this.fillColor, + this.errorText, + this.errorStyle, + this.contentPadding = const EdgeInsets.symmetric( + vertical: 18, + horizontal: 16, + ), + this.borderRadius, + this.focusNode, + this.keyboardType, + this.autoFocus = false, + this.onChanged, + }); + + /// The initial value of the text field. + /// + /// If `null`, the text field will be empty. + final String? initialValue; + + /// The style to use for the text field. + final TextStyle? style; + + /// Whether the text field is enabled. + final bool enabled; + + /// The hint text to be displayed in the text field. + final String? hintText; + + /// The fill color of the text field. + final Color? fillColor; + + /// The error text to be displayed below the text field. + /// + /// If `null`, no error text will be displayed. + final String? errorText; + + /// The style to use for the error text. + final TextStyle? errorStyle; + + /// The padding around the text field content. + final EdgeInsetsGeometry contentPadding; + + /// The border radius of the text field. + final BorderRadius? borderRadius; + + /// The keyboard type of the text field. + final TextInputType? keyboardType; + + /// Whether the text field should autofocus. + final bool autoFocus; + + /// The focus node of the text field. + final FocusNode? focusNode; + + /// Callback called when the text field value is changed. + final ValueChanged? onChanged; + + @override + State createState() => _StreamPollTextFieldState(); +} + +class _StreamPollTextFieldState extends State { + late final _controller = TextEditingController(text: widget.initialValue); + + @override + void didUpdateWidget(covariant StreamPollTextField oldWidget) { + super.didUpdateWidget(oldWidget); + // Update the controller value if the updated initial value is different + // from the current value. + final currValue = _controller.text; + final newValue = widget.initialValue; + if (currValue != newValue) { + _controller.value = switch (newValue) { + final value? => TextEditingValue( + text: value, + selection: TextSelection.collapsed(offset: value.length), + ), + _ => TextEditingValue.empty, + }; + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final textTheme = context.appTextStyles; + final colorTheme = context.appColors; + + // Reduce vertical padding if there is an error text. + var contentPadding = widget.contentPadding; + final verticalPadding = contentPadding.vertical; + final horizontalPadding = contentPadding.horizontal; + if (widget.errorText != null) { + contentPadding = contentPadding.subtract( + EdgeInsets.symmetric(vertical: verticalPadding / 4), + ); + } + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + PollTextFieldError( + padding: EdgeInsets.only( + top: verticalPadding / 4, + left: horizontalPadding / 2, + right: horizontalPadding / 2, + ), + errorText: widget.errorText, + errorStyle: widget.errorStyle ?? + textTheme.footnote.copyWith( + color: colorTheme.accentError, + ), + ), + TextField( + autocorrect: false, + controller: _controller, + focusNode: widget.focusNode, + onChanged: widget.onChanged, + style: widget.style ?? textTheme.bodyBold, + keyboardType: widget.keyboardType, + autofocus: widget.autoFocus, + inputFormatters: [FilteringTextInputFormatter.deny(RegExp(r'^\s'))], + decoration: InputDecoration( + filled: true, + isCollapsed: true, + enabled: widget.enabled, + fillColor: widget.fillColor, + hintText: widget.hintText, + hintStyle: (widget.style ?? textTheme.bodyBold).copyWith( + color: colorTheme.textLowEmphasis, + ), + contentPadding: contentPadding, + border: OutlineInputBorder( + borderRadius: widget.borderRadius ?? BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), + ], + ); + } +} + +/// {@template pollTextFieldError} +/// A widget that displays an error text around a text field with a fade +/// transition. +/// +/// Usually used with [StreamPollTextField]. +/// {@endtemplate} +class PollTextFieldError extends StatefulWidget { + /// {@macro pollTextFieldError} + const PollTextFieldError({ + super.key, + this.errorText, + this.errorStyle, + this.errorMaxLines, + this.textAlign, + this.padding, + }); + + /// The error text to be displayed. + final String? errorText; + + /// The maximum number of lines for the error text. + final int? errorMaxLines; + + /// The alignment of the error text. + final TextAlign? textAlign; + + /// The style of the error text. + final TextStyle? errorStyle; + + /// The padding around the error text. + final EdgeInsetsGeometry? padding; + + @override + State createState() => _PollTextFieldErrorState(); +} + +class _PollTextFieldErrorState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: _kTransitionDuration, + vsync: this, + )..addListener(() => setState(() {})); + + if (widget.errorText != null) { + _controller.value = 1.0; + } + } + + @override + void didUpdateWidget(covariant PollTextFieldError oldWidget) { + super.didUpdateWidget(oldWidget); + // Animate the error text if the error text state has changed. + final newError = widget.errorText; + final currError = oldWidget.errorText; + final errorTextStateChanged = (newError != null) != (currError != null); + if (errorTextStateChanged) { + if (newError != null) { + _controller.forward(); + } else { + _controller.reverse(); + } + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final errorText = widget.errorText; + if (errorText == null) return const SizedBox.shrink(); + + return Container( + padding: widget.padding, + child: Semantics( + container: true, + child: FadeTransition( + opacity: _controller, + child: FractionalTranslation( + translation: Tween( + begin: const Offset(0, 0.25), + end: Offset.zero, + ).evaluate(_controller.view), + child: Text( + errorText, + style: widget.errorStyle, + textAlign: widget.textAlign, + overflow: TextOverflow.ellipsis, + maxLines: widget.errorMaxLines, + ), + ), + ), + ), + ); + } +} diff --git a/sample_app/lib/screens/user_feed/polls/show_poll/poll_add_comment_dialog.dart b/sample_app/lib/screens/user_feed/polls/show_poll/poll_add_comment_dialog.dart new file mode 100644 index 00000000..f0228e82 --- /dev/null +++ b/sample_app/lib/screens/user_feed/polls/show_poll/poll_add_comment_dialog.dart @@ -0,0 +1,105 @@ +import 'package:flutter/material.dart'; + +import '../../../../theme/extensions/theme_extensions.dart'; +import '../create_poll/poll_text_field.dart'; + +/// {@template showPollAddCommentDialog} +/// Shows a dialog that allows the user to add a poll comment. +/// +/// Optionally, you can provide an [initialValue] to pre-fill the text field. +/// {@endtemplate} +Future showPollAddCommentDialog({ + required BuildContext context, + String initialValue = '', +}) => + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => PollAddCommentDialog( + initialValue: initialValue, + ), + ); + +/// {@template pollAddCommentDialog} +/// A dialog that allows the user to add or update a poll comment. +/// +/// Optionally, you can provide an [initialValue] to pre-fill the text field. +/// {@endtemplate} +class PollAddCommentDialog extends StatefulWidget { + /// {@macro pollAddCommentDialog} + const PollAddCommentDialog({ + super.key, + this.initialValue = '', + }); + + /// Initial answer to be displayed in the text field. + /// + /// Defaults to an empty string. + final String initialValue; + + @override + State createState() => _PollAddCommentDialogState(); +} + +class _PollAddCommentDialogState extends State { + late String _comment = widget.initialValue; + + @override + Widget build(BuildContext context) { + final textTheme = context.appTextStyles; + final colorTheme = context.appColors; + + final actions = [ + TextButton( + onPressed: Navigator.of(context).pop, + style: TextButton.styleFrom( + textStyle: textTheme.headlineBold, + foregroundColor: colorTheme.accentPrimary, + disabledForegroundColor: colorTheme.disabled, + ), + child: Text('Cancel'.toUpperCase()), + ), + TextButton( + onPressed: switch (_comment == widget.initialValue) { + true => null, + false => () => Navigator.of(context).pop(_comment), + }, + style: TextButton.styleFrom( + textStyle: textTheme.headlineBold, + foregroundColor: colorTheme.accentPrimary, + disabledForegroundColor: colorTheme.disabled, + ), + child: Text('Send'.toUpperCase()), + ), + ]; + + return AlertDialog( + title: Text( + switch (widget.initialValue.isEmpty) { + true => 'Add a comment', + false => 'Update your comment', + }, + style: textTheme.headlineBold, + ), + actions: actions, + titlePadding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + contentPadding: const EdgeInsets.all(16), + actionsPadding: const EdgeInsets.all(8), + backgroundColor: colorTheme.appBg, + content: StreamPollTextField( + autoFocus: true, + initialValue: _comment, + hintText: 'Enter your comment', + contentPadding: const EdgeInsets.symmetric( + vertical: 12, + horizontal: 16, + ), + style: textTheme.headline, + fillColor: colorTheme.inputBg, + borderRadius: BorderRadius.circular(12), + onChanged: (value) => setState(() => _comment = value), + ), + ); + } +} diff --git a/sample_app/lib/screens/user_feed/polls/show_poll/poll_end_vote_dialog.dart b/sample_app/lib/screens/user_feed/polls/show_poll/poll_end_vote_dialog.dart new file mode 100644 index 00000000..88144637 --- /dev/null +++ b/sample_app/lib/screens/user_feed/polls/show_poll/poll_end_vote_dialog.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; + +import '../../../../theme/extensions/theme_extensions.dart'; + +/// {@template showPollSuggestOptionDialog} +/// Shows a dialog that allows the user to end vote for a poll. +/// {@endtemplate} +Future showPollEndVoteDialog({ + required BuildContext context, +}) { + return showDialog( + context: context, + builder: (_) => const PollEndVoteDialog(), + ); +} + +/// {@template pollEndVoteDialog} +/// A dialog that allows the user to end vote for a poll. +/// {@endtemplate} +class PollEndVoteDialog extends StatelessWidget { + /// {@macro pollEndVoteDialog} + const PollEndVoteDialog({super.key}); + + @override + Widget build(BuildContext context) { + final textTheme = context.appTextStyles; + final colorTheme = context.appColors; + + final actions = [ + TextButton( + onPressed: () => Navigator.of(context).maybePop(false), + style: TextButton.styleFrom( + textStyle: textTheme.headlineBold, + foregroundColor: colorTheme.accentPrimary, + disabledForegroundColor: colorTheme.disabled, + ), + child: Text('Cancel'.toUpperCase()), + ), + TextButton( + onPressed: () => Navigator.of(context).maybePop(true), + style: TextButton.styleFrom( + textStyle: textTheme.headlineBold, + foregroundColor: colorTheme.accentPrimary, + disabledForegroundColor: colorTheme.disabled, + ), + child: Text('End'.toUpperCase()), + ), + ]; + + return AlertDialog( + title: Text( + 'End vote', + style: textTheme.headlineBold, + ), + actions: actions, + titlePadding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + contentPadding: const EdgeInsets.all(16), + actionsPadding: const EdgeInsets.all(8), + backgroundColor: colorTheme.appBg, + ); + } +} diff --git a/sample_app/lib/screens/user_feed/polls/show_poll/poll_footer.dart b/sample_app/lib/screens/user_feed/polls/show_poll/poll_footer.dart new file mode 100644 index 00000000..519c6220 --- /dev/null +++ b/sample_app/lib/screens/user_feed/polls/show_poll/poll_footer.dart @@ -0,0 +1,173 @@ +import 'package:flutter/material.dart'; +import 'package:stream_feeds/stream_feeds.dart'; + +import '../../../../theme/extensions/theme_extensions.dart'; + +class PollFooter extends StatelessWidget { + const PollFooter({ + super.key, + required this.poll, + required this.currentUser, + this.visibleOptionCount, + this.onEndVote, + this.onAddComment, + this.onViewComments, + this.onViewResults, + this.onSuggestOption, + this.onSeeMoreOptions, + }); + + /// The poll the footer is for. + final PollData poll; + + /// The current user interacting with the poll. + final User currentUser; + + /// The number of visible options in the poll. + /// + /// Used to determine if the user can see more options button. + final int? visibleOptionCount; + + /// Callback invoked when the user wants to end the voting. + /// + /// This is only available if the [currentUser] is the creator of the poll. + final VoidCallback? onEndVote; + + /// Callback invoked when the user wants to add a comment. + /// + /// This is only available if the poll is not closed and allows answers. + final VoidCallback? onAddComment; + + /// Callback invoked when the user wants to view all the comments. + /// + /// This is only available if the poll is not closed and has answers. + final VoidCallback? onViewComments; + + /// Callback invoked when the user wants to view the poll results. + final VoidCallback? onViewResults; + + /// Callback invoked when the user wants to suggest an option. + /// + /// This is only available if the poll is not closed and allows user + /// suggested options. + final VoidCallback? onSuggestOption; + + /// Callback invoked when the user wants to see more options. + /// + /// This is only available if the poll has more options than the + /// [visibleOptionCount]. + final VoidCallback? onSeeMoreOptions; + + bool get _shouldShowEndPollButton { + if (poll.isClosed) return false; + + // Only the creator of the poll can end it. + return poll.createdBy?.id == currentUser.id; + } + + bool get _shouldShowAddCommentButton { + if (poll.isClosed || !poll.allowAnswers) return false; + + // If the user has already commented, don't show the button. + if (poll.ownAnswers.isNotEmpty) return false; + + return true; + } + + bool get _shouldShowViewCommentsButton { + // If the poll has no answers, don't show the button. + return poll.answersCount > 0; + } + + bool get _shouldShowSuggestionsButton { + if (poll.isClosed) return false; + + // Only show the button if the poll allows user suggested options. + return poll.allowUserSuggestedOptions; + } + + bool get _shouldEnableViewResultsButton { + // Disable the button if the poll haven't got any votes yet. + if (poll.voteCount < 1) return false; + + return true; + } + + @override + Widget build(BuildContext context) { + return Column( + spacing: 2, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (visibleOptionCount case final count? + when count < poll.options.length) + PollFooterButton( + title: 'See all options (${poll.options.length})', + onPressed: onSeeMoreOptions, + ), + if (_shouldShowSuggestionsButton) + PollFooterButton( + title: 'Suggest an option', + onPressed: onSuggestOption, + ), + if (_shouldShowAddCommentButton) + PollFooterButton( + title: 'Add a comment', + onPressed: onAddComment, + ), + if (_shouldShowViewCommentsButton) + PollFooterButton( + title: 'View comments', + onPressed: onViewComments, + ), + PollFooterButton( + title: 'View results', + onPressed: _shouldEnableViewResultsButton ? onViewResults : null, + ), + if (_shouldShowEndPollButton) + PollFooterButton( + title: 'End vote', + onPressed: onEndVote, + ), + ], + ); + } +} + +/// {@template pollFooterButton} +/// A button used in [PollFooter]. +/// +/// Displays the title and invokes the [onPressed] callback when pressed. +/// {@endtemplate} +class PollFooterButton extends StatelessWidget { + /// {@macro pollFooterButton} + const PollFooterButton({ + super.key, + required this.title, + this.onPressed, + }); + + /// The title of the button. + final String title; + + /// Callback invoked when the button is pressed. + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) { + final textTheme = context.appTextStyles; + final colorTheme = context.appColors; + return TextButton( + onPressed: onPressed, + // Consume long press to avoid the parent long press. + onLongPress: onPressed != null ? () {} : null, + style: TextButton.styleFrom( + textStyle: textTheme.headlineBold, + foregroundColor: colorTheme.accentPrimary, + disabledForegroundColor: colorTheme.disabled, + ), + child: Text(title), + ); + } +} diff --git a/sample_app/lib/screens/user_feed/polls/show_poll/poll_header.dart b/sample_app/lib/screens/user_feed/polls/show_poll/poll_header.dart new file mode 100644 index 00000000..c3de10ee --- /dev/null +++ b/sample_app/lib/screens/user_feed/polls/show_poll/poll_header.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import 'package:stream_feeds/stream_feeds.dart'; + +import '../../../../theme/extensions/theme_extensions.dart'; + +class PollHeader extends StatelessWidget { + const PollHeader({ + super.key, + required this.poll, + }); + + /// The poll the header is for. + final PollData poll; + + @override + Widget build(BuildContext context) { + final theme = context.appTextStyles; + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + poll.name, + style: theme.headlineBold, + ), + Text( + switch (poll.votingMode) { + PollVotingMode.disabled => 'Vote ended', + PollVotingMode.unique => 'Select one', + PollVotingMode.limited => 'Select up to ${poll.maxVotesAllowed}', + PollVotingMode.all => 'Select one or more', + }, + style: theme.footnote, + ), + ], + ); + } +} + +enum PollVotingMode { + disabled, + unique, + limited, + all, +} + +extension PollDataExtension on PollData { + PollVotingMode get votingMode { + if (isClosed) return PollVotingMode.disabled; + if (enforceUniqueVote || maxVotesAllowed == 1) { + return PollVotingMode.unique; + } + if (maxVotesAllowed == null) return PollVotingMode.all; + return PollVotingMode.limited; + } +} diff --git a/sample_app/lib/screens/user_feed/polls/show_poll/poll_message.dart b/sample_app/lib/screens/user_feed/polls/show_poll/poll_message.dart new file mode 100644 index 00000000..042f63da --- /dev/null +++ b/sample_app/lib/screens/user_feed/polls/show_poll/poll_message.dart @@ -0,0 +1,100 @@ +import 'package:flutter/material.dart'; +import 'package:stream_feeds/stream_feeds.dart'; + +import 'poll_add_comment_dialog.dart'; +import 'poll_end_vote_dialog.dart'; +import 'poll_suggest_option_dialog.dart'; +import 'stream_poll_comments_dialog.dart'; +import 'stream_poll_interactor.dart'; +import 'stream_poll_options_dialog.dart'; +import 'stream_poll_results_dialog.dart'; + +const _maxVisibleOptionCount = 10; + +const _kDefaultPollMessageConstraints = BoxConstraints( + maxWidth: 270, +); + +class PollMessage extends StatelessWidget { + /// {@macro pollMessage} + const PollMessage({ + super.key, + required this.activity, + required this.currentUser, + required this.poll, + }); + + /// The message with the poll to display. + final Activity activity; + final User currentUser; + final PollData poll; + + @override + Widget build(BuildContext context) { + Future onEndVote() async { + final confirm = await showPollEndVoteDialog(context: context); + if (confirm == null || !confirm) return; + + activity.closePoll().ignore(); + } + + Future onAddComment() async { + final commentText = await showPollAddCommentDialog( + context: context, + // We use the first answer as the initial value because the user + // can only add one comment per poll. + initialValue: poll.ownAnswers.firstOrNull?.answerText ?? '', + ); + + if (commentText == null) return; + activity + .castPollVote( + CastPollVoteRequest( + vote: VoteData(answerText: commentText), + ), + ) + .ignore(); + } + + Future onSuggestOption() async { + final optionText = await showPollSuggestOptionDialog( + context: context, + ); + + if (optionText == null) return; + activity + .createPollOption(CreatePollOptionRequest(text: optionText)) + .ignore(); + } + + return ConstrainedBox( + constraints: _kDefaultPollMessageConstraints, + child: StreamPollInteractor( + poll: poll, + currentUser: currentUser, + visibleOptionCount: _maxVisibleOptionCount, + onEndVote: onEndVote, + onCastVote: (option) => activity.castPollVote( + CastPollVoteRequest(vote: VoteData(optionId: option.id)), + ), + onRemoveVote: (vote) => activity.deletePollVote(voteId: vote.id), + onAddComment: onAddComment, + onSuggestOption: onSuggestOption, + onViewComments: () => showStreamPollCommentsDialog( + context: context, + activity: activity, + poll: poll, + ), + onSeeMoreOptions: () => showStreamPollOptionsDialog( + context: context, + activity: activity, + poll: poll, + ), + onViewResults: () => showStreamPollResultsDialog( + context: context, + poll: poll, + ), + ), + ); + } +} diff --git a/sample_app/lib/screens/user_feed/polls/show_poll/poll_options_list_view.dart b/sample_app/lib/screens/user_feed/polls/show_poll/poll_options_list_view.dart new file mode 100644 index 00000000..7e908574 --- /dev/null +++ b/sample_app/lib/screens/user_feed/polls/show_poll/poll_options_list_view.dart @@ -0,0 +1,387 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:stream_feeds/stream_feeds.dart'; + +import '../../../../theme/extensions/theme_extensions.dart'; +import '../../../../widgets/user_avatar.dart'; +import 'poll_header.dart'; + +class PollOptionsListView extends StatelessWidget { + const PollOptionsListView({ + super.key, + required this.poll, + this.visibleOptionCount, + this.showProgressBar = false, + this.onCastVote, + this.onRemoveVote, + }); + + /// The poll to display the options for. + final PollData poll; + + /// The number of visible options in the poll. + /// + /// If null, all options will be visible. + final int? visibleOptionCount; + + /// Whether to show the voting progress bar. + /// + /// Note: This is only used when the poll is public. + final bool showProgressBar; + + /// Callback invoked when the user wants to cast a vote. + /// + /// The [PollOption] parameter is the option the user wants to vote for. + final ValueChanged? onCastVote; + + /// Callback invoked when the user wants to remove a vote. + /// + /// The [PollVote] parameter is the vote the user wants to remove. + final ValueChanged? onRemoveVote; + + void _handleVoteRemoval(PollOptionData option) { + final vote = poll.currentUserVoteFor(option); + if (vote == null) return; + + return onRemoveVote?.call(vote); + } + + void _handleVoteAction( + PollOptionData option, { + required bool checked, + }) { + if (checked) return onCastVote?.call(option); + return _handleVoteRemoval(option); + } + + @override + Widget build(BuildContext context) { + final options = switch (visibleOptionCount) { + final count? => poll.options.take(count), + _ => poll.options, + }; + + return ListView.separated( + shrinkWrap: true, + itemCount: options.length, + physics: const NeverScrollableScrollPhysics(), + separatorBuilder: (_, __) => const SizedBox(height: 8), + itemBuilder: (context, index) { + final option = options.elementAt(index); + return PollOptionItem( + key: ValueKey(option.id), + poll: poll, + option: option, + showProgressBar: showProgressBar, + onChanged: (checked) { + if (checked == null) return; + + // Handle voting based on the voting mode. + switch (poll.votingMode) { + case PollVotingMode.disabled: + break; + // Do nothing + case PollVotingMode.all: + return _handleVoteAction(option, checked: checked); + // Note: We don't need to remove the other votes in the unique + // voting mode as the backend handles it. + case PollVotingMode.unique: + return _handleVoteAction(option, checked: checked); + case PollVotingMode.limited: + return _handleVoteAction( + option, + checked: + checked && poll.ownVotes.length < poll.maxVotesAllowed!, + ); + } + }, + ); + }, + ); + } +} + +/// {@template pollOptionItem} +/// A widget that displays a poll option. +/// +/// Used in [PollOptionsListView] to display the poll options to interact with. +/// +/// This widget is used to display the poll option and the number of votes it +/// has received. Also shows the voters if the poll is public. +/// {@endtemplate} +class PollOptionItem extends StatelessWidget { + /// {@macro pollOptionItem} + const PollOptionItem({ + super.key, + required this.poll, + required this.option, + this.showProgressBar = true, + this.onChanged, + }); + + /// The poll the option belongs to. + final PollData poll; + + /// The poll option the user can interact with. + final PollOptionData option; + + /// Whether to show the progress bar. + /// + /// Note: This is only used when the poll is public. + final bool showProgressBar; + + /// Callback invoked when the user interacts with the option. + /// + /// The [bool] parameter is the new value of the option. + final ValueChanged? onChanged; + + @override + Widget build(BuildContext context) { + final textTheme = context.appTextStyles; + final colorTheme = context.appColors; + + final pollClosed = poll.isClosed; + final isOptionSelected = poll.hasCurrentUserVotedFor(option); + + final control = ExcludeFocus( + child: Checkbox( + value: isOptionSelected, + onChanged: pollClosed ? null : onChanged, + checkColor: colorTheme.accentPrimary, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + side: BorderSide(color: colorTheme.accentPrimary), + activeColor: colorTheme.accentPrimary, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: const VisualDensity( + vertical: VisualDensity.minimumDensity, + horizontal: VisualDensity.minimumDensity, + ), + ), + ); + + return InkWell( + onTap: pollClosed ? null : () => onChanged?.call(!isOptionSelected), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + spacing: 4, + children: [ + if (pollClosed case false) control, + Expanded( + child: Column( + spacing: 4, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + spacing: 4, + children: [ + Expanded( + child: Text( + option.text, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: textTheme.bodyBold, + ), + ), + // Show voters only if the poll is public. + if (poll.votingVisibility == 'public') + OptionVoters( + // We only show the latest 3 voters. + voters: [ + ...?poll.latestVotesByOption[option.id], + ].map((it) => it.user).whereType().take(3), + ), + Text( + poll.voteCountFor(option).toString(), + style: textTheme.bodyBold, + ), + ], + ), + if (showProgressBar) + OptionVotesProgressBar( + value: poll.voteRatioFor(option), + borderRadius: BorderRadius.circular(4), + trackColor: colorTheme.barsBg, + valueColor: switch (poll.isOptionWinner(option)) { + true => colorTheme.accentInfo, + false => colorTheme.accentPrimary, + }, + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +/// {@template optionVoters} +/// A widget that displays the voters of an option. +/// +/// Used in [PollOptionItem] to display the voters of a poll option. +/// {@endtemplate} +class OptionVoters extends StatelessWidget { + /// {@macro optionVoters} + const OptionVoters({ + super.key, + this.radius = 10, + this.overlap = 0.5, + required this.voters, + }) : assert( + overlap >= 0 && overlap <= 1, + 'Overlap must be between 0 and 1', + ); + + /// The radius of the avatars. + final double radius; + + /// The overlap between the avatars. + /// + /// The default value is 1/2 i.e. 50%. + final double overlap; + + /// The list of voters to display. + final Iterable voters; + + @override + Widget build(BuildContext context) { + final colorTheme = context.appColors; + + if (voters.isEmpty) return const SizedBox.shrink(); + + final diameter = radius * 2; + final width = diameter + (voters.length * diameter * overlap); + + var overlapPadding = 0.0; + + return SizedBox.fromSize( + size: Size(width, diameter), + child: Stack( + children: [ + ...voters.map( + (user) { + overlapPadding += diameter * overlap; + return Positioned( + right: overlapPadding - (diameter * overlap), + bottom: 0, + top: 0, + child: Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: colorTheme.barsBg, + ), + padding: const EdgeInsets.all(1), + child: UserAvatar.small(user: user), + ), + ); + }, + ), + ], + ), + ); + } +} + +/// {@template optionVotesProgressBar} +/// A widget that displays the progress of the votes for an option. +/// +/// Used in [PollOptionItem] to display the progress of the votes for a +/// particular option. +/// {@endtemplate} +class OptionVotesProgressBar extends StatelessWidget { + /// {@macro optionVotesProgressBar} + const OptionVotesProgressBar({ + super.key, + required this.value, + this.minHeight = 4, + this.trackColor, + this.valueColor, + this.borderRadius = BorderRadius.zero, + }); + + /// The value of the progress bar. + final double? value; + + /// The minimum height of the progress bar. + final double minHeight; + + /// The color of the track. + final Color? trackColor; + + /// The color of the value. + final Color? valueColor; + + /// The border radius of the progress bar. + /// + /// Defaults to [BorderRadius.zero]. + final BorderRadiusGeometry borderRadius; + + @override + Widget build(BuildContext context) { + final value = this.value; + if (value == null) return const SizedBox.shrink(); + + final shape = RoundedRectangleBorder(borderRadius: borderRadius); + return Container( + constraints: BoxConstraints( + minWidth: double.infinity, + minHeight: minHeight, + ), + decoration: ShapeDecoration( + shape: shape, + color: trackColor, + ), + child: LayoutBuilder( + builder: (context, constraints) { + final size = constraints.constrain(Size.zero); + + final textDirection = Directionality.of(context); + final alignment = switch (textDirection) { + TextDirection.ltr => Alignment.centerLeft, + TextDirection.rtl => Alignment.centerRight, + }; + + return Align( + alignment: alignment, + child: AnimatedContainer( + height: size.height, + width: size.width * value, + duration: Durations.medium2, + decoration: ShapeDecoration( + shape: shape, + color: valueColor, + ), + ), + ); + }, + ), + ); + } +} + +extension on PollData { + PollVoteData? currentUserVoteFor(PollOptionData option) { + return ownVotesAndAnswers + .firstWhereOrNull((it) => it.optionId == option.id); + } + + bool hasCurrentUserVotedFor(PollOptionData option) { + return ownVotesAndAnswers.any((it) => it.optionId == option.id); + } + + int voteCountFor(PollOptionData option) { + return voteCountsByOption[option.id] ?? 0; + } + + double? voteRatioFor(PollOptionData option) { + if (voteCount == 0) return null; + return (voteCountsByOption[option.id] ?? 0) / voteCount; + } + + bool isOptionWinner(PollOptionData option) { + if (voteCountsByOption.isEmpty) return false; + return voteCountsByOption[option.id] == voteCountsByOption.values.max; + } +} diff --git a/sample_app/lib/screens/user_feed/polls/show_poll/poll_suggest_option_dialog.dart b/sample_app/lib/screens/user_feed/polls/show_poll/poll_suggest_option_dialog.dart new file mode 100644 index 00000000..8ee51583 --- /dev/null +++ b/sample_app/lib/screens/user_feed/polls/show_poll/poll_suggest_option_dialog.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; + +import '../../../../theme/extensions/theme_extensions.dart'; +import '../create_poll/poll_text_field.dart'; + +/// {@template showPollSuggestOptionDialog} +/// Shows a dialog that allows the user to suggest an option for a poll. +/// +/// Optionally, you can provide an [initialOption] to pre-fill the text field. +/// {@endtemplate} +Future showPollSuggestOptionDialog({ + required BuildContext context, + String initialOption = '', +}) => + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => PollSuggestOptionDialog( + initialOption: initialOption, + ), + ); + +/// {@template pollSuggestOptionDialog} +/// A dialog that allows the user to suggest an option for a poll. +/// +/// Optionally, you can provide an [initialOption] to pre-fill the text field. +/// {@endtemplate} +class PollSuggestOptionDialog extends StatefulWidget { + /// {@macro pollSuggestOptionDialog} + const PollSuggestOptionDialog({ + super.key, + this.initialOption = '', + }); + + /// Initial option to be displayed in the text field. + /// + /// Defaults to an empty string. + final String initialOption; + + @override + State createState() => + _PollSuggestOptionDialogState(); +} + +class _PollSuggestOptionDialogState extends State { + late String _option = widget.initialOption; + + @override + Widget build(BuildContext context) { + final textTheme = context.appTextStyles; + final colorTheme = context.appColors; + + final actions = [ + TextButton( + onPressed: Navigator.of(context).pop, + style: TextButton.styleFrom( + textStyle: textTheme.headlineBold, + foregroundColor: colorTheme.accentPrimary, + disabledForegroundColor: colorTheme.disabled, + ), + child: Text('Cancel'.toUpperCase()), + ), + TextButton( + onPressed: switch (_option == widget.initialOption) { + true => null, + false => () => Navigator.of(context).pop(_option), + }, + style: TextButton.styleFrom( + textStyle: textTheme.headlineBold, + foregroundColor: colorTheme.accentPrimary, + disabledForegroundColor: colorTheme.disabled, + ), + child: Text('Send'.toUpperCase()), + ), + ]; + + return AlertDialog( + title: Text( + 'Suggest an option', + style: textTheme.headlineBold, + ), + actions: actions, + titlePadding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + contentPadding: const EdgeInsets.all(16), + actionsPadding: const EdgeInsets.all(8), + backgroundColor: colorTheme.appBg, + content: StreamPollTextField( + autoFocus: true, + initialValue: _option, + hintText: 'Enter a new option', + contentPadding: const EdgeInsets.symmetric( + vertical: 12, + horizontal: 16, + ), + style: textTheme.headline, + fillColor: colorTheme.inputBg, + borderRadius: BorderRadius.circular(12), + onChanged: (value) => setState(() => _option = value), + ), + ); + } +} diff --git a/sample_app/lib/screens/user_feed/polls/show_poll/show_poll_widget.dart b/sample_app/lib/screens/user_feed/polls/show_poll/show_poll_widget.dart new file mode 100644 index 00000000..91cdf239 --- /dev/null +++ b/sample_app/lib/screens/user_feed/polls/show_poll/show_poll_widget.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_state_notifier/flutter_state_notifier.dart'; +import 'package:stream_feeds/stream_feeds.dart'; + +import '../../../../core/di/di_initializer.dart'; +import 'poll_message.dart'; + +class ShowPollWidget extends StatefulWidget { + const ShowPollWidget({ + super.key, + required this.feed, + required this.activity, + required this.poll, + }); + final Feed feed; + final ActivityData activity; + final PollData poll; + + @override + State createState() => _ShowPollWidgetState(); +} + +class _ShowPollWidgetState extends State + with AutomaticKeepAliveClientMixin { + StreamFeedsClient get client => locator(); + late Activity activity; + + @override + bool get wantKeepAlive => true; + + @override + void initState() { + super.initState(); + _getActivity(); + } + + @override + void didUpdateWidget(covariant ShowPollWidget oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.activity.id != widget.activity.id) { + activity.dispose(); + _getActivity(); + } + } + + @override + void dispose() { + activity.dispose(); + super.dispose(); + } + + void _getActivity() { + activity = client.activity( + activityId: widget.activity.id, + fid: widget.feed.fid, + ); + activity.get().ignore(); + } + + @override + Widget build(BuildContext context) { + super.build(context); + + return StateNotifierBuilder( + stateNotifier: activity.notifier, + builder: (context, state, child) { + final poll = state.poll ?? widget.poll; + return PollMessage( + poll: poll, + activity: activity, + currentUser: client.user, + ); + }, + ); + } +} diff --git a/sample_app/lib/screens/user_feed/polls/show_poll/stream_poll_comments_dialog.dart b/sample_app/lib/screens/user_feed/polls/show_poll/stream_poll_comments_dialog.dart new file mode 100644 index 00000000..dc4e40a3 --- /dev/null +++ b/sample_app/lib/screens/user_feed/polls/show_poll/stream_poll_comments_dialog.dart @@ -0,0 +1,118 @@ +import 'package:flutter/material.dart'; +import 'package:stream_feeds/stream_feeds.dart'; + +import '../../../../theme/extensions/theme_extensions.dart'; +import 'poll_add_comment_dialog.dart'; +import 'stream_poll_vote_list_tile.dart'; + +/// {@template showStreamPollCommentsDialog} +/// Displays an interactive dialog to show all the comments for a poll. +/// +/// The comments are paginated and get's loaded as the user scrolls. +/// +/// The dialog also allows the user to update their comment. +/// {@endtemplate} +Future showStreamPollCommentsDialog({ + required BuildContext context, + required Activity activity, + required PollData poll, +}) { + final navigator = Navigator.of(context); + return navigator.push( + MaterialPageRoute( + fullscreenDialog: true, + builder: (_) { + Future onUpdateComment() async { + final commentText = await showPollAddCommentDialog( + context: context, + // We use the first answer as the initial value because the + // user can only add one comment per poll. + initialValue: poll.ownAnswers.firstOrNull?.answerText ?? '', + ); + + if (commentText == null) return; + activity + .castPollVote( + CastPollVoteRequest(vote: VoteData(answerText: commentText)), + ) + .ignore(); + } + + return StreamPollCommentsDialog( + poll: poll, + onUpdateComment: onUpdateComment, + ); + }, + ), + ); +} + +/// {@template streamPollCommentsDialog} +/// A dialog that displays all the comments for a poll. +/// +/// The comments are paginated and get's loaded as the user scrolls. +/// +/// Provides a callback to update the user's comment. +/// {@endtemplate} +class StreamPollCommentsDialog extends StatelessWidget { + /// {@macro streamPollCommentsDialog} + const StreamPollCommentsDialog({ + super.key, + required this.poll, + this.onUpdateComment, + }); + + /// The poll to display the options for. + final PollData poll; + + /// Callback invoked when the user wants to cast a vote. + final VoidCallback? onUpdateComment; + + @override + Widget build(BuildContext context) { + final colorTheme = context.appColors; + final textTheme = context.appTextStyles; + + return Scaffold( + backgroundColor: colorTheme.appBg, + appBar: AppBar( + elevation: 0, + backgroundColor: colorTheme.appBg, + title: Text( + 'Poll comments', + style: textTheme.headlineBold, + ), + ), + body: ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: poll.latestAnswers.length, + itemBuilder: (context, index) { + final pollVote = poll.latestAnswers.elementAt(index); + + return StreamPollVoteListTile(pollVote: pollVote); + }, + ), + bottomNavigationBar: poll.isClosed + ? null + : SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: FilledButton.tonal( + onPressed: onUpdateComment, + style: TextButton.styleFrom( + textStyle: textTheme.headlineBold, + foregroundColor: colorTheme.accentPrimary, + disabledForegroundColor: colorTheme.disabled, + ), + child: Text( + switch (poll.ownAnswers.isEmpty) { + true => 'Add a comment', + false => 'Update your comment', + }, + ), + ), + ), + ), + ); + } +} diff --git a/sample_app/lib/screens/user_feed/polls/show_poll/stream_poll_interactor.dart b/sample_app/lib/screens/user_feed/polls/show_poll/stream_poll_interactor.dart new file mode 100644 index 00000000..d64a362f --- /dev/null +++ b/sample_app/lib/screens/user_feed/polls/show_poll/stream_poll_interactor.dart @@ -0,0 +1,137 @@ +import 'package:flutter/material.dart'; +import 'package:stream_feeds/stream_feeds.dart'; + +import 'poll_footer.dart'; +import 'poll_header.dart'; +import 'poll_options_list_view.dart'; + +/// {@template streamPollInteractor} +/// A widget that allows users to interact with a poll. +/// +/// The widget provides various callbacks to interact with the poll. +/// - [onCastVote] is called when the user wants to cast a vote. +/// - [onRemoveVote] is called when the user wants to remove a vote. +/// - [onEndVote] is called when the user wants to end the voting. +/// - [onAddComment] is called when the user wants to add a comment. +/// - [onViewComments] is called when the user wants to view all the comments. +/// - [onViewResults] is called when the user wants to view the poll results. +/// - [onSuggestOption] is called when the user wants to suggest an option. +/// - [onSeeMoreOptions] is called when the user wants to see more options. +/// +/// The widget also provides a [visibleOptionCount] parameter to control the +/// number of visible options in the poll. If null, all options will be visible. +/// {@endtemplate} +class StreamPollInteractor extends StatelessWidget { + /// {@macro streamPollInteractor} + const StreamPollInteractor({ + super.key, + required this.poll, + required this.currentUser, + this.padding = const EdgeInsets.symmetric( + vertical: 12, + horizontal: 10, + ), + this.visibleOptionCount, + this.onCastVote, + this.onRemoveVote, + this.onEndVote, + this.onAddComment, + this.onViewComments, + this.onViewResults, + this.onSuggestOption, + this.onSeeMoreOptions, + }); + + /// The poll to interact with. + final PollData poll; + + /// The current user interacting with the poll. + final User currentUser; + + /// The padding to apply to the interactor. + final EdgeInsetsGeometry padding; + + /// The number of visible options in the poll. + /// + /// If null, all options will be visible. + final int? visibleOptionCount; + + /// Callback invoked when the user wants to cast a vote. + /// + /// The [PollOptionData] parameter is the option the user wants to vote for. + final ValueChanged? onCastVote; + + /// Callback invoked when the user wants to remove a vote. + /// + /// The [PollVote] parameter is the vote the user wants to remove. + final ValueChanged? onRemoveVote; + + /// Callback invoked when the user wants to end the voting. + /// + /// This is only invoked if the [currentUser] is the creator of the poll. + final VoidCallback? onEndVote; + + /// Callback invoked when the user wants to add a comment. + /// + /// This is only invoked if the poll allows adding answers. + final VoidCallback? onAddComment; + + /// Callback invoked when the user wants to view all the comments. + /// + /// This is only invoked if the poll contains answers. + final VoidCallback? onViewComments; + + /// Callback invoked when the user wants to view the poll results. + final VoidCallback? onViewResults; + + /// Callback invoked when the user wants to suggest an option. + /// + /// This is only invoked if the poll allows user suggested options. + final VoidCallback? onSuggestOption; + + /// Callback invoked when the user wants to see more options. + /// + /// This is only invoked if the poll has more options than + /// [visibleOptionCount]. + final VoidCallback? onSeeMoreOptions; + + @override + Widget build(BuildContext context) { + return Padding( + padding: padding, + child: Column( + spacing: 8, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + PollHeader(poll: poll), + MediaQuery.removePadding( + context: context, + // Workaround for the bottom padding issue. + // Link: https://github.com/flutter/flutter/issues/156149 + removeTop: true, + removeBottom: true, + child: PollOptionsListView( + poll: poll, + showProgressBar: true, + visibleOptionCount: visibleOptionCount, + onCastVote: onCastVote, + onRemoveVote: onRemoveVote, + ), + ), + PollFooter( + poll: poll, + currentUser: currentUser, + visibleOptionCount: visibleOptionCount, + onEndVote: onEndVote, + onAddComment: onAddComment, + onViewComments: onViewComments, + onViewResults: onViewResults, + onSuggestOption: onSuggestOption, + onSeeMoreOptions: onSeeMoreOptions, + ), + ], + ), + ); + } +} diff --git a/sample_app/lib/screens/user_feed/polls/show_poll/stream_poll_options_dialog.dart b/sample_app/lib/screens/user_feed/polls/show_poll/stream_poll_options_dialog.dart new file mode 100644 index 00000000..c1d43074 --- /dev/null +++ b/sample_app/lib/screens/user_feed/polls/show_poll/stream_poll_options_dialog.dart @@ -0,0 +1,155 @@ +import 'package:flutter/material.dart'; +import 'package:stream_feeds/stream_feeds.dart'; + +import '../../../../theme/extensions/theme_extensions.dart'; +import 'poll_options_list_view.dart'; +import 'stream_poll_results_dialog.dart'; + +/// {@template showStreamPollOptionsDialog} +/// Displays an interactive dialog to show all the available options for a poll. +/// +/// The dialog allows the user to cast a vote or remove a vote. +/// {@endtemplate} +Future showStreamPollOptionsDialog({ + required BuildContext context, + required Activity activity, + required PollData poll, +}) { + final navigator = Navigator.of(context); + return navigator.push( + MaterialPageRoute( + fullscreenDialog: true, + builder: (_) { + void onCastVote(PollOptionData option) { + activity.castPollVote( + CastPollVoteRequest(vote: VoteData(optionId: option.id)), + ); + } + + void onRemoveVote(PollVoteData vote) { + activity.deletePollVote(voteId: vote.id); + } + + return StreamPollOptionsDialog( + poll: poll, + onCastVote: onCastVote, + onRemoveVote: onRemoveVote, + ); + }, + ), + ); +} + +/// {@template streamPollOptionsDialog} +/// A dialog that displays all the available options for a poll. +/// +/// Provides callbacks when a vote has been cast or removed from the poll. +/// {@endtemplate} +class StreamPollOptionsDialog extends StatelessWidget { + /// {@macro streamPollOptionsDialog} + const StreamPollOptionsDialog({ + super.key, + required this.poll, + this.onCastVote, + this.onRemoveVote, + }); + + /// The poll to display the options for. + final PollData poll; + + /// Callback invoked when the user wants to cast a vote. + /// + /// The [PollOption] parameter is the option the user wants to vote for. + final ValueChanged? onCastVote; + + /// Callback invoked when the user wants to remove a vote. + /// + /// The [PollVote] parameter is the vote the user wants to remove. + final ValueChanged? onRemoveVote; + + @override + Widget build(BuildContext context) { + final textTheme = context.appTextStyles; + final colorTheme = context.appColors; + + return Scaffold( + backgroundColor: colorTheme.appBg, + appBar: AppBar( + elevation: 0, + backgroundColor: colorTheme.appBg, + title: Text( + 'Poll options', + style: textTheme.headlineBold, + ), + ), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + PollOptionsQuestion( + question: poll.name, + ), + Container( + padding: const EdgeInsets.symmetric( + vertical: 12, + horizontal: 16, + ), + decoration: BoxDecoration( + color: colorTheme.appBg, + border: Border.all( + color: colorTheme.appBg, + ), + ), + child: MediaQuery.removePadding( + context: context, + removeTop: true, + removeBottom: true, + child: PollOptionsListView( + poll: poll, + onCastVote: onCastVote, + onRemoveVote: onRemoveVote, + ), + ), + ), + ].insertBetween(const SizedBox(height: 32)), + ), + ); + } +} + +/// {@template pollOptionsQuestion} +/// A widget that displays the question of a poll. +/// {@endtemplate} +class PollOptionsQuestion extends StatelessWidget { + /// {@macro pollOptionsQuestion} + const PollOptionsQuestion({ + super.key, + required this.question, + }); + + /// The question of the poll. + final String question; + + @override + Widget build(BuildContext context) { + final textTheme = context.appTextStyles; + final colorTheme = context.appColors; + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colorTheme.appBg, + border: Border.all( + color: colorTheme.appBg, + ), + ), + constraints: const BoxConstraints( + minHeight: 56, + minWidth: double.infinity, + ), + child: Text( + question, + style: textTheme.headlineBold, + ), + ); + } +} diff --git a/sample_app/lib/screens/user_feed/polls/show_poll/stream_poll_results_dialog.dart b/sample_app/lib/screens/user_feed/polls/show_poll/stream_poll_results_dialog.dart new file mode 100644 index 00000000..39986f12 --- /dev/null +++ b/sample_app/lib/screens/user_feed/polls/show_poll/stream_poll_results_dialog.dart @@ -0,0 +1,351 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:stream_feeds/stream_feeds.dart'; + +import '../../../../theme/theme.dart'; +import 'stream_poll_vote_list_tile.dart'; + +/// {@template showStreamPollResultsDialog} +/// Displays an interactive dialog to show the results of a poll. +/// +/// The dialog allows the user to see the results of the poll. The results are +/// displayed in a list of options with the number of votes each option has +/// received and the users who have voted for that option. +/// +/// By default, only the first 5 votes are shown for each option. The user can +/// see all the votes for an option by pressing the "Show all votes" button. +/// +/// The dialog is updated in real-time as new votes are cast. +/// +/// {@endtemplate} +Future showStreamPollResultsDialog({ + required BuildContext context, + required PollData poll, +}) { + final navigator = Navigator.of(context); + return navigator.push( + MaterialPageRoute( + fullscreenDialog: true, + builder: (_) { + void onShowAllVotesPressed(PollOptionData option) { + // showStreamPollOptionVotesDialog( + // context: context, + // messageNotifier: messageNotifier, + // option: option, + // ); + } + + return StreamPollResultsDialog( + poll: poll, + visibleVotesCount: 5, + onShowAllVotesPressed: onShowAllVotesPressed, + ); + }, + ), + ); +} + +/// {@template streamPollResultsDialog} +/// A dialog that displays the results of a poll. +/// +/// The results are displayed in a list of options with the number of votes each +/// option has received and the users who have voted for that option. +/// +/// By default, only the latest votes are shown for each option. The user can +/// see all the votes for an option by pressing the "Show all votes" button. +/// +/// The dialog is updated in real-time as new votes are cast. +/// {@endtemplate} +class StreamPollResultsDialog extends StatelessWidget { + /// {@macro streamPollResultsDialog} + const StreamPollResultsDialog({ + super.key, + required this.poll, + this.visibleVotesCount, + this.onShowAllVotesPressed, + }); + + /// The poll to display the results for. + final PollData poll; + + /// The number of votes to show for each option. + final int? visibleVotesCount; + + /// Callback invoked when the "Show all votes" button is pressed. + final ValueSetter? onShowAllVotesPressed; + + @override + Widget build(BuildContext context) { + final colorTheme = context.appColors; + final textTheme = context.appTextStyles; + + return Scaffold( + backgroundColor: colorTheme.appBg, + appBar: AppBar( + elevation: 0, + backgroundColor: colorTheme.appBg, + title: Text( + 'Poll results', + style: textTheme.headlineBold, + ), + ), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + PollResultsQuestion(question: poll.name), + PollVotesByOptionListView( + poll: poll, + visibleVotesCount: visibleVotesCount, + onShowAllVotesPressed: onShowAllVotesPressed, + ), + ].insertBetween(const SizedBox(height: 32)), + ), + ); + } +} + +/// {@template pollResultsQuestion} +/// A widget that displays the question of a poll. +/// {@endtemplate} +class PollResultsQuestion extends StatelessWidget { + /// {@macro pollResultsQuestion} + const PollResultsQuestion({ + super.key, + required this.question, + }); + + /// The question of the poll. + final String question; + + @override + Widget build(BuildContext context) { + final textTheme = context.appTextStyles; + final colorTheme = context.appColors; + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colorTheme.appBg, + border: Border.all( + color: colorTheme.appBg, + ), + ), + constraints: const BoxConstraints( + minHeight: 56, + minWidth: double.infinity, + ), + child: Text( + question, + style: textTheme.headlineBold, + ), + ); + } +} + +/// {@template pollVotesByOptionListView} +/// A list of poll options with the latest votes for each option. +/// +/// Displays a button with a callback [onShowAllVotesPressed] to show all votes +/// for an option if there are more votes than the [visibleVotesCount]. +/// +/// By default, The options are sorted by the number of votes they have +/// received in descending order. +/// {@endtemplate} +class PollVotesByOptionListView extends StatelessWidget { + /// {@macro pollVotesByOptionListView} + const PollVotesByOptionListView({ + super.key, + required this.poll, + this.visibleVotesCount, + this.onShowAllVotesPressed, + }); + + /// The poll the options are for. + final PollData poll; + + /// The number of votes to show for each option. + /// + /// If the number of votes for an option is greater than this value, a button + /// is displayed to show all votes for that option. + final int? visibleVotesCount; + + /// Callback invoked when the "Show all votes" button is pressed. + final ValueSetter? onShowAllVotesPressed; + + @override + Widget build(BuildContext context) { + final latestVotesByOption = poll.latestVotesByOption; + final voteCountsByOption = poll.voteCountsByOption; + final pollOptions = poll.options.sorted((a, b) { + final optionAVoteCounts = voteCountsByOption[a.id] ?? 0; + final optionBVoteCounts = voteCountsByOption[b.id] ?? 0; + return optionBVoteCounts.compareTo(optionAVoteCounts); + }); + + return ListView.separated( + shrinkWrap: true, + itemCount: pollOptions.length, + physics: const NeverScrollableScrollPhysics(), + separatorBuilder: (context, index) => const SizedBox(height: 8), + itemBuilder: (context, index) { + final option = pollOptions.elementAt(index); + final latestPollVotes = latestVotesByOption[option.id] ?? []; + final pollVotesCount = voteCountsByOption[option.id] ?? 0; + + return PollVotesByOptionItem( + option: option, + pollVotesCount: pollVotesCount, + latestPollVotes: latestPollVotes, + visibleVotesCount: visibleVotesCount, + isOptionWinner: poll.isOptionWithMaximumVotes(option), + onShowAllVotesPressed: switch (onShowAllVotesPressed) { + final onShowAllVotesPressed? => () => onShowAllVotesPressed(option), + _ => null, + }, + ); + }, + ); + } +} + +/// {@template pollVotesByOptionItem} +/// A widget that displays the votes for a poll option. +/// +/// The widget is used in [PollVotesByOptionListView] to display the votes for +/// each option in a poll. +/// +/// Displays a award icon next to the option if [isOptionWinner] is true. +/// {@endtemplate} +class PollVotesByOptionItem extends StatelessWidget { + /// {@macro pollVotesByOptionItem} + const PollVotesByOptionItem({ + super.key, + required this.option, + required this.latestPollVotes, + required this.pollVotesCount, + this.isOptionWinner = false, + this.visibleVotesCount, + this.onShowAllVotesPressed, + }); + + /// The option to display the votes for. + final PollOptionData option; + + /// The available latest votes for the option. + final List latestPollVotes; + + /// The total number of votes for the option. + final int pollVotesCount; + + /// Whether the option is the winner of the poll. + final bool isOptionWinner; + + /// The number of votes to show for the option. + /// + /// If this is less than the [pollVotesCount] a button is displayed to show + /// all votes for the option. + final int? visibleVotesCount; + + /// Callback invoked when the "Show all votes" button is pressed. + final VoidCallback? onShowAllVotesPressed; + + @override + Widget build(BuildContext context) { + final colorTheme = context.appColors; + final textTheme = context.appTextStyles; + + final votes = switch (visibleVotesCount) { + final visibleVotesCount? => latestPollVotes.take(visibleVotesCount), + _ => latestPollVotes, + }; + + return Container( + padding: const EdgeInsets.symmetric( + vertical: 12, + horizontal: 16, + ), + decoration: isOptionWinner + ? BoxDecoration( + color: colorTheme.appBg, + border: Border.all( + color: colorTheme.accentInfo, + ), + ) + : BoxDecoration( + color: colorTheme.appBg, + border: Border.all( + color: colorTheme.appBg, + ), + ), + child: Column( + spacing: 16, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: Text( + option.text, + style: isOptionWinner ? textTheme.bodyBold : textTheme.body, + ), + ), + const SizedBox(width: 8), + if (isOptionWinner) ...[ + Icon( + Icons.grade, + color: colorTheme.accentInfo, + ), + const SizedBox(width: 8), + ], + Text( + '$pollVotesCount votes', + style: isOptionWinner ? textTheme.bodyBold : textTheme.body, + ), + ], + ), + if (votes.isNotEmpty) + Flexible( + child: ListView.separated( + shrinkWrap: true, + itemCount: votes.length, + physics: const NeverScrollableScrollPhysics(), + separatorBuilder: (_, __) => const SizedBox(height: 16), + itemBuilder: (context, index) { + final pollVote = votes.elementAt(index); + return StreamPollVoteListTile( + pollVote: pollVote, + contentPadding: const EdgeInsets.symmetric(vertical: 6), + ); + }, + ), + ), + if (votes.length < latestPollVotes.length) + TextButton( + onPressed: onShowAllVotesPressed, + style: TextButton.styleFrom( + backgroundColor: colorTheme.accentInfo, + foregroundColor: colorTheme.appBg, + ), + child: Text( + 'Show all votes ($pollVotesCount)', + ), + ), + ], + ), + ); + } +} + +extension IterableExtension on Iterable { + /// Insert any item inBetween the list items + List insertBetween(T item) => expand((e) sync* { + yield item; + yield e; + }).skip(1).toList(growable: false); +} + +extension on PollData { + bool isOptionWithMaximumVotes(PollOptionData option) { + return voteCountsByOption[option.id] == voteCountsByOption.values.max; + } +} diff --git a/sample_app/lib/screens/user_feed/polls/show_poll/stream_poll_vote_list_tile.dart b/sample_app/lib/screens/user_feed/polls/show_poll/stream_poll_vote_list_tile.dart new file mode 100644 index 00000000..a66f4d8c --- /dev/null +++ b/sample_app/lib/screens/user_feed/polls/show_poll/stream_poll_vote_list_tile.dart @@ -0,0 +1,135 @@ +import 'package:flutter/material.dart'; +import 'package:stream_feeds/stream_feeds.dart'; + +import '../../../../theme/extensions/theme_extensions.dart'; +import '../../../../widgets/user_avatar.dart'; + +class StreamPollVoteListTile extends StatelessWidget { + const StreamPollVoteListTile({ + super.key, + required this.pollVote, + this.showAnswerText = true, + this.onTap, + this.onLongPress, + this.tileColor, + this.borderRadius, + this.contentPadding, + }); + + /// The poll vote to display the tile for. + final PollVoteData pollVote; + + /// Whether to show the answer text. + final bool showAnswerText; + + /// Called when the user taps this list tile. + final GestureTapCallback? onTap; + + /// Called when the user long-presses on this list tile. + final GestureLongPressCallback? onLongPress; + + /// Defines the background color of the tile. + final Color? tileColor; + + /// The tile's border radius. + final BorderRadiusGeometry? borderRadius; + + /// The tile's internal padding. + final EdgeInsetsGeometry? contentPadding; + + /// Creates a copy of this tile but with the given fields replaced with + /// the new values. + StreamPollVoteListTile copyWith({ + Key? key, + PollVoteData? pollVote, + bool? showAnswerText, + GestureTapCallback? onTap, + GestureLongPressCallback? onLongPress, + Color? tileColor, + BorderRadiusGeometry? borderRadius, + EdgeInsetsGeometry? contentPadding, + }) => + StreamPollVoteListTile( + key: key ?? this.key, + pollVote: pollVote ?? this.pollVote, + showAnswerText: showAnswerText ?? this.showAnswerText, + onTap: onTap ?? this.onTap, + onLongPress: onLongPress ?? this.onLongPress, + tileColor: tileColor ?? this.tileColor, + borderRadius: borderRadius ?? this.borderRadius, + contentPadding: contentPadding ?? this.contentPadding, + ); + + @override + Widget build(BuildContext context) { + final textTheme = context.appTextStyles; + final colorTheme = context.appColors; + + return InkWell( + onTap: onTap, + onLongPress: onLongPress, + child: Container( + padding: contentPadding, + decoration: BoxDecoration( + color: tileColor, + borderRadius: borderRadius, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (pollVote.answerText case final answerText? + when showAnswerText) ...[ + Text( + answerText, + style: textTheme.headlineBold.copyWith( + color: colorTheme.textHighEmphasis, + ), + ), + const SizedBox(height: 16), + ], + Row( + children: [ + if (pollVote.user case final user?) ...[ + UserAvatar( + user: User(id: user.id, name: user.name, image: user.image), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Text( + user.name ?? user.id, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textTheme.body.copyWith( + color: colorTheme.textHighEmphasis, + ), + ), + ), + ), + ], + PollVoteUpdatedAt( + dateTime: pollVote.updatedAt.toLocal(), + ), + ], + ), + ], + ), + ), + ); + } +} + +class PollVoteUpdatedAt extends StatelessWidget { + const PollVoteUpdatedAt({ + super.key, + required this.dateTime, + }); + + /// The date and time when the poll vote was last updated. + final DateTime dateTime; + + @override + Widget build(BuildContext context) { + return Text(dateTime.toString()); + } +} diff --git a/sample_app/lib/screens/user_feed/user_feed_screen.dart b/sample_app/lib/screens/user_feed/user_feed_screen.dart index c567103d..d88e2766 100644 --- a/sample_app/lib/screens/user_feed/user_feed_screen.dart +++ b/sample_app/lib/screens/user_feed/user_feed_screen.dart @@ -111,15 +111,17 @@ class _UserFeedScreenState extends State { children: [ ...?switch (breakpoint) { Breakpoint.compact => null, + Breakpoint.medium => [ + Flexible(child: UserProfile(userFeed: userFeed)), + VerticalDivider( + width: 8, + color: context.appColors.borders, + ), + ], _ => [ - Flexible( - child: ConstrainedBox( - constraints: const BoxConstraints( - minWidth: 280, - maxWidth: 420, - ), - child: UserProfile(userFeed: userFeed), - ), + SizedBox( + width: 420, + child: UserProfile(userFeed: userFeed), ), VerticalDivider( width: 8, @@ -193,7 +195,7 @@ class _UserFeedScreenState extends State { } Future _showCreateActivityBottomSheet() async { - final request = await showModalBottomSheet( + final request = await showModalBottomSheet( context: context, useSafeArea: true, isScrollControlled: true, @@ -211,9 +213,18 @@ class _UserFeedScreenState extends State { ); if (request == null) return; - final result = await userFeed.addActivity(request: request); - switch (result) { + late Result activityResult; + if (request is FeedAddActivityRequest) { + activityResult = await userFeed.addActivity(request: request); + } else if (request is CreatePollRequest) { + activityResult = + await userFeed.createPoll(request: request, activityType: 'poll'); + } else { + return; + } + + switch (activityResult) { case Success(): if (mounted) { ScaffoldMessenger.of(context).showSnackBar( diff --git a/sample_app/lib/screens/user_feed/widgets/create_activity_bottom_sheet.dart b/sample_app/lib/screens/user_feed/widgets/create_activity_bottom_sheet.dart index 76d90abb..7132d0f1 100644 --- a/sample_app/lib/screens/user_feed/widgets/create_activity_bottom_sheet.dart +++ b/sample_app/lib/screens/user_feed/widgets/create_activity_bottom_sheet.dart @@ -4,6 +4,7 @@ import 'package:stream_feeds/stream_feeds.dart'; import '../../../theme/extensions/theme_extensions.dart'; import '../../../widgets/attachments/attachments.dart'; import '../../../widgets/user_avatar.dart'; +import '../polls/create_poll/create_poll_state.dart'; /// A bottom sheet for creating new activities with text and attachments. /// @@ -72,6 +73,7 @@ class _CreateActivityBottomSheetState extends State { // Attachment picker AttachmentPicker( onAttachmentsSelected: _addAttachments, + onPollCreated: _createPoll, ), // Bottom padding to avoid being too close to screen edge @@ -194,4 +196,44 @@ class _CreateActivityBottomSheetState extends State { // Return the request to the parent for handling Navigator.pop(context, request); } + + void _createPoll(CreatePollState poll) { + final request = poll.toCreatePollRequest(); + + // Return the request to the parent for handling + Navigator.pop(context, request); + } +} + +extension on CreatePollState { + CreatePollRequest toCreatePollRequest() { + return CreatePollRequest( + name: name, + options: options.map((e) => e.toCreatePollOptionRequest()).toList(), + enforceUniqueVote: enforceUniqueVote, + maxVotesAllowed: maxVotesAllowed, + allowUserSuggestedOptions: allowUserSuggestedOptions, + votingVisibility: votingVisibility?.toRequest(), + allowAnswers: allowAnswers, + description: description, + isClosed: isClosed, + ); + } +} + +extension on PollOptionInputState { + PollOptionInput toCreatePollOptionRequest() { + return PollOptionInput( + text: text, + ); + } +} + +extension on VotingVisibility { + CreatePollRequestVotingVisibility toRequest() { + return switch (this) { + VotingVisibility.public => CreatePollRequestVotingVisibility.public, + VotingVisibility.anonymous => CreatePollRequestVotingVisibility.anonymous, + }; + } } diff --git a/sample_app/lib/widgets/attachments/attachment_picker.dart b/sample_app/lib/widgets/attachments/attachment_picker.dart index e90fdfbf..700660a1 100644 --- a/sample_app/lib/widgets/attachments/attachment_picker.dart +++ b/sample_app/lib/widgets/attachments/attachment_picker.dart @@ -2,6 +2,8 @@ import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:stream_feeds/stream_feeds.dart'; +import '../../screens/user_feed/polls/create_poll/create_poll_state.dart'; +import '../../screens/user_feed/polls/create_poll/stream_poll_creator_dialog.dart'; import '../../theme/extensions/theme_extensions.dart'; /// A widget that provides attachment selection functionality. @@ -12,10 +14,12 @@ class AttachmentPicker extends StatelessWidget { const AttachmentPicker({ super.key, required this.onAttachmentsSelected, + required this.onPollCreated, }); /// Callback when attachments are selected by the user. final void Function(List attachments) onAttachmentsSelected; + final void Function(CreatePollState) onPollCreated; @override Widget build(BuildContext context) { @@ -34,6 +38,11 @@ class AttachmentPicker extends StatelessWidget { label: 'Videos', onTap: () => _pickVideos(context), ), + _AttachmentButton( + icon: Icons.poll, + label: 'Polls', + onTap: () => _createPoll(context), + ), ], ), ); @@ -85,6 +94,13 @@ class AttachmentPicker extends StatelessWidget { } } + Future _createPoll(BuildContext context) async { + final result = await showStreamPollCreatorDialog(context: context); + if (result != null) { + onPollCreated(result); + } + } + List _convertToStreamAttachments( List files, { AttachmentType type = AttachmentType.file, diff --git a/sample_app/pubspec.yaml b/sample_app/pubspec.yaml index 6d8651ef..3b584278 100644 --- a/sample_app/pubspec.yaml +++ b/sample_app/pubspec.yaml @@ -20,6 +20,7 @@ dependencies: flutter_local_notifications: ^18.0.1 flutter_state_notifier: ^1.0.0 flutter_svg: ^2.2.0 + freezed_annotation: ^3.0.0 get_it: ^8.0.3 google_fonts: ^6.3.0 injectable: ^2.5.1 @@ -36,6 +37,7 @@ dev_dependencies: flutter_launcher_icons: ^0.14.4 flutter_test: sdk: flutter + freezed: ^3.0.0 injectable_generator: ^2.7.0 json_serializable: ^6.9.5