diff --git a/packages/stream_chat/lib/src/client/channel.dart b/packages/stream_chat/lib/src/client/channel.dart index 49f0075e7..1ab584624 100644 --- a/packages/stream_chat/lib/src/client/channel.dart +++ b/packages/stream_chat/lib/src/client/channel.dart @@ -1035,6 +1035,36 @@ class Channel { return _client.sendEvent(id!, type, event); } + /// Send a message with a poll to this channel. + /// + /// Optionally provide a [messageText] to send a message along with the poll. + Future sendPoll( + Poll poll, { + String messageText = '', + }) async { + _checkInitialized(); + final res = await _client.createPoll(poll); + return sendMessage( + Message( + text: messageText, + poll: res.poll, + pollId: res.poll.id, + ), + ); + } + + /// Updates the [poll] in this channel. + Future updatePoll(Poll poll) { + _checkInitialized(); + return _client.updatePoll(poll); + } + + /// Deletes the poll with the given [pollId] from this channel. + Future deletePoll(String pollId) { + _checkInitialized(); + return _client.deletePoll(pollId); + } + /// Send a reaction to this channel. /// /// Set [enforceUnique] to true to remove the existing user reaction. diff --git a/packages/stream_chat/lib/src/core/models/poll.dart b/packages/stream_chat/lib/src/core/models/poll.dart index b95122c74..27c6e9407 100644 --- a/packages/stream_chat/lib/src/core/models/poll.dart +++ b/packages/stream_chat/lib/src/core/models/poll.dart @@ -9,6 +9,12 @@ import 'package:uuid/uuid.dart'; part 'poll.g.dart'; +class _NullConst { + const _NullConst(); +} + +const _nullConst = _NullConst(); + /// {@template streamVotingVisibility} /// Represents the visibility of the voting process. /// {@endtemplate} @@ -32,7 +38,7 @@ class Poll extends Equatable { this.description, required this.options, this.votingVisibility = VotingVisibility.public, - this.enforceUniqueVote = false, + this.enforceUniqueVote = true, this.maxVotesAllowed, this.allowAnswers = false, this.answers = const [], @@ -159,7 +165,7 @@ class Poll extends Equatable { List? options, VotingVisibility? votingVisibility, bool? enforceUniqueVote, - int? maxVotesAllowed, + Object? maxVotesAllowed = _nullConst, bool? allowUserSuggestedOptions, bool? allowAnswers, bool? isClosed, @@ -182,7 +188,9 @@ class Poll extends Equatable { options: options ?? this.options, votingVisibility: votingVisibility ?? this.votingVisibility, enforceUniqueVote: enforceUniqueVote ?? this.enforceUniqueVote, - maxVotesAllowed: maxVotesAllowed ?? this.maxVotesAllowed, + maxVotesAllowed: maxVotesAllowed == _nullConst + ? this.maxVotesAllowed + : maxVotesAllowed as int?, allowUserSuggestedOptions: allowUserSuggestedOptions ?? this.allowUserSuggestedOptions, allowAnswers: allowAnswers ?? this.allowAnswers, diff --git a/packages/stream_chat/lib/src/core/models/poll.g.dart b/packages/stream_chat/lib/src/core/models/poll.g.dart index 883e82bae..8a31e1d2b 100644 --- a/packages/stream_chat/lib/src/core/models/poll.g.dart +++ b/packages/stream_chat/lib/src/core/models/poll.g.dart @@ -16,7 +16,7 @@ Poll _$PollFromJson(Map json) => Poll( votingVisibility: $enumDecodeNullable( _$VotingVisibilityEnumMap, json['voting_visibility']) ?? VotingVisibility.public, - enforceUniqueVote: json['enforce_unique_vote'] as bool? ?? false, + enforceUniqueVote: json['enforce_unique_vote'] as bool? ?? true, maxVotesAllowed: (json['max_votes_allowed'] as num?)?.toInt(), allowAnswers: json['allow_answers'] as bool? ?? false, answers: (json['latest_answers'] as List?) diff --git a/packages/stream_chat/lib/src/core/models/poll_option.dart b/packages/stream_chat/lib/src/core/models/poll_option.dart index b85fc4e27..9ef99d07e 100644 --- a/packages/stream_chat/lib/src/core/models/poll_option.dart +++ b/packages/stream_chat/lib/src/core/models/poll_option.dart @@ -4,6 +4,12 @@ import 'package:stream_chat/src/core/util/serializer.dart'; part 'poll_option.g.dart'; +class _NullConst { + const _NullConst(); +} + +const _nullConst = _NullConst(); + /// {@template streamPollOption} /// A model class representing a poll option. /// {@endtemplate} @@ -23,7 +29,7 @@ class PollOption extends Equatable { ); /// The unique identifier of the poll option. - @JsonKey(includeToJson: false) + @JsonKey(includeIfNull: false) final String? id; /// The text describing the poll option. @@ -36,6 +42,18 @@ class PollOption extends Equatable { Map toJson() => Serializer.moveFromExtraDataToRoot(_$PollOptionToJson(this)); + /// Creates a copy of [PollOption] with specified attributes overridden. + PollOption copyWith({ + Object? id = _nullConst, + String? text, + Map? extraData, + }) => + PollOption( + id: id == _nullConst ? this.id : id as String?, + text: text ?? this.text, + extraData: extraData ?? this.extraData, + ); + /// Known top level fields. /// /// Useful for [Serializer] methods. diff --git a/packages/stream_chat/lib/src/core/models/poll_option.g.dart b/packages/stream_chat/lib/src/core/models/poll_option.g.dart index 69361bbea..623d5e4fc 100644 --- a/packages/stream_chat/lib/src/core/models/poll_option.g.dart +++ b/packages/stream_chat/lib/src/core/models/poll_option.g.dart @@ -12,8 +12,17 @@ PollOption _$PollOptionFromJson(Map json) => PollOption( extraData: json['extra_data'] as Map? ?? const {}, ); -Map _$PollOptionToJson(PollOption instance) => - { - 'text': instance.text, - 'extra_data': instance.extraData, - }; +Map _$PollOptionToJson(PollOption instance) { + final val = {}; + + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } + + writeNotNull('id', instance.id); + val['text'] = instance.text; + val['extra_data'] = instance.extraData; + return val; +} diff --git a/packages/stream_chat/lib/src/core/models/poll_vote.dart b/packages/stream_chat/lib/src/core/models/poll_vote.dart index c091acb80..03f556a69 100644 --- a/packages/stream_chat/lib/src/core/models/poll_vote.dart +++ b/packages/stream_chat/lib/src/core/models/poll_vote.dart @@ -32,7 +32,7 @@ class PollVote extends Equatable { _$PollVoteFromJson(json); /// The unique identifier of the poll vote. - @JsonKey(includeToJson: false) + @JsonKey(includeIfNull: false) final String? id; /// The unique identifier of the option selected in the poll. diff --git a/packages/stream_chat/lib/src/core/models/poll_vote.g.dart b/packages/stream_chat/lib/src/core/models/poll_vote.g.dart index 12dba7e61..37f8aec79 100644 --- a/packages/stream_chat/lib/src/core/models/poll_vote.g.dart +++ b/packages/stream_chat/lib/src/core/models/poll_vote.g.dart @@ -32,6 +32,7 @@ Map _$PollVoteToJson(PollVote instance) { } } + writeNotNull('id', instance.id); writeNotNull('option_id', instance.optionId); writeNotNull('answer_text', instance.answerText); return val; diff --git a/packages/stream_chat/lib/src/db/chat_persistence_client.dart b/packages/stream_chat/lib/src/db/chat_persistence_client.dart index 9eefe186e..1199eefab 100644 --- a/packages/stream_chat/lib/src/db/chat_persistence_client.dart +++ b/packages/stream_chat/lib/src/db/chat_persistence_client.dart @@ -255,10 +255,11 @@ abstract class ChatPersistenceClient { final members = state.members; final Iterable? messages; if (CurrentPlatform.isWeb) { - messages = state.messages?.where((it) => !it.attachments.any( - (attachment) => - attachment.uploadState != const UploadState.success(), - )); + messages = state.messages?.where( + (it) => !it.attachments.any( + (it) => it.uploadState != const UploadState.success(), + ), + ); } else { messages = state.messages; } diff --git a/packages/stream_chat/lib/src/permission_type.dart b/packages/stream_chat/lib/src/permission_type.dart index 993351b72..24b022723 100644 --- a/packages/stream_chat/lib/src/permission_type.dart +++ b/packages/stream_chat/lib/src/permission_type.dart @@ -93,4 +93,7 @@ class PermissionType { /// Capability required to update/edit channel members /// Channel is not distinct and user has UpdateChannelMembers permission static const String updateChannelMembers = 'update-channel-members'; + + /// Capability required to send a poll in a channel. + static const String sendPoll = 'send-poll'; } diff --git a/packages/stream_chat/test/src/core/models/poll_test.dart b/packages/stream_chat/test/src/core/models/poll_test.dart index af7907d9d..be0bd9f4e 100644 --- a/packages/stream_chat/test/src/core/models/poll_test.dart +++ b/packages/stream_chat/test/src/core/models/poll_test.dart @@ -62,10 +62,13 @@ void main() { expect(json['name'], 'test'); expect(json['description'], isNull); expect(json['options'], [ - {'text': 'option1 text'} + { + 'id': 'option1', + 'text': 'option1 text', + } ]); expect(json['voting_visibility'], 'public'); - expect(json['enforce_unique_vote'], false); + expect(json['enforce_unique_vote'], true); expect(json['max_votes_allowed'], isNull); expect(json['allow_user_suggested_options'], false); expect(json['allow_answers'], false); diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index 053822ce8..767217957 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -1,3 +1,9 @@ +## Upcoming + +✅ Added + +- Added a new `StreamPollCreator` widget to facilitate poll creation within the chat interface. + ## 8.3.0 ✅ Added diff --git a/packages/stream_chat_flutter/lib/src/indicators/unread_indicator.dart b/packages/stream_chat_flutter/lib/src/indicators/unread_indicator.dart index f13533b44..cb6ce01ea 100644 --- a/packages/stream_chat_flutter/lib/src/indicators/unread_indicator.dart +++ b/packages/stream_chat_flutter/lib/src/indicators/unread_indicator.dart @@ -16,6 +16,7 @@ class StreamUnreadIndicator extends StatelessWidget { @override Widget build(BuildContext context) { + final theme = StreamChatTheme.of(context); final client = StreamChat.of(context).client; return IgnorePointer( child: BetterStreamBuilder( @@ -25,31 +26,18 @@ class StreamUnreadIndicator extends StatelessWidget { initialData: cid != null ? client.state.channels[cid]?.state?.unreadCount : client.state.totalUnreadCount, - builder: (context, data) { - if (data == 0) { - return const Offstage(); - } - return Material( - borderRadius: BorderRadius.circular(8), - color: StreamChatTheme.of(context) - .channelPreviewTheme - .unreadCounterColor, - child: Padding( - padding: const EdgeInsets.only( - left: 5, - right: 5, - top: 2, - bottom: 1, - ), - child: Center( - child: Text( - '${data > 99 ? '99+' : data}', - style: const TextStyle( - fontSize: 11, - color: Colors.white, - ), - ), - ), + builder: (context, unreadCount) { + if (unreadCount == 0) return const SizedBox.shrink(); + + return Badge( + textColor: Colors.white, + textStyle: theme.textTheme.footnoteBold, + backgroundColor: theme.channelPreviewTheme.unreadCounterColor, + label: Text( + switch (unreadCount) { + > 99 => '99+', + _ => '$unreadCount', + }, ), ); }, diff --git a/packages/stream_chat_flutter/lib/src/localization/translations.dart b/packages/stream_chat_flutter/lib/src/localization/translations.dart index 3cf3fd42f..ce962970e 100644 --- a/packages/stream_chat_flutter/lib/src/localization/translations.dart +++ b/packages/stream_chat_flutter/lib/src/localization/translations.dart @@ -2,7 +2,7 @@ import 'package:jiffy/jiffy.dart'; import 'package:stream_chat_flutter/src/message_list_view/message_list_view.dart'; import 'package:stream_chat_flutter/src/misc/connection_status_builder.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart' - show User; + show Range, User; /// Translation strings for the stream chat widgets abstract class Translations { @@ -370,6 +370,63 @@ abstract class Translations { /// The text for "MUTE"/"UNMUTE" based on the value of [isMuted]. String toggleMuteUnmuteAction({required bool isMuted}); + + /// The label for "Create poll". + /// + /// If [isNew] is true, it returns "Create a new poll". + String createPollLabel({bool isNew = false}); + + /// The label for "Questions". + String get questionsLabel; + + /// The label for "Ask a question". + String get askAQuestionLabel; + + /// The error shown when the poll question [length] is not within the [range]. + /// + /// Returns 'Question must be a least ${range.min} characters long' if the + /// question is too short and 'Question must be at most ${range.max} + /// characters long' if the question is too long. + String? pollQuestionValidationError(int length, Range range); + + /// The label for "Option". + /// + /// If [isPlural] is true, it returns "Options". + String optionLabel({bool isPlural = false}); + + /// The error shown when the poll option text is empty. + String get pollOptionEmptyError; + + /// The error shown when the poll option is a duplicate. + String get pollOptionDuplicateError; + + /// The label for "Add an option". + String get addAnOptionLabel; + + /// The label for "Multiple answers". + String get multipleAnswersLabel; + + /// The label for "Maximum votes per person". + String get maximumVotesPerPersonLabel; + + /// The error shown when the max [votes] is not within the [range]. + /// + /// Returns 'Vote count must be at least ${range.min}' if the vote count is + /// too short and 'Vote count must be at most ${range.max}' if the vote count + /// is too long. + String? maxVotesPerPersonValidationError(int votes, Range range); + + /// The label for "Anonymous poll". + String get anonymousPollLabel; + + /// The label for "Suggest an option". + String get suggestAnOptionLabel; + + /// The label for "Add a comment". + String get addACommentLabel; + + /// The label for "Create". + String get createLabel; } /// Default implementation of Translation strings for the stream chat widgets @@ -835,4 +892,81 @@ Attachment limit exceeded: it's not possible to add more than $limit attachments String get markUnreadError => 'Error marking message unread. Cannot mark unread messages older than the' ' newest 100 channel messages.'; + + @override + String createPollLabel({bool isNew = false}) { + if (isNew) return 'Create a new poll'; + return 'Create Poll'; + } + + @override + String get questionsLabel => 'Questions'; + + @override + String get askAQuestionLabel => 'Ask a question'; + + @override + String? pollQuestionValidationError(int length, Range range) { + final (:min, :max) = range; + + // Check if the question is too short. + if (min != null && length < min) { + return 'Question must be at least $min characters long'; + } + + // Check if the question is too long. + if (max != null && length > max) { + return 'Question must be at most $max characters long'; + } + + return null; + } + + @override + String optionLabel({bool isPlural = false}) { + if (isPlural) return 'Options'; + return 'Option'; + } + + @override + String get pollOptionEmptyError => 'Option cannot be empty'; + + @override + String get pollOptionDuplicateError => 'This is already an option'; + + @override + String get addAnOptionLabel => 'Add an option'; + + @override + String get multipleAnswersLabel => 'Multiple answers'; + + @override + String get maximumVotesPerPersonLabel => 'Maximum votes per person'; + + @override + String? maxVotesPerPersonValidationError(int votes, Range range) { + final (:min, :max) = range; + + 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; + } + + @override + String get anonymousPollLabel => 'Anonymous poll'; + + @override + String get suggestAnOptionLabel => 'Suggest an option'; + + @override + String get addACommentLabel => 'Add a comment'; + + @override + String get createLabel => 'Create'; } diff --git a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/options.dart b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/options.dart index 298ab43e8..2e34169c5 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/options.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/options.dart @@ -1,4 +1,5 @@ export 'stream_file_picker.dart'; export 'stream_gallery_picker.dart'; export 'stream_image_picker.dart'; +export 'stream_poll_creator.dart'; export 'stream_video_picker.dart'; diff --git a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_poll_creator.dart b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_poll_creator.dart new file mode 100644 index 000000000..4712a4d06 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_poll_creator.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/poll/stream_poll_creator_dialog.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// Widget used to create a poll. +class StreamPollCreator extends StatelessWidget { + /// Creates a [StreamPollCreator] widget. + const StreamPollCreator({ + super.key, + this.poll, + this.config, + this.onPollCreated, + }); + + /// The initial poll to be used in the poll creator. + final Poll? poll; + + /// The configuration used to validate the poll. + final PollConfig? config; + + /// Callback called when a poll is created. + final ValueSetter? onPollCreated; + + @override + Widget build(BuildContext context) { + final theme = StreamChatTheme.of(context); + + Future _openCreatePollFlow() async { + final result = await showStreamPollCreatorDialog( + context: context, + poll: poll, + config: config, + ); + + onPollCreated?.call(result); + } + + return OptionDrawer( + child: EndOfFrameCallbackWidget( + child: StreamSvgIcon.polls( + size: 180, + color: theme.colorTheme.disabled, + ), + onEndOfFrame: (_) => _openCreatePollFlow(), + errorBuilder: (context, error, stacktrace) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + StreamSvgIcon.polls( + size: 240, + color: theme.colorTheme.disabled, + ), + const SizedBox(height: 8), + TextButton( + onPressed: _openCreatePollFlow, + child: Text( + context.translations.createPollLabel(isNew: true), + style: theme.textTheme.bodyBold.copyWith( + color: theme.colorTheme.accentPrimary, + ), + ), + ), + ], + ); + }, + ), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker.dart b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker.dart index bc9d0cbc8..b9f6df5c5 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/src/message_input/attachment_picker/options/options.dart'; +import 'package:stream_chat_flutter/src/poll/stream_poll_creator_dialog.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// The default maximum size for media attachments. @@ -11,10 +12,40 @@ const kDefaultMaxAttachmentSize = 100 * 1024 * 1024; // 100MB in Bytes /// The default maximum number of media attachments. const kDefaultMaxAttachmentCount = 10; +/// Value class for [AttachmentPickerController]. +/// +/// This class holds the list of [Poll] and [Attachment] objects. +class AttachmentPickerValue { + /// Creates a new instance of [AttachmentPickerValue]. + const AttachmentPickerValue({ + this.poll, + this.attachments = const [], + }); + + /// The poll object. + final Poll? poll; + + /// The list of [Attachment] objects. + final List attachments; + + /// Returns a copy of this object with the provided values. + AttachmentPickerValue copyWith({ + Poll? poll, + List? attachments, + }) { + return AttachmentPickerValue( + poll: poll ?? this.poll, + attachments: attachments ?? this.attachments, + ); + } +} + /// Controller class for [StreamAttachmentPicker]. -class StreamAttachmentPickerController extends ValueNotifier> { +class StreamAttachmentPickerController + extends ValueNotifier { /// Creates a new instance of [StreamAttachmentPickerController]. StreamAttachmentPickerController({ + this.initialPoll, this.initialAttachments, this.maxAttachmentSize = kDefaultMaxAttachmentSize, this.maxAttachmentCount = kDefaultMaxAttachmentCount, @@ -22,7 +53,12 @@ class StreamAttachmentPickerController extends ValueNotifier> { (initialAttachments?.length ?? 0) <= maxAttachmentCount, '''The initial attachments count must be less than or equal to maxAttachmentCount''', ), - super(initialAttachments ?? const []); + super( + AttachmentPickerValue( + poll: initialPoll, + attachments: initialAttachments ?? const [], + ), + ); /// The max attachment size allowed in bytes. final int maxAttachmentSize; @@ -30,12 +66,15 @@ class StreamAttachmentPickerController extends ValueNotifier> { /// The max attachment count allowed. final int maxAttachmentCount; + /// The initial poll. + final Poll? initialPoll; + /// The initial attachments. final List? initialAttachments; @override - set value(List newValue) { - if (newValue.length > maxAttachmentCount) { + set value(AttachmentPickerValue newValue) { + if (newValue.attachments.length > maxAttachmentCount) { throw ArgumentError( 'The maximum number of attachments is $maxAttachmentCount.', ); @@ -43,6 +82,11 @@ class StreamAttachmentPickerController extends ValueNotifier> { super.value = newValue; } + /// Adds a new [poll] to the message. + set poll(Poll poll) { + value = value.copyWith(poll: poll); + } + Future _saveToCache(AttachmentFile file) async { // Cache the attachment in a temporary file. return StreamAttachmentHandler.instance.saveAttachmentFile( @@ -73,21 +117,21 @@ class StreamAttachmentPickerController extends ValueNotifier> { // No need to cache the attachment if it's already uploaded // or we are on web. if (file == null || uploadState.isSuccess || isWeb) { - value = [...value, attachment]; + value = value.copyWith(attachments: [...value.attachments, attachment]); return; } // Cache the attachment in a temporary file. final tempFilePath = await _saveToCache(file); - value = [ - ...value, + value = value.copyWith(attachments: [ + ...value.attachments, attachment.copyWith( file: file.copyWith( path: tempFilePath, ), ), - ]; + ]); } /// Removes the specified [attachment] from the message. @@ -96,19 +140,21 @@ class StreamAttachmentPickerController extends ValueNotifier> { final uploadState = attachment.uploadState; if (file == null || uploadState.isSuccess || isWeb) { - value = [...value]..remove(attachment); + final updatedAttachments = [...value.attachments]..remove(attachment); + value = value.copyWith(attachments: updatedAttachments); return; } // Remove the cached attachment file. await _removeFromCache(file); - value = [...value]..remove(attachment); + final updatedAttachments = [...value.attachments]..remove(attachment); + value = value.copyWith(attachments: updatedAttachments); } /// Remove the attachment with the given [attachmentId]. void removeAttachmentById(String attachmentId) { - final attachment = value.firstWhereOrNull( + final attachment = value.attachments.firstWhereOrNull( (attachment) => attachment.id == attachmentId, ); @@ -119,7 +165,7 @@ class StreamAttachmentPickerController extends ValueNotifier> { /// Clears all the attachments. Future clear() async { - final attachments = [...value]; + final attachments = [...value.attachments]; for (final attachment in attachments) { final file = attachment.file; final uploadState = attachment.uploadState; @@ -128,12 +174,12 @@ class StreamAttachmentPickerController extends ValueNotifier> { await _removeFromCache(file); } - value = const []; + value = const AttachmentPickerValue(); } /// Resets the controller to its initial state. Future reset() async { - final attachments = [...value]; + final attachments = [...value.attachments]; for (final attachment in attachments) { final file = attachment.file; final uploadState = attachment.uploadState; @@ -142,7 +188,11 @@ class StreamAttachmentPickerController extends ValueNotifier> { await _removeFromCache(file); } - value = initialAttachments ?? const []; + + value = AttachmentPickerValue( + poll: initialPoll, + attachments: initialAttachments ?? const [], + ); } } @@ -159,6 +209,9 @@ enum AttachmentPickerType { /// The attachment picker will only allow to pick files or documents. files, + + /// The attachment picker will only allow to create poll. + poll, } /// Function signature for building the attachment picker option view. @@ -240,16 +293,19 @@ class WebOrDesktopAttachmentPickerOption extends AttachmentPickerOption { extension AttachmentPickerOptionTypeX on StreamAttachmentPickerController { /// Returns the list of available attachment picker options. Set get currentAttachmentPickerTypes { - final containsImage = value.any((it) => it.type == AttachmentType.image); - final containsVideo = value.any((it) => it.type == AttachmentType.video); - final containsAudio = value.any((it) => it.type == AttachmentType.audio); - final containsFile = value.any((it) => it.type == AttachmentType.file); + final attach = value.attachments; + final containsImage = attach.any((it) => it.type == AttachmentType.image); + final containsVideo = attach.any((it) => it.type == AttachmentType.video); + final containsAudio = attach.any((it) => it.type == AttachmentType.audio); + final containsFile = attach.any((it) => it.type == AttachmentType.file); + final containsPoll = value.poll != null; return { if (containsImage) AttachmentPickerType.images, if (containsVideo) AttachmentPickerType.videos, if (containsAudio) AttachmentPickerType.audios, if (containsFile) AttachmentPickerType.files, + if (containsPoll) AttachmentPickerType.poll, }; } @@ -270,11 +326,12 @@ extension AttachmentPickerOptionTypeX on StreamAttachmentPickerController { /// Returns true if the [initialAttachments] are changed. bool get isValueChanged { - final isEqual = UnorderedIterableEquality( - EqualityBy((Attachment attachment) => attachment.id), - ).equals(value, initialAttachments); + final isPollEqual = value.poll == initialPoll; + final areAttachmentsEqual = UnorderedIterableEquality( + EqualityBy((attachment) => attachment.id), + ).equals(value.attachments, initialAttachments); - return !isEqual; + return !isPollEqual || !areAttachmentsEqual; } } @@ -342,7 +399,7 @@ class StreamMobileAttachmentPickerBottomSheet extends StatefulWidget { required this.options, required this.controller, this.initialOption, - this.onSendAttachments, + this.onSendValue, }); /// The list of options. @@ -355,7 +412,7 @@ class StreamMobileAttachmentPickerBottomSheet extends StatefulWidget { final StreamAttachmentPickerController controller; /// The callback when the send button gets tapped. - final ValueSetter>? onSendAttachments; + final ValueSetter? onSendValue; @override State createState() => @@ -387,7 +444,7 @@ class _StreamMobileAttachmentPickerBottomSheetState @override Widget build(BuildContext context) { - return ValueListenableBuilder>( + return ValueListenableBuilder( valueListenable: widget.controller, builder: (context, attachments, _) { return Column( @@ -397,7 +454,7 @@ class _StreamMobileAttachmentPickerBottomSheetState controller: widget.controller, options: widget.options, currentOption: _currentOption, - onSendAttachment: widget.onSendAttachments, + onSendValue: widget.onSendValue, onOptionSelected: (option) async { setState(() => _currentOption = option); }, @@ -420,19 +477,19 @@ class _AttachmentPickerOptions extends StatelessWidget { required this.currentOption, required this.controller, this.onOptionSelected, - this.onSendAttachment, + this.onSendValue, }); final Iterable options; final AttachmentPickerOption currentOption; final StreamAttachmentPickerController controller; final ValueSetter? onOptionSelected; - final ValueSetter>? onSendAttachment; + final ValueSetter? onSendValue; @override Widget build(BuildContext context) { final colorTheme = StreamChatTheme.of(context).colorTheme; - return ValueListenableBuilder>( + return ValueListenableBuilder( valueListenable: controller, builder: (context, attachments, __) { final enabledTypes = controller.filterEnabledTypes(options: options); @@ -472,14 +529,12 @@ class _AttachmentPickerOptions extends StatelessWidget { ), Builder( builder: (context) { - final isEnabled = - onSendAttachment != null && controller.isValueChanged; - - final onPressed = isEnabled - ? () { - onSendAttachment!(attachments); - } - : null; + final VoidCallback? onPressed; + if (onSendValue != null && controller.isValueChanged) { + onPressed = () => onSendValue!(attachments); + } else { + onPressed = null; + } return IconButton( iconSize: 22, @@ -692,6 +747,8 @@ class OptionDrawer extends StatelessWidget { Widget mobileAttachmentPickerBuilder({ required BuildContext context, required StreamAttachmentPickerController controller, + Poll? initialPoll, + PollConfig? pollConfig, Iterable? customOptions, List allowedTypes = AttachmentPickerType.values, ThumbnailSize attachmentThumbnailSize = const ThumbnailSize(400, 400), @@ -702,19 +759,20 @@ Widget mobileAttachmentPickerBuilder({ }) { return StreamMobileAttachmentPickerBottomSheet( controller: controller, - onSendAttachments: Navigator.of(context).pop, + onSendValue: Navigator.of(context).pop, options: { ...{ if (customOptions != null) ...customOptions, AttachmentPickerOption( key: 'gallery-picker', - icon: StreamSvgIcon.pictures(size: 36).toIconThemeSvgIcon(), + icon: StreamSvgIcon.pictures().toIconThemeSvgIcon(), supportedTypes: [ AttachmentPickerType.images, AttachmentPickerType.videos, ], optionViewBuilder: (context, controller) { - final selectedIds = controller.value.map((it) => it.id); + final attachment = controller.value.attachments; + final selectedIds = attachment.map((it) => it.id); return StreamGalleryPicker( selectedMediaItems: selectedIds, mediaThumbnailSize: attachmentThumbnailSize, @@ -737,7 +795,7 @@ Widget mobileAttachmentPickerBuilder({ ), AttachmentPickerOption( key: 'file-picker', - icon: StreamSvgIcon.files(size: 36).toIconThemeSvgIcon(), + icon: StreamSvgIcon.files().toIconThemeSvgIcon(), supportedTypes: [AttachmentPickerType.files], optionViewBuilder: (context, controller) { return StreamFilePicker( @@ -757,7 +815,7 @@ Widget mobileAttachmentPickerBuilder({ ), AttachmentPickerOption( key: 'image-picker', - icon: StreamSvgIcon.camera(size: 36).toIconThemeSvgIcon(), + icon: StreamSvgIcon.camera().toIconThemeSvgIcon(), supportedTypes: [AttachmentPickerType.images], optionViewBuilder: (context, controller) { return StreamImagePicker( @@ -779,7 +837,7 @@ Widget mobileAttachmentPickerBuilder({ ), AttachmentPickerOption( key: 'video-picker', - icon: StreamSvgIcon.record(size: 36).toIconThemeSvgIcon(), + icon: StreamSvgIcon.record().toIconThemeSvgIcon(), supportedTypes: [AttachmentPickerType.videos], optionViewBuilder: (context, controller) { return StreamVideoPicker( @@ -793,6 +851,29 @@ Widget mobileAttachmentPickerBuilder({ Navigator.pop(context, controller.value); if (onError != null) return onError.call(e, stk); + rethrow; + } + }, + ); + }, + ), + AttachmentPickerOption( + key: 'poll-creator', + icon: StreamSvgIcon.polls().toIconThemeSvgIcon(), + supportedTypes: [AttachmentPickerType.poll], + optionViewBuilder: (context, controller) { + final initialPoll = controller.value.poll; + return StreamPollCreator( + poll: initialPoll, + config: pollConfig, + onPollCreated: (poll) { + try { + if (poll != null) controller.poll = poll; + return Navigator.pop(context, controller.value); + } catch (e, stk) { + Navigator.pop(context, controller.value); + if (onError != null) return onError.call(e, stk); + rethrow; } }, @@ -808,6 +889,8 @@ Widget mobileAttachmentPickerBuilder({ Widget webOrDesktopAttachmentPickerBuilder({ required BuildContext context, required StreamAttachmentPickerController controller, + Poll? initialPoll, + PollConfig? pollConfig, Iterable? customOptions, List allowedTypes = AttachmentPickerType.values, ThumbnailSize attachmentThumbnailSize = const ThumbnailSize(400, 400), @@ -824,24 +907,43 @@ Widget webOrDesktopAttachmentPickerBuilder({ WebOrDesktopAttachmentPickerOption( key: 'image-picker', type: AttachmentPickerType.images, - icon: StreamSvgIcon.pictures(size: 36).toIconThemeSvgIcon(), + icon: StreamSvgIcon.pictures().toIconThemeSvgIcon(), title: context.translations.uploadAPhotoLabel, ), WebOrDesktopAttachmentPickerOption( key: 'video-picker', type: AttachmentPickerType.videos, - icon: StreamSvgIcon.record(size: 36).toIconThemeSvgIcon(), + icon: StreamSvgIcon.record().toIconThemeSvgIcon(), title: context.translations.uploadAVideoLabel, ), WebOrDesktopAttachmentPickerOption( key: 'file-picker', type: AttachmentPickerType.files, - icon: StreamSvgIcon.files(size: 36).toIconThemeSvgIcon(), + icon: StreamSvgIcon.files().toIconThemeSvgIcon(), title: context.translations.uploadAFileLabel, ), + WebOrDesktopAttachmentPickerOption( + key: 'poll-creator', + type: AttachmentPickerType.poll, + icon: StreamSvgIcon.polls().toIconThemeSvgIcon(), + title: context.translations.createPollLabel(), + ), }.where((option) => option.supportedTypes.every(allowedTypes.contains)), }, onOptionTap: (context, controller, option) async { + // Handle the polls type option separately + if (option.type case AttachmentPickerType.poll) { + final poll = await showStreamPollCreatorDialog( + context: context, + poll: initialPoll, + config: pollConfig, + ); + + if (poll != null) controller.poll = poll; + return Navigator.pop(context, controller.value); + } + + // Handle the remaining option types. try { final attachment = await StreamAttachmentHandler.instance.pickFile( type: option.type.fileType, diff --git a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker_bottom_sheet.dart b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker_bottom_sheet.dart index fbb12e272..d56a7047d 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker_bottom_sheet.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker_bottom_sheet.dart @@ -67,6 +67,8 @@ Future showStreamAttachmentPickerModalBottomSheet({ required BuildContext context, Iterable? customOptions, List allowedTypes = AttachmentPickerType.values, + Poll? initialPoll, + PollConfig? pollConfig, List? initialAttachments, StreamAttachmentPickerController? controller, ErrorListener? onError, @@ -108,6 +110,7 @@ Future showStreamAttachmentPickerModalBottomSheet({ builder: (BuildContext context) { return StreamPlatformAttachmentPickerBottomSheetBuilder( controller: controller, + initialPoll: initialPoll, initialAttachments: initialAttachments, builder: (context, controller, child) { final currentPlatform = defaultTargetPlatform; @@ -125,6 +128,8 @@ Future showStreamAttachmentPickerModalBottomSheet({ customOptions: customOptions?.map( WebOrDesktopAttachmentPickerOption.fromAttachmentPickerOption, ), + initialPoll: initialPoll, + pollConfig: pollConfig, attachmentThumbnailSize: attachmentThumbnailSize, attachmentThumbnailFormat: attachmentThumbnailFormat, attachmentThumbnailQuality: attachmentThumbnailQuality, @@ -138,6 +143,8 @@ Future showStreamAttachmentPickerModalBottomSheet({ controller: controller, allowedTypes: allowedTypes, customOptions: customOptions, + initialPoll: initialPoll, + pollConfig: pollConfig, attachmentThumbnailSize: attachmentThumbnailSize, attachmentThumbnailFormat: attachmentThumbnailFormat, attachmentThumbnailQuality: attachmentThumbnailQuality, @@ -155,6 +162,7 @@ class StreamPlatformAttachmentPickerBottomSheetBuilder extends StatefulWidget { const StreamPlatformAttachmentPickerBottomSheetBuilder({ super.key, this.customOptions, + this.initialPoll, this.initialAttachments, this.child, this.controller, @@ -174,6 +182,9 @@ class StreamPlatformAttachmentPickerBottomSheetBuilder extends StatefulWidget { /// The custom options to be displayed in the attachment picker. final List? customOptions; + /// The initial poll. + final Poll? initialPoll; + /// The initial attachments. final List? initialAttachments; @@ -194,13 +205,14 @@ class _StreamPlatformAttachmentPickerBottomSheetBuilderState super.initState(); _controller = widget.controller ?? StreamAttachmentPickerController( + initialPoll: widget.initialPoll, initialAttachments: widget.initialAttachments, ); } // Handle a potential change in StreamAttachmentPickerController by properly // disposing of the old one and setting up the new one, if needed. - void _updateTextEditingController( + void _updateAttachmentPickerController( StreamAttachmentPickerController? old, StreamAttachmentPickerController? current, ) { @@ -209,7 +221,10 @@ class _StreamPlatformAttachmentPickerBottomSheetBuilderState _controller.dispose(); _controller = current!; } else if (current == null) { - _controller = StreamAttachmentPickerController(); + _controller = StreamAttachmentPickerController( + initialPoll: widget.initialPoll, + initialAttachments: widget.initialAttachments, + ); } else { _controller = current; } @@ -220,7 +235,7 @@ class _StreamPlatformAttachmentPickerBottomSheetBuilderState StreamPlatformAttachmentPickerBottomSheetBuilder oldWidget, ) { super.didUpdateWidget(oldWidget); - _updateTextEditingController( + _updateAttachmentPickerController( oldWidget.controller, widget.controller, ); diff --git a/packages/stream_chat_flutter/lib/src/message_input/stream_message_input.dart b/packages/stream_chat_flutter/lib/src/message_input/stream_message_input.dart index 247467fb0..17ee02da5 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/stream_message_input.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/stream_message_input.dart @@ -152,6 +152,7 @@ class StreamMessageInput extends StatefulWidget { this.hintGetter = _defaultHintGetter, this.contentInsertionConfiguration, this.useNativeAttachmentPickerOnMobile = false, + this.pollConfig, }); /// The predicate used to send a message on desktop/web @@ -351,6 +352,11 @@ class StreamMessageInput extends StatefulWidget { /// Stream attachment picker. final bool useNativeAttachmentPickerOnMobile; + /// The configuration to use while creating a poll. + /// + /// If not provided, the default configuration is used. + final PollConfig? pollConfig; + static String? _defaultHintGetter( BuildContext context, HintType type, @@ -845,24 +851,81 @@ class StreamMessageInputState extends State defaultButton; } + Future _sendPoll(Poll poll) { + final streamChannel = StreamChannel.of(context); + final channel = streamChannel.channel; + + return channel.sendPoll(poll); + } + + Future _updatePoll(Poll poll) { + final streamChannel = StreamChannel.of(context); + final channel = streamChannel.channel; + + return channel.updatePoll(poll); + } + + Future _deletePoll(Poll poll) { + final streamChannel = StreamChannel.of(context); + final channel = streamChannel.channel; + + return channel.deletePoll(poll.id); + } + + Future _createOrUpdatePoll(Poll? old, Poll? current) async { + // If both are null or the same, return + if ((old == null && current == null) || old == current) return; + + // If old is null, i.e., there was no poll before, create the poll. + if (old == null) return _sendPoll(current!); + + // If current is null, i.e., the poll is removed, delete the poll. + if (current == null) return _deletePoll(old); + + // Otherwise, update the poll. + return _updatePoll(current); + } + /// Handle the platform-specific logic for selecting files. /// /// On mobile, this will open the file selection bottom sheet. On desktop, /// this will open the native file system and allow the user to select one /// or more files. Future _onAttachmentButtonPressed() async { - final attachments = await showStreamAttachmentPickerModalBottomSheet( + final initialPoll = _effectiveController.poll; + final initialAttachments = _effectiveController.attachments; + + // Remove AttachmentPickerType.poll if the user doesn't have the permission + // to send a poll. + final allowedTypes = [...widget.allowedAttachmentPickerTypes] + ..removeWhere((it) { + if (it != AttachmentPickerType.poll) return false; + final channel = StreamChannel.of(context).channel; + if (channel.ownCapabilities.contains(PermissionType.sendPoll)) { + return false; + } + + return true; + }); + + final value = await showStreamAttachmentPickerModalBottomSheet( context: context, onError: widget.onError, - allowedTypes: widget.allowedAttachmentPickerTypes, - initialAttachments: _effectiveController.attachments, + allowedTypes: allowedTypes, + pollConfig: widget.pollConfig, + initialPoll: initialPoll, + initialAttachments: initialAttachments, useNativeAttachmentPickerOnMobile: widget.useNativeAttachmentPickerOnMobile, ); - if (attachments != null) { - _effectiveController.attachments = attachments; - } + if (value == null || value is! AttachmentPickerValue) return; + + // Add the attachments to the controller. + _effectiveController.attachments = value.attachments; + + // Create or update the poll. + await _createOrUpdatePoll(initialPoll, value.poll); } Expanded _buildTextInput(BuildContext context) { diff --git a/packages/stream_chat_flutter/lib/src/misc/back_button.dart b/packages/stream_chat_flutter/lib/src/misc/back_button.dart index 22245e011..b7fdc2fe0 100644 --- a/packages/stream_chat_flutter/lib/src/misc/back_button.dart +++ b/packages/stream_chat_flutter/lib/src/misc/back_button.dart @@ -4,7 +4,6 @@ import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// {@template streamBackButton} /// A custom back button implementation /// {@endtemplate} -// ignore: prefer-match-file-name class StreamBackButton extends StatelessWidget { /// {@macro streamBackButton} const StreamBackButton({ @@ -25,39 +24,36 @@ class StreamBackButton extends StatelessWidget { @override Widget build(BuildContext context) { - return Stack( - alignment: Alignment.center, - children: [ - RawMaterialButton( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(4), - ), - elevation: 0, - highlightElevation: 0, - focusElevation: 0, - hoverElevation: 0, - onPressed: () { - if (onPressed != null) { - onPressed!(); - } else { - Navigator.of(context).maybePop(); - } - }, - padding: const EdgeInsets.all(14), - child: StreamSvgIcon.left( - size: 24, - color: StreamChatTheme.of(context).colorTheme.textHighEmphasis, - ), - ), - if (showUnreadCount) - Positioned( - top: 7, - right: 7, - child: StreamUnreadIndicator( - cid: channelId, - ), + final theme = StreamChatTheme.of(context); + + Widget icon = StreamSvgIcon.left( + size: 24, + color: theme.colorTheme.textHighEmphasis, + ); + + if (showUnreadCount) { + icon = Stack( + clipBehavior: Clip.none, + children: [ + icon, + PositionedDirectional( + top: -4, + start: 12, + child: StreamUnreadIndicator(cid: channelId), ), - ], + ], + ); + } + + return IconButton( + icon: icon, + onPressed: () { + if (onPressed case final onPressed?) { + return onPressed(); + } + + Navigator.maybePop(context); + }, ); } } diff --git a/packages/stream_chat_flutter/lib/src/misc/separated_reorderable_list_view.dart b/packages/stream_chat_flutter/lib/src/misc/separated_reorderable_list_view.dart new file mode 100644 index 000000000..054c1de52 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/misc/separated_reorderable_list_view.dart @@ -0,0 +1,83 @@ +// ignore_for_file: use_is_even_rather_than_modulo +import 'dart:math' as math; +import 'package:flutter/material.dart'; + +/// {@template streamSeparatedReorderableListView} +/// A custom implementation of [ReorderableListView] that supports separators. +/// {@endtemplate} +class SeparatedReorderableListView extends ReorderableListView { + /// {@macro streamSeparatedReorderableListView} + SeparatedReorderableListView({ + super.key, + required IndexedWidgetBuilder itemBuilder, + required IndexedWidgetBuilder separatorBuilder, + required int itemCount, + required ReorderCallback onReorder, + 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 % 2 == 1) 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 % 2 == 1 + ? (newIndex + 1) ~/ 2 + : newIndex ~/ 2; + + onReorder(updatedOldIndex, updatedNewIndex); + }, + ); +} diff --git a/packages/stream_chat_flutter/lib/src/misc/stream_svg_icon.dart b/packages/stream_chat_flutter/lib/src/misc/stream_svg_icon.dart index d69b83bad..af71e6099 100644 --- a/packages/stream_chat_flutter/lib/src/misc/stream_svg_icon.dart +++ b/packages/stream_chat_flutter/lib/src/misc/stream_svg_icon.dart @@ -196,6 +196,32 @@ class StreamSvgIcon extends StatelessWidget { ); } + /// [StreamSvgIcon] type + factory StreamSvgIcon.polls({ + double? size, + Color? color, + }) { + return StreamSvgIcon( + assetName: 'polls.svg', + color: color, + width: size, + height: size, + ); + } + + /// [StreamSvgIcon] type + factory StreamSvgIcon.send({ + double? size, + Color? color, + }) { + return StreamSvgIcon( + assetName: 'send.svg', + color: color, + width: size, + height: size, + ); + } + /// [StreamSvgIcon] type factory StreamSvgIcon.pictures({ double? size, diff --git a/packages/stream_chat_flutter/lib/src/poll/poll_option_reorderable_list_view.dart b/packages/stream_chat_flutter/lib/src/poll/poll_option_reorderable_list_view.dart new file mode 100644 index 000000000..cc0d94126 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/poll/poll_option_reorderable_list_view.dart @@ -0,0 +1,308 @@ +import 'dart:ui'; + +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/misc/separated_reorderable_list_view.dart'; +import 'package:stream_chat_flutter/src/poll/poll_text_field.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.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({ + String? id, + this.text = '', + this.error, + }) : id = id ?? const Uuid().v4(); + + /// The unique id of the poll option item. + final String id; + + /// The text of the poll option item. + final String text; + + /// 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? id, + String? text, + Object? error = _nullConst, + }) { + return PollOptionItem( + id: id ?? this.id, + text: text ?? this.text, + 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.onChanged, + }); + + /// The poll option item. + final PollOptionItem option; + + /// Hint to be displayed in the poll option list item. + final String? hintText; + + /// Callback called when the poll option item is changed. + final ValueSetter? onChanged; + + @override + Widget build(BuildContext context) { + final theme = StreamPollCreatorTheme.of(context); + final fillColor = theme.optionsTextFieldFillColor; + final borderRadius = theme.optionsTextFieldBorderRadius; + + return DecoratedBox( + decoration: BoxDecoration( + color: fillColor, + borderRadius: borderRadius, + ), + child: Row( + children: [ + Expanded( + child: PollTextField( + initialValue: option.text, + hintText: hintText, + style: theme.optionsTextFieldStyle, + fillColor: fillColor, + borderRadius: borderRadius, + errorText: option.error, + errorStyle: theme.optionsTextFieldErrorStyle, + 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.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; + + /// Callback called when the items are updated or reordered. + final ValueSetter>? onOptionsChanged; + + @override + State createState() => + _PollOptionReorderableListViewState(); +} + +class _PollOptionReorderableListViewState + extends State { + late var _options = { + for (final option in widget.initialOptions) option.id: option, + }; + + @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.id, it.text)), + ); + + if (optionItemEquality.equals(currOptions, newOptions) case false) { + _options = { + for (final option in widget.initialOptions) option.id: option, + }; + } + } + + 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) { + final translations = context.translations; + + // Check if the option is empty. + if (option.text.isEmpty) return translations.pollOptionEmptyError; + + // 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.id == option.id) return false; + + return it.text == option.text; + })) return translations.pollOptionDuplicateError; + } + + return null; + } + + void _onOptionChanged(PollOptionItem option) { + setState(() { + // Update the changed option. + _options[option.id] = 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.id) return value; + + return value.copyWith(error: _validateOption(value)); + }); + + // Notify the parent widget about the change + 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.id: option, + }; + + // Notify the parent widget about the change + widget.onOptionsChanged?.call([..._options.values]); + }); + } + + void _onAddOptionPressed() { + setState(() { + // Create a new option and add it to the map. + final option = PollOptionItem(); + _options[option.id] = option; + + // Notify the parent widget about the change + widget.onOptionsChanged?.call([..._options.values]); + }); + } + + @override + Widget build(BuildContext context) { + final theme = StreamPollCreatorTheme.of(context); + final borderRadius = theme.optionsTextFieldBorderRadius; + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (widget.title case final title?) ...[ + Text(title, style: theme.optionsHeaderStyle), + const SizedBox(height: 8), + ], + Flexible( + child: SeparatedReorderableListView( + shrinkWrap: true, + itemCount: _options.length, + physics: const NeverScrollableScrollPhysics(), + proxyDecorator: _proxyDecorator, + separatorBuilder: (_, __) => const SizedBox(height: 8), + onReorder: _onOptionReorder, + itemBuilder: (context, index) { + final option = _options.values.elementAt(index); + return PollOptionListItem( + key: Key(option.id), + option: option, + hintText: widget.itemHintText, + onChanged: _onOptionChanged, + ); + }, + ), + ), + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: FilledButton.tonal( + onPressed: _onAddOptionPressed, + style: TextButton.styleFrom( + alignment: Alignment.centerLeft, + textStyle: theme.optionsTextFieldStyle, + shape: RoundedRectangleBorder( + borderRadius: borderRadius ?? BorderRadius.zero, + ), + padding: const EdgeInsets.symmetric( + vertical: 18, + horizontal: 16, + ), + backgroundColor: theme.optionsTextFieldFillColor, + ), + child: Text(context.translations.addAnOptionLabel), + ), + ), + ], + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/poll/poll_question_text_field.dart b/packages/stream_chat_flutter/lib/src/poll/poll_question_text_field.dart new file mode 100644 index 000000000..aa8924dff --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/poll/poll_question_text_field.dart @@ -0,0 +1,156 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/poll/poll_text_field.dart'; +import 'package:stream_chat_flutter/src/theme/poll_creator_theme.dart'; +import 'package:stream_chat_flutter/src/utils/extensions.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; + +class _NullConst { + const _NullConst(); +} + +const _nullConst = _NullConst(); + +/// {@template pollQuestion} +/// A data class that represents a poll question. +/// {@endtemplate} +class PollQuestion { + /// {@macro pollQuestion} + PollQuestion({ + String? id, + this.text = '', + this.error, + }) : id = id ?? const Uuid().v4(); + + /// The unique id of the poll this question belongs to. + final String id; + + /// 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? id, + String? text, + Object? error = _nullConst, + }) { + return PollQuestion( + id: id ?? this.id, + 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.id, it.text), + ); + + if (questionEquality.equals(currQuestion, newQuestion) case false) { + _question = newQuestion; + } + } + + String? _validateQuestion(String question) { + if (widget.questionRange case final range?) { + return context.translations.pollQuestionValidationError( + question.length, + range, + ); + } + + return null; + } + + @override + Widget build(BuildContext context) { + final theme = StreamPollCreatorTheme.of(context); + final fillColor = theme.questionTextFieldFillColor; + final borderRadius = theme.questionTextFieldBorderRadius; + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (widget.title case final title?) ...[ + Text(title, style: theme.questionHeaderStyle), + const SizedBox(height: 8), + ], + DecoratedBox( + decoration: BoxDecoration( + color: fillColor, + borderRadius: borderRadius, + ), + child: PollTextField( + initialValue: _question.text, + hintText: widget.hintText, + fillColor: fillColor, + style: theme.questionTextFieldStyle, + borderRadius: borderRadius, + errorText: _question.error, + errorStyle: theme.questionTextFieldErrorStyle, + onChanged: (text) { + _question = _question.copyWith( + text: text, + error: _validateQuestion(text), + ); + + widget.onChanged?.call(_question); + }, + ), + ), + ], + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/poll/poll_switch_list_tile.dart b/packages/stream_chat_flutter/lib/src/poll/poll_switch_list_tile.dart new file mode 100644 index 000000000..90e55caf7 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/poll/poll_switch_list_tile.dart @@ -0,0 +1,241 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/poll/poll_text_field.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.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 theme = StreamPollCreatorTheme.of(context); + final fillColor = theme.switchListTileFillColor; + final borderRadius = theme.switchListTileBorderRadius; + + final listTile = SwitchListTile( + value: value, + onChanged: onChanged, + tileColor: fillColor, + title: Text(title, style: theme.switchListTileTitleStyle), + contentPadding: const EdgeInsets.only(left: 16, right: 8), + shape: RoundedRectangleBorder( + borderRadius: borderRadius ?? BorderRadius.zero, + ), + ); + + 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({ + String? id, + this.value = false, + this.inputValue, + this.error, + }) : id = id ?? const Uuid().v4(); + + /// 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 theme = StreamPollCreatorTheme.of(context); + final fillColor = theme.switchListTileFillColor; + final borderRadius = theme.switchListTileBorderRadius; + + return DecoratedBox( + decoration: BoxDecoration( + color: fillColor, + borderRadius: borderRadius, + ), + child: Row( + children: [ + Expanded( + child: PollTextField( + hintText: widget.hintText, + enabled: _item.value, + fillColor: fillColor, + style: theme.switchListTileTitleStyle, + keyboardType: widget.keyboardType, + borderRadius: borderRadius, + errorText: _item.value ? _item.error : null, + errorStyle: theme.switchListTileErrorStyle, + initialValue: _item.inputValue?.toString(), + onChanged: _onFieldChanged, + ), + ), + Switch( + value: _item.value, + onChanged: _onSwitchToggled, + ), + const SizedBox(width: 8), + ], + ), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/poll/poll_text_field.dart b/packages/stream_chat_flutter/lib/src/poll/poll_text_field.dart new file mode 100644 index 000000000..bc33beace --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/poll/poll_text_field.dart @@ -0,0 +1,262 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +const _kTransitionDuration = Duration(milliseconds: 167); + +/// {@template pollInputTextField} +/// A widget that represents a text field for poll input. +/// {@endtemplate} +class PollTextField extends StatefulWidget { + /// {@macro pollInputTextField} + const PollTextField({ + 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.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; + + /// 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() => _PollTextFieldState(); +} + +class _PollTextFieldState extends State { + late final _controller = TextEditingController(text: widget.initialValue); + + @override + void didUpdateWidget(covariant PollTextField 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 theme = StreamChatTheme.of(context); + + // 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 ?? + theme.textTheme.footnote.copyWith( + color: theme.colorTheme.accentError, + ), + ), + TextField( + autocorrect: false, + controller: _controller, + focusNode: widget.focusNode, + onChanged: widget.onChanged, + style: widget.style ?? theme.textTheme.headline, + keyboardType: widget.keyboardType, + decoration: InputDecoration( + filled: true, + isCollapsed: true, + enabled: widget.enabled, + fillColor: widget.fillColor, + hintText: widget.hintText, + hintStyle: (widget.style ?? theme.textTheme.headline).copyWith( + color: theme.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 [PollTextField]. +/// {@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/packages/stream_chat_flutter/lib/src/poll/stream_poll_creator_dialog.dart b/packages/stream_chat_flutter/lib/src/poll/stream_poll_creator_dialog.dart new file mode 100644 index 000000000..0be97d186 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/poll/stream_poll_creator_dialog.dart @@ -0,0 +1,270 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/poll/stream_poll_creator_widget.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.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, + Poll? 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 = StreamChatTheme.of(context).colorTheme; + + // 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 Poll? 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 = StreamChatTheme.of(context); + final pollCreatorTheme = StreamPollCreatorTheme.of(context); + + final actions = [ + TextButton( + onPressed: Navigator.of(context).pop, + style: TextButton.styleFrom( + textStyle: theme.textTheme.headlineBold, + foregroundColor: theme.colorTheme.accentPrimary, + disabledForegroundColor: theme.colorTheme.disabled, + ), + child: Text(context.translations.cancelLabel.toUpperCase()), + ), + ValueListenableBuilder( + valueListenable: _controller, + builder: (context, poll, child) { + final isValid = _controller.validate(); + return TextButton( + onPressed: isValid + ? () { + final errors = _controller.validateGranularly(); + if (errors.isNotEmpty) { + return; + } + + final sanitizedPoll = _controller.sanitizedPoll; + return Navigator.of(context).pop(sanitizedPoll); + } + : null, + style: TextButton.styleFrom( + textStyle: theme.textTheme.headlineBold, + foregroundColor: theme.colorTheme.accentPrimary, + disabledForegroundColor: theme.colorTheme.disabled, + ), + child: Text(context.translations.createLabel.toUpperCase()), + ); + }, + ), + ]; + + return AlertDialog( + title: Text( + context.translations.createPollLabel(), + style: pollCreatorTheme.appBarTitleStyle, + ), + 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: pollCreatorTheme.backgroundColor, + 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 Poll? 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 = StreamPollCreatorTheme.of(context); + + return Scaffold( + backgroundColor: theme.backgroundColor, + appBar: AppBar( + elevation: theme.appBarElevation, + backgroundColor: theme.appBarBackgroundColor, + title: Text( + context.translations.createPollLabel(), + style: theme.appBarTitleStyle, + ), + actions: [ + ValueListenableBuilder( + valueListenable: _controller, + builder: (context, poll, child) { + final colorTheme = StreamChatTheme.of(context).colorTheme; + + final isValid = _controller.validate(); + + return IconButton( + color: colorTheme.accentPrimary, + disabledColor: colorTheme.disabled, + icon: StreamSvgIcon.send().toIconThemeSvgIcon(), + onPressed: isValid + ? () { + final errors = _controller.validateGranularly(); + if (errors.isNotEmpty) { + return; + } + + final sanitizedPoll = _controller.sanitizedPoll; + return Navigator.of(context).pop(sanitizedPoll); + } + : null, + ); + }, + ), + ], + ), + body: StreamPollCreatorWidget( + padding: widget.padding, + controller: _controller, + ), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/poll/stream_poll_creator_widget.dart b/packages/stream_chat_flutter/lib/src/poll/stream_poll_creator_widget.dart new file mode 100644 index 000000000..daf2c80a9 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/poll/stream_poll_creator_widget.dart @@ -0,0 +1,142 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/poll/poll_option_reorderable_list_view.dart'; +import 'package:stream_chat_flutter/src/poll/poll_question_text_field.dart'; +import 'package:stream_chat_flutter/src/poll/poll_switch_list_tile.dart'; +import 'package:stream_chat_flutter/src/utils/extensions.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.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; + final translations = context.translations; + + // 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: translations.questionsLabel, + hintText: translations.askAQuestionLabel, + initialQuestion: PollQuestion(id: poll.id, text: poll.name), + onChanged: (question) => controller.question = question.text, + ), + const SizedBox(height: 32), + PollOptionReorderableListView( + title: translations.optionLabel(isPlural: true), + itemHintText: translations.optionLabel(), + allowDuplicate: config.allowDuplicateOptions, + initialOptions: [ + for (final option in poll.options) + PollOptionItem(id: option.id, text: option.text), + ], + onOptionsChanged: (options) => controller.options = [ + for (final option in options) + PollOption(id: option.id, text: option.text), + ], + ), + const SizedBox(height: 32), + PollSwitchListTile( + title: translations.multipleAnswersLabel, + value: poll.enforceUniqueVote == false, + onChanged: (value) { + controller.enforceUniqueVote = !value; + // We also need to reset maxVotesAllowed if disabled. + if (value case false) controller.maxVotesAllowed = null; + }, + children: [ + PollSwitchTextField( + hintText: translations.maximumVotesPerPersonLabel, + item: PollSwitchItem( + value: poll.maxVotesAllowed != null, + inputValue: poll.maxVotesAllowed, + ), + keyboardType: TextInputType.number, + validator: (item) { + if (config.allowedVotesRange case final allowedRange?) { + final votes = item.inputValue; + if (votes == null) return null; + + return translations.maxVotesPerPersonValidationError( + votes, + allowedRange, + ); + } + + return null; + }, + onChanged: (option) { + final enabled = option.value; + final maxVotes = option.inputValue; + + controller.maxVotesAllowed = enabled ? maxVotes : null; + }, + ), + ], + ), + const SizedBox(height: 8), + PollSwitchListTile( + title: translations.anonymousPollLabel, + value: poll.votingVisibility == VotingVisibility.anonymous, + onChanged: (anon) => controller.votingVisibility = anon // + ? VotingVisibility.anonymous + : VotingVisibility.public, + ), + const SizedBox(height: 8), + PollSwitchListTile( + title: translations.suggestAnOptionLabel, + value: poll.allowUserSuggestedOptions, + onChanged: (allow) => controller.allowSuggestions = allow, + ), + const SizedBox(height: 8), + PollSwitchListTile( + title: translations.addACommentLabel, + value: poll.allowAnswers, + onChanged: (allow) => controller.allowComments = allow, + ), + ], + ), + ); + }, + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/theme/color_theme.dart b/packages/stream_chat_flutter/lib/src/theme/color_theme.dart index 010d8777c..167c68ee6 100644 --- a/packages/stream_chat_flutter/lib/src/theme/color_theme.dart +++ b/packages/stream_chat_flutter/lib/src/theme/color_theme.dart @@ -10,7 +10,7 @@ class StreamColorTheme { this.textLowEmphasis = const Color(0xff7a7a7a), this.disabled = const Color(0xffdbdbdb), this.borders = const Color(0xffecebeb), - this.inputBg = const Color(0xfff2f2f2), + this.inputBg = const Color(0xffe9eaed), this.appBg = const Color(0xfff7f7f8), this.barsBg = const Color(0xffffffff), this.linkBg = const Color(0xffe9f2ff), diff --git a/packages/stream_chat_flutter/lib/src/theme/poll_creator_theme.dart b/packages/stream_chat_flutter/lib/src/theme/poll_creator_theme.dart new file mode 100644 index 000000000..34c21dcd1 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/theme/poll_creator_theme.dart @@ -0,0 +1,318 @@ +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; + +/// {@template streamPollCreatorTheme} +/// Overrides the default style of [StreamPollCreatorWidget] descendants. +/// +/// See also: +/// +/// * [StreamPollCreatorThemeData], which is used to configure this theme. +/// {@endtemplate} +class StreamPollCreatorTheme extends InheritedTheme { + /// Creates a [StreamPollCreatorTheme]. + /// + /// The [data] parameter must not be null. + const StreamPollCreatorTheme({ + super.key, + required this.data, + required super.child, + }); + + /// The configuration of this theme. + final StreamPollCreatorThemeData data; + + /// The closest instance of this class that encloses the given context. + /// + /// If there is no enclosing [StreamPollCreatorTheme] widget, then + /// [StreamChatThemeData.pollCreatorTheme] is used. + /// + /// Typical usage is as follows: + /// + /// ```dart + /// StreamPollCreatorTheme theme = StreamPollCreatorTheme.of(context); + /// ``` + static StreamPollCreatorThemeData of(BuildContext context) { + final pollCreatorTheme = + context.dependOnInheritedWidgetOfExactType(); + return pollCreatorTheme?.data ?? + StreamChatTheme.of(context).pollCreatorTheme; + } + + @override + Widget wrap(BuildContext context, Widget child) => + StreamPollCreatorTheme(data: data, child: child); + + @override + bool updateShouldNotify(StreamPollCreatorTheme oldWidget) => + data != oldWidget.data; +} + +/// {@template streamPollCreatorThemeData} +/// A style that overrides the default appearance of [MessageInput] widgets +/// when used with [StreamPollCreatorTheme] or with the overall +/// [StreamChatTheme]'s [StreamChatThemeData.pollCreatorTheme]. +/// {@endtemplate} +class StreamPollCreatorThemeData with Diagnosticable { + /// {@macro streamPollCreatorThemeData} + const StreamPollCreatorThemeData({ + this.backgroundColor, + this.appBarTitleStyle, + this.appBarElevation, + this.appBarBackgroundColor, + this.questionTextFieldFillColor, + this.questionHeaderStyle, + this.questionTextFieldStyle, + this.questionTextFieldErrorStyle, + this.questionTextFieldBorderRadius, + this.optionsHeaderStyle, + this.optionsTextFieldStyle, + this.optionsTextFieldFillColor, + this.optionsTextFieldErrorStyle, + this.optionsTextFieldBorderRadius, + this.switchListTileFillColor, + this.switchListTileTitleStyle, + this.switchListTileErrorStyle, + this.switchListTileBorderRadius, + }); + + /// The background color of the poll creator. + final Color? backgroundColor; + + /// The text style of the appBar title. + final TextStyle? appBarTitleStyle; + + /// The elevation of the appBar. + final double? appBarElevation; + + /// The background color of the appBar. + final Color? appBarBackgroundColor; + + /// The fill color of the question text field. + final Color? questionTextFieldFillColor; + + /// The style of the question header text. + final TextStyle? questionHeaderStyle; + + /// The text style of the question text field. + final TextStyle? questionTextFieldStyle; + + /// The text style of the question error text when the question is invalid. + final TextStyle? questionTextFieldErrorStyle; + + /// The border radius of the question text field. + final BorderRadius? questionTextFieldBorderRadius; + + /// The fill color of the options text field. + final Color? optionsTextFieldFillColor; + + /// The style of the options header text. + final TextStyle? optionsHeaderStyle; + + /// The text style of the options text field. + final TextStyle? optionsTextFieldStyle; + + /// The text style of the options error text when the options are invalid. + final TextStyle? optionsTextFieldErrorStyle; + + /// The border radius of the options text field. + final BorderRadius? optionsTextFieldBorderRadius; + + /// The fill color of the switch list tile. + final Color? switchListTileFillColor; + + /// The text style of the switch list tile title. + final TextStyle? switchListTileTitleStyle; + + /// The text style of the switch list tile error text when the switch list + /// tile is invalid. + final TextStyle? switchListTileErrorStyle; + + /// The border radius of the switch list tile. + final BorderRadius? switchListTileBorderRadius; + + /// Copies this [StreamPollCreatorThemeData] with some new values. + StreamPollCreatorThemeData copyWith({ + Color? backgroundColor, + TextStyle? appBarTitleStyle, + double? appBarElevation, + Color? appBarBackgroundColor, + Color? questionTextFieldFillColor, + TextStyle? questionHeaderStyle, + TextStyle? questionTextFieldStyle, + TextStyle? questionTextFieldErrorStyle, + BorderRadius? questionTextFieldBorderRadius, + Color? optionsTextFieldFillColor, + TextStyle? optionsHeaderStyle, + TextStyle? optionsTextFieldStyle, + TextStyle? optionsTextFieldErrorStyle, + BorderRadius? optionsTextFieldBorderRadius, + Color? switchListTileFillColor, + TextStyle? switchListTileTitleStyle, + TextStyle? switchListTileErrorStyle, + BorderRadius? switchListTileBorderRadius, + }) { + return StreamPollCreatorThemeData( + backgroundColor: backgroundColor ?? this.backgroundColor, + appBarTitleStyle: appBarTitleStyle ?? this.appBarTitleStyle, + appBarElevation: appBarElevation ?? this.appBarElevation, + appBarBackgroundColor: + appBarBackgroundColor ?? this.appBarBackgroundColor, + questionTextFieldFillColor: + questionTextFieldFillColor ?? this.questionTextFieldFillColor, + questionHeaderStyle: questionHeaderStyle ?? this.questionHeaderStyle, + questionTextFieldStyle: + questionTextFieldStyle ?? this.questionTextFieldStyle, + questionTextFieldErrorStyle: + questionTextFieldErrorStyle ?? this.questionTextFieldErrorStyle, + questionTextFieldBorderRadius: + questionTextFieldBorderRadius ?? this.questionTextFieldBorderRadius, + optionsTextFieldFillColor: + optionsTextFieldFillColor ?? this.optionsTextFieldFillColor, + optionsHeaderStyle: optionsHeaderStyle ?? this.optionsHeaderStyle, + optionsTextFieldStyle: + optionsTextFieldStyle ?? this.optionsTextFieldStyle, + optionsTextFieldErrorStyle: + optionsTextFieldErrorStyle ?? this.optionsTextFieldErrorStyle, + optionsTextFieldBorderRadius: + optionsTextFieldBorderRadius ?? this.optionsTextFieldBorderRadius, + switchListTileFillColor: + switchListTileFillColor ?? this.switchListTileFillColor, + switchListTileTitleStyle: + switchListTileTitleStyle ?? this.switchListTileTitleStyle, + switchListTileErrorStyle: + switchListTileErrorStyle ?? this.switchListTileErrorStyle, + switchListTileBorderRadius: + switchListTileBorderRadius ?? this.switchListTileBorderRadius, + ); + } + + /// Merges [this] [StreamPollCreatorThemeData] with the [other] + StreamPollCreatorThemeData merge(StreamPollCreatorThemeData? other) { + if (other == null) return this; + return copyWith( + backgroundColor: other.backgroundColor ?? backgroundColor, + appBarTitleStyle: other.appBarTitleStyle ?? appBarTitleStyle, + appBarElevation: other.appBarElevation ?? appBarElevation, + appBarBackgroundColor: + other.appBarBackgroundColor ?? appBarBackgroundColor, + questionTextFieldFillColor: + other.questionTextFieldFillColor ?? questionTextFieldFillColor, + questionHeaderStyle: other.questionHeaderStyle ?? questionHeaderStyle, + questionTextFieldStyle: + other.questionTextFieldStyle ?? questionTextFieldStyle, + questionTextFieldErrorStyle: + other.questionTextFieldErrorStyle ?? questionTextFieldErrorStyle, + questionTextFieldBorderRadius: + other.questionTextFieldBorderRadius ?? questionTextFieldBorderRadius, + optionsTextFieldFillColor: + other.optionsTextFieldFillColor ?? optionsTextFieldFillColor, + optionsHeaderStyle: other.optionsHeaderStyle ?? optionsHeaderStyle, + optionsTextFieldStyle: + other.optionsTextFieldStyle ?? optionsTextFieldStyle, + optionsTextFieldErrorStyle: + other.optionsTextFieldErrorStyle ?? optionsTextFieldErrorStyle, + optionsTextFieldBorderRadius: + other.optionsTextFieldBorderRadius ?? optionsTextFieldBorderRadius, + switchListTileFillColor: + other.switchListTileFillColor ?? switchListTileFillColor, + switchListTileTitleStyle: + other.switchListTileTitleStyle ?? switchListTileTitleStyle, + switchListTileErrorStyle: + other.switchListTileErrorStyle ?? switchListTileErrorStyle, + switchListTileBorderRadius: + other.switchListTileBorderRadius ?? switchListTileBorderRadius, + ); + } + + /// Linearly interpolate between two [StreamPollCreatorThemeData]. + StreamPollCreatorThemeData lerp( + StreamPollCreatorThemeData a, + StreamPollCreatorThemeData b, + double t, + ) { + return StreamPollCreatorThemeData( + backgroundColor: Color.lerp(a.backgroundColor, b.backgroundColor, t), + appBarTitleStyle: + TextStyle.lerp(a.appBarTitleStyle, b.appBarTitleStyle, t), + appBarElevation: lerpDouble(a.appBarElevation, b.appBarElevation, t), + appBarBackgroundColor: + Color.lerp(a.appBarBackgroundColor, b.appBarBackgroundColor, t), + questionTextFieldFillColor: Color.lerp( + a.questionTextFieldFillColor, b.questionTextFieldFillColor, t), + questionHeaderStyle: + TextStyle.lerp(a.questionHeaderStyle, b.questionHeaderStyle, t), + questionTextFieldStyle: + TextStyle.lerp(a.questionTextFieldStyle, b.questionTextFieldStyle, t), + questionTextFieldErrorStyle: TextStyle.lerp( + a.questionTextFieldErrorStyle, b.questionTextFieldErrorStyle, t), + questionTextFieldBorderRadius: BorderRadius.lerp( + a.questionTextFieldBorderRadius, b.questionTextFieldBorderRadius, t), + optionsTextFieldFillColor: Color.lerp( + a.optionsTextFieldFillColor, b.optionsTextFieldFillColor, t), + optionsHeaderStyle: + TextStyle.lerp(a.optionsHeaderStyle, b.optionsHeaderStyle, t), + optionsTextFieldStyle: + TextStyle.lerp(a.optionsTextFieldStyle, b.optionsTextFieldStyle, t), + optionsTextFieldErrorStyle: TextStyle.lerp( + a.optionsTextFieldErrorStyle, b.optionsTextFieldErrorStyle, t), + optionsTextFieldBorderRadius: BorderRadius.lerp( + a.optionsTextFieldBorderRadius, b.optionsTextFieldBorderRadius, t), + switchListTileFillColor: + Color.lerp(a.switchListTileFillColor, b.switchListTileFillColor, t), + switchListTileTitleStyle: TextStyle.lerp( + a.switchListTileTitleStyle, b.switchListTileTitleStyle, t), + switchListTileErrorStyle: TextStyle.lerp( + a.switchListTileErrorStyle, b.switchListTileErrorStyle, t), + switchListTileBorderRadius: BorderRadius.lerp( + a.switchListTileBorderRadius, b.switchListTileBorderRadius, t), + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is StreamPollCreatorThemeData && + other.backgroundColor == backgroundColor && + other.appBarTitleStyle == appBarTitleStyle && + other.appBarElevation == appBarElevation && + other.appBarBackgroundColor == appBarBackgroundColor && + other.questionTextFieldFillColor == questionTextFieldFillColor && + other.questionHeaderStyle == questionHeaderStyle && + other.questionTextFieldStyle == questionTextFieldStyle && + other.questionTextFieldErrorStyle == questionTextFieldErrorStyle && + other.questionTextFieldBorderRadius == + questionTextFieldBorderRadius && + other.optionsTextFieldFillColor == optionsTextFieldFillColor && + other.optionsHeaderStyle == optionsHeaderStyle && + other.optionsTextFieldStyle == optionsTextFieldStyle && + other.optionsTextFieldErrorStyle == optionsTextFieldErrorStyle && + other.optionsTextFieldBorderRadius == optionsTextFieldBorderRadius && + other.switchListTileFillColor == switchListTileFillColor && + other.switchListTileTitleStyle == switchListTileTitleStyle && + other.switchListTileErrorStyle == switchListTileErrorStyle && + other.switchListTileBorderRadius == switchListTileBorderRadius; + + @override + int get hashCode => + backgroundColor.hashCode ^ + appBarTitleStyle.hashCode ^ + appBarElevation.hashCode ^ + appBarBackgroundColor.hashCode ^ + questionTextFieldFillColor.hashCode ^ + questionHeaderStyle.hashCode ^ + questionTextFieldStyle.hashCode ^ + questionTextFieldErrorStyle.hashCode ^ + questionTextFieldBorderRadius.hashCode ^ + optionsTextFieldFillColor.hashCode ^ + optionsHeaderStyle.hashCode ^ + optionsTextFieldStyle.hashCode ^ + optionsTextFieldErrorStyle.hashCode ^ + optionsTextFieldBorderRadius.hashCode ^ + switchListTileFillColor.hashCode ^ + switchListTileTitleStyle.hashCode ^ + switchListTileErrorStyle.hashCode ^ + switchListTileBorderRadius.hashCode; +} diff --git a/packages/stream_chat_flutter/lib/src/theme/stream_chat_theme.dart b/packages/stream_chat_flutter/lib/src/theme/stream_chat_theme.dart index 697e042f1..113125045 100644 --- a/packages/stream_chat_flutter/lib/src/theme/stream_chat_theme.dart +++ b/packages/stream_chat_flutter/lib/src/theme/stream_chat_theme.dart @@ -56,6 +56,7 @@ class StreamChatThemeData { StreamGalleryFooterThemeData? imageFooterTheme, StreamMessageListViewThemeData? messageListViewTheme, StreamVoiceRecordingThemeData? voiceRecordingTheme, + StreamPollCreatorThemeData? pollCreatorTheme, }) { brightness ??= colorTheme?.brightness ?? Brightness.light; final isDark = brightness == Brightness.dark; @@ -83,6 +84,7 @@ class StreamChatThemeData { galleryFooterTheme: imageFooterTheme, messageListViewTheme: messageListViewTheme, voiceRecordingTheme: voiceRecordingTheme, + pollCreatorTheme: pollCreatorTheme, ); return defaultData.merge(customizedData); @@ -111,6 +113,7 @@ class StreamChatThemeData { required this.galleryFooterTheme, required this.messageListViewTheme, required this.voiceRecordingTheme, + required this.pollCreatorTheme, }); /// Creates a theme from a Material [Theme] @@ -283,6 +286,44 @@ class StreamChatThemeData { voiceRecordingTheme: colorTheme.brightness == Brightness.dark ? StreamVoiceRecordingThemeData.dark() : StreamVoiceRecordingThemeData.light(), + pollCreatorTheme: StreamPollCreatorThemeData( + backgroundColor: colorTheme.appBg, + appBarBackgroundColor: colorTheme.barsBg, + appBarElevation: 1, + appBarTitleStyle: textTheme.headlineBold.copyWith( + color: colorTheme.textHighEmphasis, + ), + questionTextFieldFillColor: colorTheme.inputBg, + questionHeaderStyle: textTheme.headline.copyWith( + color: colorTheme.textHighEmphasis, + ), + questionTextFieldStyle: textTheme.headline.copyWith( + color: colorTheme.textHighEmphasis, + ), + questionTextFieldErrorStyle: textTheme.footnote.copyWith( + color: colorTheme.accentError, + ), + questionTextFieldBorderRadius: BorderRadius.circular(12), + optionsTextFieldFillColor: colorTheme.inputBg, + optionsHeaderStyle: textTheme.headline.copyWith( + color: colorTheme.textHighEmphasis, + ), + optionsTextFieldStyle: textTheme.headline.copyWith( + color: colorTheme.textHighEmphasis, + ), + optionsTextFieldErrorStyle: textTheme.footnote.copyWith( + color: colorTheme.accentError, + ), + optionsTextFieldBorderRadius: BorderRadius.circular(12), + switchListTileFillColor: colorTheme.inputBg, + switchListTileTitleStyle: textTheme.headline.copyWith( + color: colorTheme.textHighEmphasis, + ), + switchListTileErrorStyle: textTheme.footnote.copyWith( + color: colorTheme.accentError, + ), + switchListTileBorderRadius: BorderRadius.circular(12), + ), ); } @@ -327,6 +368,9 @@ class StreamChatThemeData { /// Theme configuration for the [StreamVoiceRecordingListPLayer] widget. final StreamVoiceRecordingThemeData voiceRecordingTheme; + /// Theme configuration for the [StreamPollCreatorWidget] widget. + final StreamPollCreatorThemeData pollCreatorTheme; + /// Creates a copy of [StreamChatThemeData] with specified attributes /// overridden. StreamChatThemeData copyWith({ @@ -347,6 +391,7 @@ class StreamChatThemeData { StreamGalleryFooterThemeData? galleryFooterTheme, StreamMessageListViewThemeData? messageListViewTheme, StreamVoiceRecordingThemeData? voiceRecordingTheme, + StreamPollCreatorThemeData? pollCreatorTheme, }) => StreamChatThemeData.raw( channelListHeaderTheme: @@ -364,6 +409,7 @@ class StreamChatThemeData { galleryFooterTheme: galleryFooterTheme ?? this.galleryFooterTheme, messageListViewTheme: messageListViewTheme ?? this.messageListViewTheme, voiceRecordingTheme: voiceRecordingTheme ?? this.voiceRecordingTheme, + pollCreatorTheme: pollCreatorTheme ?? this.pollCreatorTheme, ); /// Merge themes @@ -385,6 +431,7 @@ class StreamChatThemeData { messageListViewTheme: messageListViewTheme.merge(other.messageListViewTheme), voiceRecordingTheme: voiceRecordingTheme.merge(other.voiceRecordingTheme), + pollCreatorTheme: pollCreatorTheme.merge(other.pollCreatorTheme), ); } } diff --git a/packages/stream_chat_flutter/lib/src/theme/text_theme.dart b/packages/stream_chat_flutter/lib/src/theme/text_theme.dart index e44dcc20f..9662b4bcd 100644 --- a/packages/stream_chat_flutter/lib/src/theme/text_theme.dart +++ b/packages/stream_chat_flutter/lib/src/theme/text_theme.dart @@ -8,41 +8,42 @@ class StreamTextTheme { StreamTextTheme.light({ this.title = const TextStyle( fontSize: 22, - fontWeight: FontWeight.bold, + fontWeight: FontWeight.w500, color: Colors.black, ), - this.headlineBold = const TextStyle( + this.headline = const TextStyle( fontSize: 16, - fontWeight: FontWeight.bold, + fontWeight: FontWeight.w400, color: Colors.black, ), - this.headline = const TextStyle( + this.headlineBold = const TextStyle( fontSize: 16, fontWeight: FontWeight.w500, color: Colors.black, ), - this.bodyBold = const TextStyle( + this.body = const TextStyle( fontSize: 14, - fontWeight: FontWeight.bold, + fontWeight: FontWeight.w400, color: Colors.black, ), - this.body = const TextStyle( + this.bodyBold = const TextStyle( fontSize: 14, fontWeight: FontWeight.w500, color: Colors.black, ), - this.footnoteBold = const TextStyle( + this.footnote = const TextStyle( fontSize: 12, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w400, color: Colors.black, ), - this.footnote = const TextStyle( + this.footnoteBold = const TextStyle( fontSize: 12, + fontWeight: FontWeight.w500, color: Colors.black, ), this.captionBold = const TextStyle( fontSize: 10, - fontWeight: FontWeight.bold, + fontWeight: FontWeight.w700, color: Colors.black, ), }); @@ -51,41 +52,42 @@ class StreamTextTheme { StreamTextTheme.dark({ this.title = const TextStyle( fontSize: 22, - fontWeight: FontWeight.bold, + fontWeight: FontWeight.w500, color: Colors.white, ), - this.headlineBold = const TextStyle( + this.headline = const TextStyle( fontSize: 16, - fontWeight: FontWeight.bold, + fontWeight: FontWeight.w400, color: Colors.white, ), - this.headline = const TextStyle( + this.headlineBold = const TextStyle( fontSize: 16, fontWeight: FontWeight.w500, color: Colors.white, ), - this.bodyBold = const TextStyle( + this.body = const TextStyle( fontSize: 14, - fontWeight: FontWeight.bold, + fontWeight: FontWeight.w400, color: Colors.white, ), - this.body = const TextStyle( + this.bodyBold = const TextStyle( fontSize: 14, fontWeight: FontWeight.w500, color: Colors.white, ), - this.footnoteBold = const TextStyle( + this.footnote = const TextStyle( fontSize: 12, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w400, color: Colors.white, ), - this.footnote = const TextStyle( + this.footnoteBold = const TextStyle( fontSize: 12, + fontWeight: FontWeight.w500, color: Colors.white, ), this.captionBold = const TextStyle( fontSize: 10, - fontWeight: FontWeight.bold, + fontWeight: FontWeight.w700, color: Colors.white, ), }); diff --git a/packages/stream_chat_flutter/lib/src/theme/themes.dart b/packages/stream_chat_flutter/lib/src/theme/themes.dart index 1ecabd864..39189d23a 100644 --- a/packages/stream_chat_flutter/lib/src/theme/themes.dart +++ b/packages/stream_chat_flutter/lib/src/theme/themes.dart @@ -8,5 +8,6 @@ export 'gallery_header_theme.dart'; export 'message_input_theme.dart'; export 'message_list_view_theme.dart'; export 'message_theme.dart'; +export 'poll_creator_theme.dart'; export 'text_theme.dart'; export 'voice_attachment_theme.dart'; diff --git a/packages/stream_chat_flutter/lib/src/utils/extensions.dart b/packages/stream_chat_flutter/lib/src/utils/extensions.dart index 3987817c0..5b22c5b05 100644 --- a/packages/stream_chat_flutter/lib/src/utils/extensions.dart +++ b/packages/stream_chat_flutter/lib/src/utils/extensions.dart @@ -497,6 +497,8 @@ extension AttachmentPickerTypeX on AttachmentPickerType { return FileType.any; case AttachmentPickerType.audios: return FileType.audio; + case AttachmentPickerType.poll: + throw Exception('Polls do not have a file type'); } } } diff --git a/packages/stream_chat_flutter/lib/svgs/Icon_camera.svg b/packages/stream_chat_flutter/lib/svgs/Icon_camera.svg index a5a7d99d9..f7903f30d 100644 --- a/packages/stream_chat_flutter/lib/svgs/Icon_camera.svg +++ b/packages/stream_chat_flutter/lib/svgs/Icon_camera.svg @@ -1,3 +1,3 @@ - - + + diff --git a/packages/stream_chat_flutter/lib/svgs/Icon_record.svg b/packages/stream_chat_flutter/lib/svgs/Icon_record.svg index 64d02895c..4a240d956 100644 --- a/packages/stream_chat_flutter/lib/svgs/Icon_record.svg +++ b/packages/stream_chat_flutter/lib/svgs/Icon_record.svg @@ -1,3 +1,3 @@ - - + + diff --git a/packages/stream_chat_flutter/lib/svgs/files.svg b/packages/stream_chat_flutter/lib/svgs/files.svg index 5d72fdd33..63fd1585b 100644 --- a/packages/stream_chat_flutter/lib/svgs/files.svg +++ b/packages/stream_chat_flutter/lib/svgs/files.svg @@ -1,3 +1,3 @@ - - + + diff --git a/packages/stream_chat_flutter/lib/svgs/pictures.svg b/packages/stream_chat_flutter/lib/svgs/pictures.svg index 64e6ca344..d7c5a9da5 100644 --- a/packages/stream_chat_flutter/lib/svgs/pictures.svg +++ b/packages/stream_chat_flutter/lib/svgs/pictures.svg @@ -1,3 +1,3 @@ - - + + diff --git a/packages/stream_chat_flutter/lib/svgs/polls.svg b/packages/stream_chat_flutter/lib/svgs/polls.svg new file mode 100644 index 000000000..2e4277466 --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/polls.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/stream_chat_flutter/lib/svgs/send.svg b/packages/stream_chat_flutter/lib/svgs/send.svg new file mode 100644 index 000000000..2a3ccd78d --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/send.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/stream_chat_flutter/test/src/goldens/attachment_button_0.png b/packages/stream_chat_flutter/test/src/goldens/attachment_button_0.png index 096c345d2..918525fa6 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/attachment_button_0.png and b/packages/stream_chat_flutter/test/src/goldens/attachment_button_0.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/attachment_modal_sheet_0.png b/packages/stream_chat_flutter/test/src/goldens/attachment_modal_sheet_0.png index c4704cde9..5bd396394 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/attachment_modal_sheet_0.png and b/packages/stream_chat_flutter/test/src/goldens/attachment_modal_sheet_0.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/clear_input_item_0.png b/packages/stream_chat_flutter/test/src/goldens/clear_input_item_0.png index 53e98a8bc..fca64b737 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/clear_input_item_0.png and b/packages/stream_chat_flutter/test/src/goldens/clear_input_item_0.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/command_button_0.png b/packages/stream_chat_flutter/test/src/goldens/command_button_0.png index 0674402d2..a240087be 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/command_button_0.png and b/packages/stream_chat_flutter/test/src/goldens/command_button_0.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/confirmation_dialog_0.png b/packages/stream_chat_flutter/test/src/goldens/confirmation_dialog_0.png index 55b0c8c94..ca91c4724 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/confirmation_dialog_0.png and b/packages/stream_chat_flutter/test/src/goldens/confirmation_dialog_0.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/countdown_button_0.png b/packages/stream_chat_flutter/test/src/goldens/countdown_button_0.png index 8623c9c36..9f60150c7 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/countdown_button_0.png and b/packages/stream_chat_flutter/test/src/goldens/countdown_button_0.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/delete_message_dialog_0.png b/packages/stream_chat_flutter/test/src/goldens/delete_message_dialog_0.png index e144913d4..d5a7a6cc5 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/delete_message_dialog_0.png and b/packages/stream_chat_flutter/test/src/goldens/delete_message_dialog_0.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/deleted_message_custom.png b/packages/stream_chat_flutter/test/src/goldens/deleted_message_custom.png index 5bb1cc015..1da95a49b 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/deleted_message_custom.png and b/packages/stream_chat_flutter/test/src/goldens/deleted_message_custom.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/deleted_message_dark.png b/packages/stream_chat_flutter/test/src/goldens/deleted_message_dark.png index 42243eb06..42ce0b52a 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/deleted_message_dark.png and b/packages/stream_chat_flutter/test/src/goldens/deleted_message_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/deleted_message_light.png b/packages/stream_chat_flutter/test/src/goldens/deleted_message_light.png index 6770182ce..5c3f2932a 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/deleted_message_light.png and b/packages/stream_chat_flutter/test/src/goldens/deleted_message_light.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/dm_checkbox_0.png b/packages/stream_chat_flutter/test/src/goldens/dm_checkbox_0.png index 8e84f2e9e..ca8bce21d 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/dm_checkbox_0.png and b/packages/stream_chat_flutter/test/src/goldens/dm_checkbox_0.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/dm_checkbox_1.png b/packages/stream_chat_flutter/test/src/goldens/dm_checkbox_1.png index 51f7c4366..82291f7bd 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/dm_checkbox_1.png and b/packages/stream_chat_flutter/test/src/goldens/dm_checkbox_1.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/dm_checkbox_2.png b/packages/stream_chat_flutter/test/src/goldens/dm_checkbox_2.png index 5d411f66c..ec92bb513 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/dm_checkbox_2.png and b/packages/stream_chat_flutter/test/src/goldens/dm_checkbox_2.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/download_menu_item_0.png b/packages/stream_chat_flutter/test/src/goldens/download_menu_item_0.png index b1fbcad84..a301600d7 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/download_menu_item_0.png and b/packages/stream_chat_flutter/test/src/goldens/download_menu_item_0.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/edit_message_sheet_0.png b/packages/stream_chat_flutter/test/src/goldens/edit_message_sheet_0.png index 2cdcf5e60..c416907ec 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/edit_message_sheet_0.png and b/packages/stream_chat_flutter/test/src/goldens/edit_message_sheet_0.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/error_alert_sheet_0.png b/packages/stream_chat_flutter/test/src/goldens/error_alert_sheet_0.png index 4c1637736..831a1b4d7 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/error_alert_sheet_0.png and b/packages/stream_chat_flutter/test/src/goldens/error_alert_sheet_0.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/gallery_footer_0.png b/packages/stream_chat_flutter/test/src/goldens/gallery_footer_0.png index 203d78420..5a803c904 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/gallery_footer_0.png and b/packages/stream_chat_flutter/test/src/goldens/gallery_footer_0.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/gallery_header_0.png b/packages/stream_chat_flutter/test/src/goldens/gallery_header_0.png index eaac9b114..ad52eee94 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/gallery_header_0.png and b/packages/stream_chat_flutter/test/src/goldens/gallery_header_0.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/gradient_avatar_0.png b/packages/stream_chat_flutter/test/src/goldens/gradient_avatar_0.png index 5b153d86f..9425815da 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/gradient_avatar_0.png and b/packages/stream_chat_flutter/test/src/goldens/gradient_avatar_0.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/gradient_avatar_1.png b/packages/stream_chat_flutter/test/src/goldens/gradient_avatar_1.png index 4d3ab3b6e..66176c4cd 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/gradient_avatar_1.png and b/packages/stream_chat_flutter/test/src/goldens/gradient_avatar_1.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/gradient_avatar_2.png b/packages/stream_chat_flutter/test/src/goldens/gradient_avatar_2.png index 30fe9dc9f..ceb9226a5 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/gradient_avatar_2.png and b/packages/stream_chat_flutter/test/src/goldens/gradient_avatar_2.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/gradient_avatar_3.png b/packages/stream_chat_flutter/test/src/goldens/gradient_avatar_3.png index 6b3f5cdb1..1de2bd40c 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/gradient_avatar_3.png and b/packages/stream_chat_flutter/test/src/goldens/gradient_avatar_3.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/group_avatar_0.png b/packages/stream_chat_flutter/test/src/goldens/group_avatar_0.png index 5eb45d370..6b184f3e5 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/group_avatar_0.png and b/packages/stream_chat_flutter/test/src/goldens/group_avatar_0.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/message_dialog_0.png b/packages/stream_chat_flutter/test/src/goldens/message_dialog_0.png index 68abd020e..14cc95422 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/message_dialog_0.png and b/packages/stream_chat_flutter/test/src/goldens/message_dialog_0.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/message_dialog_1.png b/packages/stream_chat_flutter/test/src/goldens/message_dialog_1.png index b24758f9a..45f34fbef 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/message_dialog_1.png and b/packages/stream_chat_flutter/test/src/goldens/message_dialog_1.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/message_dialog_2.png b/packages/stream_chat_flutter/test/src/goldens/message_dialog_2.png index 4e31bd438..64a0ae9d4 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/message_dialog_2.png and b/packages/stream_chat_flutter/test/src/goldens/message_dialog_2.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/message_text.png b/packages/stream_chat_flutter/test/src/goldens/message_text.png index df038fe56..cc8c19662 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/message_text.png and b/packages/stream_chat_flutter/test/src/goldens/message_text.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/poll_option_reorderable_list_view_dark.png b/packages/stream_chat_flutter/test/src/goldens/poll_option_reorderable_list_view_dark.png new file mode 100644 index 000000000..92e410589 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/goldens/poll_option_reorderable_list_view_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/poll_option_reorderable_list_view_error.png b/packages/stream_chat_flutter/test/src/goldens/poll_option_reorderable_list_view_error.png new file mode 100644 index 000000000..78c610349 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/goldens/poll_option_reorderable_list_view_error.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/poll_option_reorderable_list_view_light.png b/packages/stream_chat_flutter/test/src/goldens/poll_option_reorderable_list_view_light.png new file mode 100644 index 000000000..f52da6834 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/goldens/poll_option_reorderable_list_view_light.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/poll_question_text_field_dark.png b/packages/stream_chat_flutter/test/src/goldens/poll_question_text_field_dark.png new file mode 100644 index 000000000..fff3f6c4d Binary files /dev/null and b/packages/stream_chat_flutter/test/src/goldens/poll_question_text_field_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/poll_question_text_field_error.png b/packages/stream_chat_flutter/test/src/goldens/poll_question_text_field_error.png new file mode 100644 index 000000000..9c2825b7d Binary files /dev/null and b/packages/stream_chat_flutter/test/src/goldens/poll_question_text_field_error.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/poll_question_text_field_light.png b/packages/stream_chat_flutter/test/src/goldens/poll_question_text_field_light.png new file mode 100644 index 000000000..4cf9acaa3 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/goldens/poll_question_text_field_light.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/reaction_bubble_2.png b/packages/stream_chat_flutter/test/src/goldens/reaction_bubble_2.png index aeb97727d..f4ce18e8c 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/reaction_bubble_2.png and b/packages/stream_chat_flutter/test/src/goldens/reaction_bubble_2.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/reaction_bubble_3_dark.png b/packages/stream_chat_flutter/test/src/goldens/reaction_bubble_3_dark.png index 45d6fd59c..e358803dd 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/reaction_bubble_3_dark.png and b/packages/stream_chat_flutter/test/src/goldens/reaction_bubble_3_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/reaction_bubble_3_light.png b/packages/stream_chat_flutter/test/src/goldens/reaction_bubble_3_light.png index 3b9e6366f..6d4b2dba8 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/reaction_bubble_3_light.png and b/packages/stream_chat_flutter/test/src/goldens/reaction_bubble_3_light.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/reaction_bubble_like_dark.png b/packages/stream_chat_flutter/test/src/goldens/reaction_bubble_like_dark.png index af735d6a2..9103b1dd9 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/reaction_bubble_like_dark.png and b/packages/stream_chat_flutter/test/src/goldens/reaction_bubble_like_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/reaction_bubble_like_light.png b/packages/stream_chat_flutter/test/src/goldens/reaction_bubble_like_light.png index 7187ab2d8..ffceeb7d7 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/reaction_bubble_like_light.png and b/packages/stream_chat_flutter/test/src/goldens/reaction_bubble_like_light.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/sending_indicator_0.png b/packages/stream_chat_flutter/test/src/goldens/sending_indicator_0.png index 58d1f8901..1c964c9a3 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/sending_indicator_0.png and b/packages/stream_chat_flutter/test/src/goldens/sending_indicator_0.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/sending_indicator_1.png b/packages/stream_chat_flutter/test/src/goldens/sending_indicator_1.png index 4c92e52e7..b61584de0 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/sending_indicator_1.png and b/packages/stream_chat_flutter/test/src/goldens/sending_indicator_1.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/sending_indicator_2.png b/packages/stream_chat_flutter/test/src/goldens/sending_indicator_2.png index 4c92e52e7..b61584de0 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/sending_indicator_2.png and b/packages/stream_chat_flutter/test/src/goldens/sending_indicator_2.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/stream_chat_context_menu_item_0.png b/packages/stream_chat_flutter/test/src/goldens/stream_chat_context_menu_item_0.png index 558b87538..e4b906ed3 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/stream_chat_context_menu_item_0.png and b/packages/stream_chat_flutter/test/src/goldens/stream_chat_context_menu_item_0.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/stream_poll_creator_dialog_dark.png b/packages/stream_chat_flutter/test/src/goldens/stream_poll_creator_dialog_dark.png new file mode 100644 index 000000000..716fd1bf0 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/goldens/stream_poll_creator_dialog_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/stream_poll_creator_dialog_light.png b/packages/stream_chat_flutter/test/src/goldens/stream_poll_creator_dialog_light.png new file mode 100644 index 000000000..2aab6565f Binary files /dev/null and b/packages/stream_chat_flutter/test/src/goldens/stream_poll_creator_dialog_light.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/stream_poll_creator_full_screen_dialog_dark.png b/packages/stream_chat_flutter/test/src/goldens/stream_poll_creator_full_screen_dialog_dark.png new file mode 100644 index 000000000..81bfd22da Binary files /dev/null and b/packages/stream_chat_flutter/test/src/goldens/stream_poll_creator_full_screen_dialog_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/stream_poll_creator_full_screen_dialog_light.png b/packages/stream_chat_flutter/test/src/goldens/stream_poll_creator_full_screen_dialog_light.png new file mode 100644 index 000000000..a6c70b437 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/goldens/stream_poll_creator_full_screen_dialog_light.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/system_message_dark.png b/packages/stream_chat_flutter/test/src/goldens/system_message_dark.png index 9055f705e..fd66b8962 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/system_message_dark.png and b/packages/stream_chat_flutter/test/src/goldens/system_message_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/system_message_light.png b/packages/stream_chat_flutter/test/src/goldens/system_message_light.png index d82d8d609..2c46540e4 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/system_message_light.png and b/packages/stream_chat_flutter/test/src/goldens/system_message_light.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/upload_progress_indicator_0.png b/packages/stream_chat_flutter/test/src/goldens/upload_progress_indicator_0.png index b07304494..35cd2acde 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/upload_progress_indicator_0.png and b/packages/stream_chat_flutter/test/src/goldens/upload_progress_indicator_0.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/upload_progress_indicator_1.png b/packages/stream_chat_flutter/test/src/goldens/upload_progress_indicator_1.png index 3e86d8205..f667dd8e5 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/upload_progress_indicator_1.png and b/packages/stream_chat_flutter/test/src/goldens/upload_progress_indicator_1.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/upload_progress_indicator_2.png b/packages/stream_chat_flutter/test/src/goldens/upload_progress_indicator_2.png index c4cfff20d..a748f229e 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/upload_progress_indicator_2.png and b/packages/stream_chat_flutter/test/src/goldens/upload_progress_indicator_2.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/user_avatar_0.png b/packages/stream_chat_flutter/test/src/goldens/user_avatar_0.png index 0224053b6..d5eed30d2 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/user_avatar_0.png and b/packages/stream_chat_flutter/test/src/goldens/user_avatar_0.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/user_avatar_1.png b/packages/stream_chat_flutter/test/src/goldens/user_avatar_1.png index 9546c8275..aa790d869 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/user_avatar_1.png and b/packages/stream_chat_flutter/test/src/goldens/user_avatar_1.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/poll_option_reorderable_list_view_test.dart b/packages/stream_chat_flutter/test/src/poll/poll_option_reorderable_list_view_test.dart new file mode 100644 index 000000000..4c4904d3b --- /dev/null +++ b/packages/stream_chat_flutter/test/src/poll/poll_option_reorderable_list_view_test.dart @@ -0,0 +1,110 @@ +import 'package:flutter/material.dart'; +import 'package:golden_toolkit/golden_toolkit.dart'; +import 'package:stream_chat_flutter/src/poll/poll_option_reorderable_list_view.dart'; +import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; + +void main() { + testGoldens( + '[Light] -> PollOptionReorderableListView should look fine', + (tester) async { + await tester.pumpWidgetBuilder( + PollOptionReorderableListView( + title: 'Options', + itemHintText: 'Add an option', + initialOptions: [ + PollOptionItem(text: 'Option 1'), + PollOptionItem(text: 'Option 2'), + PollOptionItem(text: 'Option 3'), + PollOptionItem(text: 'Option 4'), + ], + ), + surfaceSize: const Size(600, 500), + wrapper: (child) => _wrapWithMaterialApp( + child, + brightness: Brightness.light, + ), + ); + + await screenMatchesGolden( + tester, + 'poll_option_reorderable_list_view_light', + ); + }, + ); + + testGoldens( + '[Dark] -> PollOptionReorderableListView should look fine', + (tester) async { + await tester.pumpWidgetBuilder( + PollOptionReorderableListView( + title: 'Options', + itemHintText: 'Add an option', + initialOptions: [ + PollOptionItem(text: 'Option 1'), + PollOptionItem(text: 'Option 2'), + PollOptionItem(text: 'Option 3'), + PollOptionItem(text: 'Option 4'), + ], + ), + surfaceSize: const Size(600, 500), + wrapper: (child) => _wrapWithMaterialApp( + child, + brightness: Brightness.dark, + ), + ); + + await screenMatchesGolden( + tester, + 'poll_option_reorderable_list_view_dark', + ); + }, + ); + + testGoldens( + '[Error] -> PollOptionReorderableListView should look fine', + (tester) async { + await tester.pumpWidgetBuilder( + PollOptionReorderableListView( + title: 'Options', + itemHintText: 'Add an option', + initialOptions: [ + PollOptionItem(text: 'Option 1', error: 'Option already exists'), + PollOptionItem(text: 'Option 1', error: 'Option already exists'), + PollOptionItem(text: 'Option 3'), + PollOptionItem(text: 'Option 4'), + ], + ), + surfaceSize: const Size(600, 500), + wrapper: _wrapWithMaterialApp, + ); + + await screenMatchesGolden( + tester, + 'poll_option_reorderable_list_view_error', + ); + }, + ); +} + +Widget _wrapWithMaterialApp( + Widget widget, { + Brightness? brightness, +}) { + return MaterialApp( + home: StreamChatTheme( + data: StreamChatThemeData(brightness: brightness), + child: Builder(builder: (context) { + final theme = StreamChatTheme.of(context); + return Scaffold( + backgroundColor: theme.colorTheme.appBg, + body: Center( + child: Padding( + padding: const EdgeInsets.all(8), + child: widget, + ), + ), + ); + }), + ), + ); +} diff --git a/packages/stream_chat_flutter/test/src/poll/poll_question_text_field_test.dart b/packages/stream_chat_flutter/test/src/poll/poll_question_text_field_test.dart new file mode 100644 index 000000000..f352b16d3 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/poll/poll_question_text_field_test.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; +import 'package:golden_toolkit/golden_toolkit.dart'; +import 'package:stream_chat_flutter/src/poll/poll_question_text_field.dart'; +import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; + +void main() { + testGoldens( + '[Light] -> PollQuestionTextField should look fine', + (tester) async { + await tester.pumpWidgetBuilder( + PollQuestionTextField( + title: 'Question', + hintText: 'Ask a question', + initialQuestion: PollQuestion(), + ), + surfaceSize: const Size(600, 150), + wrapper: (child) => _wrapWithMaterialApp( + child, + brightness: Brightness.light, + ), + ); + + await screenMatchesGolden(tester, 'poll_question_text_field_light'); + }, + ); + + testGoldens( + '[Dark] -> PollQuestionTextField should look fine', + (tester) async { + await tester.pumpWidgetBuilder( + PollQuestionTextField( + title: 'Question', + initialQuestion: PollQuestion( + text: 'A very long question that should not be allowed', + error: 'Question should be at most 10 characters long', + ), + ), + surfaceSize: const Size(600, 150), + wrapper: _wrapWithMaterialApp, + ); + + await screenMatchesGolden(tester, 'poll_question_text_field_error'); + }, + ); + + testGoldens( + '[Error] -> PollQuestionTextField should look fine', + (tester) async { + await tester.pumpWidgetBuilder( + PollQuestionTextField( + title: 'Question', + hintText: 'Ask a question', + initialQuestion: PollQuestion(), + ), + surfaceSize: const Size(600, 150), + wrapper: (child) => _wrapWithMaterialApp( + child, + brightness: Brightness.dark, + ), + ); + + await screenMatchesGolden(tester, 'poll_question_text_field_dark'); + }, + ); +} + +Widget _wrapWithMaterialApp( + Widget widget, { + Brightness? brightness, +}) { + return MaterialApp( + home: StreamChatTheme( + data: StreamChatThemeData(brightness: brightness), + child: Builder(builder: (context) { + final theme = StreamChatTheme.of(context); + return Scaffold( + backgroundColor: theme.colorTheme.appBg, + body: Center( + child: Padding( + padding: const EdgeInsets.all(8), + child: widget, + ), + ), + ); + }), + ), + ); +} diff --git a/packages/stream_chat_flutter/test/src/poll/stream_poll_creator_dialog_test.dart b/packages/stream_chat_flutter/test/src/poll/stream_poll_creator_dialog_test.dart new file mode 100644 index 000000000..0b7026f60 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/poll/stream_poll_creator_dialog_test.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; +import 'package:golden_toolkit/golden_toolkit.dart'; +import 'package:stream_chat_flutter/src/poll/stream_poll_creator_dialog.dart'; +import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; + +void main() { + testGoldens( + '[Light] -> StreamPollCreatorDialog should look fine', + (tester) async { + await tester.pumpWidgetBuilder( + const StreamPollCreatorDialog(), + surfaceSize: const Size(1280, 800), + wrapper: (child) => _wrapWithMaterialApp( + child, + brightness: Brightness.light, + ), + ); + + await screenMatchesGolden(tester, 'stream_poll_creator_dialog_light'); + }, + ); + + testGoldens( + '[Dark] -> StreamPollCreatorDialog should look fine', + (tester) async { + await tester.pumpWidgetBuilder( + const StreamPollCreatorDialog(), + surfaceSize: const Size(1280, 800), + wrapper: (child) => _wrapWithMaterialApp( + child, + brightness: Brightness.dark, + ), + ); + + await screenMatchesGolden(tester, 'stream_poll_creator_dialog_dark'); + }, + ); + + testGoldens( + '[Light] -> StreamPollCreatorFullScreenDialog should look fine', + (tester) async { + await tester.pumpWidgetBuilder( + const StreamPollCreatorFullScreenDialog(), + surfaceSize: const Size(412, 916), + wrapper: (child) => _wrapWithMaterialApp( + child, + brightness: Brightness.light, + ), + ); + + await screenMatchesGolden( + tester, + 'stream_poll_creator_full_screen_dialog_light', + ); + }, + ); + + testGoldens( + '[Dark] -> StreamPollCreatorFullScreenDialog should look fine', + (tester) async { + await tester.pumpWidgetBuilder( + const StreamPollCreatorFullScreenDialog(), + surfaceSize: const Size(412, 916), + wrapper: (child) => _wrapWithMaterialApp( + child, + brightness: Brightness.dark, + ), + ); + + await screenMatchesGolden( + tester, + 'stream_poll_creator_full_screen_dialog_dark', + ); + }, + ); +} + +Widget _wrapWithMaterialApp( + Widget widget, { + Brightness? brightness, +}) { + return MaterialApp( + home: StreamChatTheme( + data: StreamChatThemeData(brightness: brightness), + child: widget, + ), + ); +} diff --git a/packages/stream_chat_flutter/test/src/poll/stream_poll_creator_widget_test.dart b/packages/stream_chat_flutter/test/src/poll/stream_poll_creator_widget_test.dart new file mode 100644 index 000000000..c9d1ccd39 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/poll/stream_poll_creator_widget_test.dart @@ -0,0 +1,118 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter/src/poll/poll_option_reorderable_list_view.dart'; +import 'package:stream_chat_flutter/src/poll/poll_question_text_field.dart'; +import 'package:stream_chat_flutter/src/poll/poll_switch_list_tile.dart'; +import 'package:stream_chat_flutter/src/poll/stream_poll_creator_widget.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +void main() { + testWidgets('StreamPollCreatorWidget renders correctly', (tester) async { + final controller = StreamPollController( + config: const PollConfig( + nameRange: (min: 1, max: 150), + allowedVotesRange: (min: 1, max: 10), + ), + ); + + await tester.pumpWidget( + _wrapWithMaterialApp( + StreamPollCreatorWidget( + controller: controller, + ), + ), + ); + + // Verify that the widget is rendered correctly + expect(find.byType(PollQuestionTextField), findsOneWidget); + expect(find.byType(PollOptionReorderableListView), findsOneWidget); + expect(find.byType(PollSwitchListTile), findsNWidgets(4)); + }); + + testWidgets('StreamPollCreatorWidget updates poll state correctly', + (tester) async { + final controller = StreamPollController( + config: const PollConfig( + nameRange: (min: 1, max: 150), + allowedVotesRange: (min: 1, max: 10), + ), + ); + + await tester.pumpWidget( + _wrapWithMaterialApp( + StreamPollCreatorWidget( + controller: controller, + ), + ), + ); + + // Interact with the widget to update the poll state + await tester.enterText( + find.byType(PollQuestionTextField), + 'What is your favorite color?', + ); + await tester.pumpAndSettle(); + expect(controller.value.name, 'What is your favorite color?'); + + await tester.tap(find.switchListTileText('Multiple answers')); + await tester.pumpAndSettle(); + expect(controller.value.enforceUniqueVote, false); + + await tester.tap( + find.descendant( + of: find.byType(PollSwitchTextField), + matching: find.byType(Switch), + ), + ); + await tester.pumpAndSettle(); + expect(controller.value.maxVotesAllowed, null); + + await tester.enterText( + find.descendant( + of: find.byType(PollSwitchTextField), + matching: find.byType(TextField), + ), + '3', + ); + await tester.pumpAndSettle(); + expect(controller.value.maxVotesAllowed, 3); + + await tester.tap(find.switchListTileText('Anonymous poll')); + await tester.pumpAndSettle(); + expect(controller.value.votingVisibility, VotingVisibility.anonymous); + + await tester.tap(find.switchListTileText('Suggest an option')); + await tester.pumpAndSettle(); + expect(controller.value.allowUserSuggestedOptions, true); + + await tester.dragUntilVisible( + find.switchListTileText('Add a comment'), + find.byType(SingleChildScrollView), + const Offset(0, 500), + ); + + await tester.tap(find.switchListTileText('Add a comment')); + await tester.pumpAndSettle(); + expect(controller.value.allowAnswers, true); + }); +} + +extension on CommonFinders { + Finder switchListTileText(String title) { + return ancestor( + of: find.text(title), + matching: find.byType(SwitchListTile), + ); + } +} + +Widget _wrapWithMaterialApp(Widget widget) { + return MaterialApp( + home: StreamChatTheme( + data: StreamChatThemeData(), + child: Scaffold( + body: widget, + ), + ), + ); +} diff --git a/packages/stream_chat_flutter/test/src/theme/channel_header_theme_test.dart b/packages/stream_chat_flutter/test/src/theme/channel_header_theme_test.dart index 576e1a8a3..a4a618c0a 100644 --- a/packages/stream_chat_flutter/test/src/theme/channel_header_theme_test.dart +++ b/packages/stream_chat_flutter/test/src/theme/channel_header_theme_test.dart @@ -73,7 +73,7 @@ final _channelThemeControlMidLerp = StreamChannelHeaderThemeData( color: const Color(0xff111417), titleStyle: const TextStyle( color: Color(0xffffffff), - fontWeight: FontWeight.bold, + fontWeight: FontWeight.w500, fontSize: 16, ), subtitleStyle: StreamTextTheme.light().footnote.copyWith( diff --git a/packages/stream_chat_flutter/test/src/theme/channel_list_header_theme_test.dart b/packages/stream_chat_flutter/test/src/theme/channel_list_header_theme_test.dart index 1dd0340ed..65b7f958f 100644 --- a/packages/stream_chat_flutter/test/src/theme/channel_list_header_theme_test.dart +++ b/packages/stream_chat_flutter/test/src/theme/channel_list_header_theme_test.dart @@ -77,7 +77,7 @@ final _channelListHeaderThemeControlMidLerp = StreamChannelListHeaderThemeData( titleStyle: const TextStyle( color: Color(0xff7f7f7f), fontSize: 16, - fontWeight: FontWeight.bold, + fontWeight: FontWeight.w500, ), ); diff --git a/packages/stream_chat_flutter/test/src/theme/channel_preview_theme_test.dart b/packages/stream_chat_flutter/test/src/theme/channel_preview_theme_test.dart index 782be835e..e0e1d535d 100644 --- a/packages/stream_chat_flutter/test/src/theme/channel_preview_theme_test.dart +++ b/packages/stream_chat_flutter/test/src/theme/channel_preview_theme_test.dart @@ -78,11 +78,12 @@ final _channelPreviewThemeControlMidLerp = StreamChannelPreviewThemeData( titleStyle: const TextStyle( color: Color(0xff7f7f7f), fontSize: 14, - fontWeight: FontWeight.bold, + fontWeight: FontWeight.w500, ), subtitleStyle: const TextStyle( color: Color(0xff7a7a7a), fontSize: 12, + fontWeight: FontWeight.w400, ), lastMessageAtStyle: StreamTextTheme.light().footnote.copyWith( color: const Color(0x807f7f7f).withOpacity(0.5), diff --git a/packages/stream_chat_flutter/test/src/theme/gallery_footer_theme_test.dart b/packages/stream_chat_flutter/test/src/theme/gallery_footer_theme_test.dart index c66d41f1f..358d817a9 100644 --- a/packages/stream_chat_flutter/test/src/theme/gallery_footer_theme_test.dart +++ b/packages/stream_chat_flutter/test/src/theme/gallery_footer_theme_test.dart @@ -157,7 +157,7 @@ const _galleryFooterThemeDataControlMidLerp = StreamGalleryFooterThemeData( titleTextStyle: TextStyle( color: Color(0xff7f7f7f), fontSize: 16, - fontWeight: FontWeight.bold, + fontWeight: FontWeight.w500, ), gridIconButtonColor: Color(0xff7f7f7f), bottomSheetBarrierColor: Color(0x4c000000), @@ -165,7 +165,7 @@ const _galleryFooterThemeDataControlMidLerp = StreamGalleryFooterThemeData( bottomSheetPhotosTextStyle: TextStyle( color: Color(0xff7f7f7f), fontSize: 16, - fontWeight: FontWeight.bold, + fontWeight: FontWeight.w500, ), bottomSheetCloseIconColor: Color(0xff7f7f7f), ); diff --git a/packages/stream_chat_flutter/test/src/theme/gallery_header_theme_test.dart b/packages/stream_chat_flutter/test/src/theme/gallery_header_theme_test.dart index 27f98cb35..040a7696f 100644 --- a/packages/stream_chat_flutter/test/src/theme/gallery_header_theme_test.dart +++ b/packages/stream_chat_flutter/test/src/theme/gallery_header_theme_test.dart @@ -130,12 +130,13 @@ final _galleryHeaderThemeDataControl = StreamGalleryHeaderThemeData( iconMenuPointColor: const Color(0xff000000), titleTextStyle: const TextStyle( fontSize: 16, - fontWeight: FontWeight.bold, + fontWeight: FontWeight.w500, color: Colors.black, ), subtitleTextStyle: const TextStyle( fontSize: 12, color: Colors.black, + fontWeight: FontWeight.w400, ).copyWith( color: const Color(0xff7A7A7A), ), @@ -149,12 +150,13 @@ final _galleryHeaderThemeDataHalfLerpControl = StreamGalleryHeaderThemeData( iconMenuPointColor: const Color(0xff7f7f7f), titleTextStyle: const TextStyle( fontSize: 16, - fontWeight: FontWeight.bold, + fontWeight: FontWeight.w500, color: Color(0xff7f7f7f), ), subtitleTextStyle: const TextStyle( fontSize: 12, color: Color(0xff7a7a7a), + fontWeight: FontWeight.w400, ).copyWith( color: const Color(0xff7A7A7A), ), @@ -168,12 +170,13 @@ final _galleryHeaderThemeDataDarkControl = StreamGalleryHeaderThemeData( iconMenuPointColor: const Color(0xffffffff), titleTextStyle: const TextStyle( fontSize: 16, - fontWeight: FontWeight.bold, + fontWeight: FontWeight.w500, color: Colors.white, ), subtitleTextStyle: const TextStyle( fontSize: 12, color: Colors.white, + fontWeight: FontWeight.w400, ).copyWith( color: const Color(0xff7A7A7A), ), diff --git a/packages/stream_chat_flutter/test/src/theme/message_input_theme_test.dart b/packages/stream_chat_flutter/test/src/theme/message_input_theme_test.dart index 80912e9a1..019fd8bc0 100644 --- a/packages/stream_chat_flutter/test/src/theme/message_input_theme_test.dart +++ b/packages/stream_chat_flutter/test/src/theme/message_input_theme_test.dart @@ -77,7 +77,7 @@ final _messageInputThemeControlMidLerp = StreamMessageInputThemeData( inputTextStyle: const TextStyle( color: Color(0xff7f7f7f), fontSize: 14, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w400, ), idleBorderGradient: const LinearGradient( stops: [0.0, 1.0], diff --git a/packages/stream_chat_flutter_core/CHANGELOG.md b/packages/stream_chat_flutter_core/CHANGELOG.md index a5fd96acf..4fd99944a 100644 --- a/packages/stream_chat_flutter_core/CHANGELOG.md +++ b/packages/stream_chat_flutter_core/CHANGELOG.md @@ -1,3 +1,9 @@ +## Upcoming + +✅ Added + +- Added `StreamPollController` to create and manage a poll based on the passed configs. + ## 8.3.0 - Updated `stream_chat` dependency to [`8.3.0`](https://pub.dev/packages/stream_chat/changelog). diff --git a/packages/stream_chat_flutter_core/lib/src/paged_value_notifier.freezed.dart b/packages/stream_chat_flutter_core/lib/src/paged_value_notifier.freezed.dart index 4a85ff07f..5d34b66b0 100644 --- a/packages/stream_chat_flutter_core/lib/src/paged_value_notifier.freezed.dart +++ b/packages/stream_chat_flutter_core/lib/src/paged_value_notifier.freezed.dart @@ -12,7 +12,7 @@ part of 'paged_value_notifier.dart'; T _$identity(T value) => value; final _privateConstructorUsedError = UnsupportedError( - 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods'); + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); /// @nodoc mixin _$PagedValue { @@ -85,25 +85,31 @@ class _$PagedValueCopyWithImpl { - factory _$$SuccessCopyWith(_$Success value, - $Res Function(_$Success) then) = - __$$SuccessCopyWithImpl; +abstract class _$$SuccessImplCopyWith { + factory _$$SuccessImplCopyWith(_$SuccessImpl value, + $Res Function(_$SuccessImpl) then) = + __$$SuccessImplCopyWithImpl; @useResult $Res call({List items, Key? nextPageKey, StreamChatError? error}); } /// @nodoc -class __$$SuccessCopyWithImpl - extends _$PagedValueCopyWithImpl> - implements _$$SuccessCopyWith { - __$$SuccessCopyWithImpl( - _$Success _value, $Res Function(_$Success) _then) +class __$$SuccessImplCopyWithImpl + extends _$PagedValueCopyWithImpl> + implements _$$SuccessImplCopyWith { + __$$SuccessImplCopyWithImpl(_$SuccessImpl _value, + $Res Function(_$SuccessImpl) _then) : super(_value, _then); + /// Create a copy of PagedValue + /// with the given fields replaced by the non-null parameter values. @pragma('vm:prefer-inline') @override $Res call({ @@ -111,7 +117,7 @@ class __$$SuccessCopyWithImpl Object? nextPageKey = freezed, Object? error = freezed, }) { - return _then(_$Success( + return _then(_$SuccessImpl( items: null == items ? _value._items : items // ignore: cast_nullable_to_non_nullable @@ -130,9 +136,9 @@ class __$$SuccessCopyWithImpl /// @nodoc -class _$Success extends Success +class _$SuccessImpl extends Success with DiagnosticableTreeMixin { - const _$Success( + const _$SuccessImpl( {required final List items, this.nextPageKey, this.error}) : _items = items, super._(); @@ -172,10 +178,10 @@ class _$Success extends Success } @override - bool operator ==(dynamic other) { + bool operator ==(Object other) { return identical(this, other) || (other.runtimeType == runtimeType && - other is _$Success && + other is _$SuccessImpl && const DeepCollectionEquality().equals(other._items, _items) && const DeepCollectionEquality() .equals(other.nextPageKey, nextPageKey) && @@ -189,11 +195,13 @@ class _$Success extends Success const DeepCollectionEquality().hash(nextPageKey), error); - @JsonKey(ignore: true) + /// Create a copy of PagedValue + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) @override @pragma('vm:prefer-inline') - _$$SuccessCopyWith> get copyWith => - __$$SuccessCopyWithImpl>( + _$$SuccessImplCopyWith> get copyWith => + __$$SuccessImplCopyWithImpl>( this, _$identity); @override @@ -275,7 +283,7 @@ abstract class Success extends PagedValue { const factory Success( {required final List items, final Key? nextPageKey, - final StreamChatError? error}) = _$Success; + final StreamChatError? error}) = _$SuccessImpl; const Success._() : super._(); /// List with all items loaded so far. @@ -286,32 +294,39 @@ abstract class Success extends PagedValue { /// The current error, if any. StreamChatError? get error; - @JsonKey(ignore: true) - _$$SuccessCopyWith> get copyWith => + + /// Create a copy of PagedValue + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + _$$SuccessImplCopyWith> get copyWith => throw _privateConstructorUsedError; } /// @nodoc -abstract class _$$LoadingCopyWith { - factory _$$LoadingCopyWith(_$Loading value, - $Res Function(_$Loading) then) = - __$$LoadingCopyWithImpl; +abstract class _$$LoadingImplCopyWith { + factory _$$LoadingImplCopyWith(_$LoadingImpl value, + $Res Function(_$LoadingImpl) then) = + __$$LoadingImplCopyWithImpl; } /// @nodoc -class __$$LoadingCopyWithImpl - extends _$PagedValueCopyWithImpl> - implements _$$LoadingCopyWith { - __$$LoadingCopyWithImpl( - _$Loading _value, $Res Function(_$Loading) _then) +class __$$LoadingImplCopyWithImpl + extends _$PagedValueCopyWithImpl> + implements _$$LoadingImplCopyWith { + __$$LoadingImplCopyWithImpl(_$LoadingImpl _value, + $Res Function(_$LoadingImpl) _then) : super(_value, _then); + + /// Create a copy of PagedValue + /// with the given fields replaced by the non-null parameter values. } /// @nodoc -class _$Loading extends Loading +class _$LoadingImpl extends Loading with DiagnosticableTreeMixin { - const _$Loading() : super._(); + const _$LoadingImpl() : super._(); @override String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { @@ -326,9 +341,10 @@ class _$Loading extends Loading } @override - bool operator ==(dynamic other) { + bool operator ==(Object other) { return identical(this, other) || - (other.runtimeType == runtimeType && other is _$Loading); + (other.runtimeType == runtimeType && + other is _$LoadingImpl); } @override @@ -410,33 +426,35 @@ class _$Loading extends Loading } abstract class Loading extends PagedValue { - const factory Loading() = _$Loading; + const factory Loading() = _$LoadingImpl; const Loading._() : super._(); } /// @nodoc -abstract class _$$ErrorCopyWith { - factory _$$ErrorCopyWith( - _$Error value, $Res Function(_$Error) then) = - __$$ErrorCopyWithImpl; +abstract class _$$ErrorImplCopyWith { + factory _$$ErrorImplCopyWith(_$ErrorImpl value, + $Res Function(_$ErrorImpl) then) = + __$$ErrorImplCopyWithImpl; @useResult $Res call({StreamChatError error}); } /// @nodoc -class __$$ErrorCopyWithImpl - extends _$PagedValueCopyWithImpl> - implements _$$ErrorCopyWith { - __$$ErrorCopyWithImpl( - _$Error _value, $Res Function(_$Error) _then) +class __$$ErrorImplCopyWithImpl + extends _$PagedValueCopyWithImpl> + implements _$$ErrorImplCopyWith { + __$$ErrorImplCopyWithImpl(_$ErrorImpl _value, + $Res Function(_$ErrorImpl) _then) : super(_value, _then); + /// Create a copy of PagedValue + /// with the given fields replaced by the non-null parameter values. @pragma('vm:prefer-inline') @override $Res call({ Object? error = null, }) { - return _then(_$Error( + return _then(_$ErrorImpl( null == error ? _value.error : error // ignore: cast_nullable_to_non_nullable @@ -447,9 +465,9 @@ class __$$ErrorCopyWithImpl /// @nodoc -class _$Error extends Error +class _$ErrorImpl extends Error with DiagnosticableTreeMixin { - const _$Error(this.error) : super._(); + const _$ErrorImpl(this.error) : super._(); @override final StreamChatError error; @@ -468,21 +486,24 @@ class _$Error extends Error } @override - bool operator ==(dynamic other) { + bool operator ==(Object other) { return identical(this, other) || (other.runtimeType == runtimeType && - other is _$Error && + other is _$ErrorImpl && (identical(other.error, error) || other.error == error)); } @override int get hashCode => Object.hash(runtimeType, error); - @JsonKey(ignore: true) + /// Create a copy of PagedValue + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) @override @pragma('vm:prefer-inline') - _$$ErrorCopyWith> get copyWith => - __$$ErrorCopyWithImpl>(this, _$identity); + _$$ErrorImplCopyWith> get copyWith => + __$$ErrorImplCopyWithImpl>( + this, _$identity); @override @optionalTypeArgs @@ -560,11 +581,14 @@ class _$Error extends Error } abstract class Error extends PagedValue { - const factory Error(final StreamChatError error) = _$Error; + const factory Error(final StreamChatError error) = _$ErrorImpl; const Error._() : super._(); StreamChatError get error; - @JsonKey(ignore: true) - _$$ErrorCopyWith> get copyWith => + + /// Create a copy of PagedValue + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + _$$ErrorImplCopyWith> get copyWith => throw _privateConstructorUsedError; } diff --git a/packages/stream_chat_flutter_core/lib/src/stream_message_input_controller.dart b/packages/stream_chat_flutter_core/lib/src/stream_message_input_controller.dart index ae331e054..874311a4a 100644 --- a/packages/stream_chat_flutter_core/lib/src/stream_message_input_controller.dart +++ b/packages/stream_chat_flutter_core/lib/src/stream_message_input_controller.dart @@ -223,6 +223,14 @@ class StreamMessageInputController extends ValueNotifier { _ogAttachment = null; } + /// Returns the poll in the message. + Poll? get poll => message.poll; + + /// Sets the poll in the message. + set poll(Poll? poll) { + message = message.copyWith(pollId: poll?.id, poll: poll); + } + /// Returns the list of mentioned users in the message. List get mentionedUsers => message.mentionedUsers; diff --git a/packages/stream_chat_flutter_core/lib/src/stream_poll_controller.dart b/packages/stream_chat_flutter_core/lib/src/stream_poll_controller.dart new file mode 100644 index 000000000..73e8136f2 --- /dev/null +++ b/packages/stream_chat_flutter_core/lib/src/stream_poll_controller.dart @@ -0,0 +1,284 @@ +import 'package:flutter/material.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:stream_chat/stream_chat.dart'; + +part 'stream_poll_controller.freezed.dart'; + +/// {@template minMax} +/// A generic type representing a minimum and maximum value. +/// {@endtemplate} +typedef Range = ({T? min, T? max}); + +/// {@template pollConfig} +/// Configurations used while validating a poll. +/// {@endtemplate} +class PollConfig { + /// {@macro 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; +} + +/// {@template streamPollController} +/// Controller used to manage the state of a poll. +/// {@endtemplate} +class StreamPollController extends ValueNotifier { + /// {@macro streamPollController} + factory StreamPollController({ + Poll? poll, + PollConfig? config, + }) => + StreamPollController._( + config ?? const PollConfig(), + poll ?? Poll(name: '', options: const [PollOption(text: '')]), + ); + + StreamPollController._(this.config, super.poll) : _initialValue = poll; + + // Initial poll passed to the controller, or modified with a new id when the + // controller is marked reset. + Poll _initialValue; + + /// The configuration used to validate the poll. + final PollConfig config; + + /// Returns a sanitized version of the poll. + /// + /// The sanitized poll is a copy of the current poll with the following + /// modifications: + /// - The id of the new options is set to `null`. This is because the id is + /// a reserved field and should not be set by the user. + Poll get sanitizedPoll { + final initialOptions = _initialValue.options.map((it) => it.id); + return value.copyWith( + options: [ + ...value.options.map((option) { + // Skip if the option is already present in the initial poll. + if (initialOptions.contains(option.id)) return option; + + // Remove the id from the new added options. + return option.copyWith(id: null); + }) + ], + ); + } + + /// Resets the poll to the initial value. + void reset({bool resetId = true}) { + if (resetId) { + final newId = const Uuid().v4(); + _initialValue = _initialValue.copyWith(id: newId); + } + value = _initialValue; + } + + /// 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 = PollOption(text: text, extraData: extraData); + 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, + extraData: extraData, + ); + + value = value.copyWith(options: options); + } + + /// Removes the option at the provided [index]. + PollOption 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 +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/packages/stream_chat_flutter_core/lib/src/stream_poll_controller.freezed.dart b/packages/stream_chat_flutter_core/lib/src/stream_poll_controller.freezed.dart new file mode 100644 index 000000000..5bacc5ef0 --- /dev/null +++ b/packages/stream_chat_flutter_core/lib/src/stream_poll_controller.freezed.dart @@ -0,0 +1,857 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// 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 +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); + +/// @nodoc +mixin _$PollValidationError { + @optionalTypeArgs + TResult when({ + required TResult Function(List options) duplicateOptions, + required TResult Function(String name, ({int? max, int? min}) range) + nameRange, + required TResult Function( + List options, ({int? max, int? min}) range) + optionsRange, + required TResult Function(int maxVotesAllowed, ({int? max, int? min}) range) + maxVotesAllowed, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function(List options)? duplicateOptions, + TResult? Function(String name, ({int? max, int? min}) range)? nameRange, + TResult? Function(List options, ({int? max, int? min}) range)? + optionsRange, + TResult? Function(int maxVotesAllowed, ({int? max, int? min}) range)? + maxVotesAllowed, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult maybeWhen({ + TResult Function(List options)? duplicateOptions, + TResult Function(String name, ({int? max, int? min}) range)? nameRange, + TResult Function(List options, ({int? max, int? min}) range)? + optionsRange, + TResult Function(int maxVotesAllowed, ({int? max, int? min}) range)? + maxVotesAllowed, + required TResult orElse(), + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult map({ + required TResult Function(_PollValidationErrorDuplicateOptions value) + duplicateOptions, + required TResult Function(_PollValidationErrorNameRange value) nameRange, + required TResult Function(_PollValidationErrorOptionsRange value) + optionsRange, + required TResult Function(_PollValidationErrorMaxVotesAllowed value) + maxVotesAllowed, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(_PollValidationErrorDuplicateOptions value)? + duplicateOptions, + TResult? Function(_PollValidationErrorNameRange value)? nameRange, + TResult? Function(_PollValidationErrorOptionsRange value)? optionsRange, + TResult? Function(_PollValidationErrorMaxVotesAllowed value)? + maxVotesAllowed, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult maybeMap({ + TResult Function(_PollValidationErrorDuplicateOptions value)? + duplicateOptions, + TResult Function(_PollValidationErrorNameRange value)? nameRange, + TResult Function(_PollValidationErrorOptionsRange value)? optionsRange, + TResult Function(_PollValidationErrorMaxVotesAllowed value)? + maxVotesAllowed, + required TResult orElse(), + }) => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $PollValidationErrorCopyWith<$Res> { + factory $PollValidationErrorCopyWith( + PollValidationError value, $Res Function(PollValidationError) then) = + _$PollValidationErrorCopyWithImpl<$Res, PollValidationError>; +} + +/// @nodoc +class _$PollValidationErrorCopyWithImpl<$Res, $Val extends PollValidationError> + implements $PollValidationErrorCopyWith<$Res> { + _$PollValidationErrorCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of PollValidationError + /// with the given fields replaced by the non-null parameter values. +} + +/// @nodoc +abstract class _$$PollValidationErrorDuplicateOptionsImplCopyWith<$Res> { + factory _$$PollValidationErrorDuplicateOptionsImplCopyWith( + _$PollValidationErrorDuplicateOptionsImpl value, + $Res Function(_$PollValidationErrorDuplicateOptionsImpl) then) = + __$$PollValidationErrorDuplicateOptionsImplCopyWithImpl<$Res>; + @useResult + $Res call({List options}); +} + +/// @nodoc +class __$$PollValidationErrorDuplicateOptionsImplCopyWithImpl<$Res> + extends _$PollValidationErrorCopyWithImpl<$Res, + _$PollValidationErrorDuplicateOptionsImpl> + implements _$$PollValidationErrorDuplicateOptionsImplCopyWith<$Res> { + __$$PollValidationErrorDuplicateOptionsImplCopyWithImpl( + _$PollValidationErrorDuplicateOptionsImpl _value, + $Res Function(_$PollValidationErrorDuplicateOptionsImpl) _then) + : super(_value, _then); + + /// Create a copy of PollValidationError + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? options = null, + }) { + return _then(_$PollValidationErrorDuplicateOptionsImpl( + null == options + ? _value._options + : options // ignore: cast_nullable_to_non_nullable + as List, + )); + } +} + +/// @nodoc + +class _$PollValidationErrorDuplicateOptionsImpl + implements _PollValidationErrorDuplicateOptions { + const _$PollValidationErrorDuplicateOptionsImpl( + final List options) + : _options = options; + + final List _options; + @override + List get options { + if (_options is EqualUnmodifiableListView) return _options; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_options); + } + + @override + String toString() { + return 'PollValidationError.duplicateOptions(options: $options)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$PollValidationErrorDuplicateOptionsImpl && + const DeepCollectionEquality().equals(other._options, _options)); + } + + @override + int get hashCode => + Object.hash(runtimeType, const DeepCollectionEquality().hash(_options)); + + /// Create a copy of PollValidationError + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$PollValidationErrorDuplicateOptionsImplCopyWith< + _$PollValidationErrorDuplicateOptionsImpl> + get copyWith => __$$PollValidationErrorDuplicateOptionsImplCopyWithImpl< + _$PollValidationErrorDuplicateOptionsImpl>(this, _$identity); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function(List options) duplicateOptions, + required TResult Function(String name, ({int? max, int? min}) range) + nameRange, + required TResult Function( + List options, ({int? max, int? min}) range) + optionsRange, + required TResult Function(int maxVotesAllowed, ({int? max, int? min}) range) + maxVotesAllowed, + }) { + return duplicateOptions(options); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function(List options)? duplicateOptions, + TResult? Function(String name, ({int? max, int? min}) range)? nameRange, + TResult? Function(List options, ({int? max, int? min}) range)? + optionsRange, + TResult? Function(int maxVotesAllowed, ({int? max, int? min}) range)? + maxVotesAllowed, + }) { + return duplicateOptions?.call(options); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function(List options)? duplicateOptions, + TResult Function(String name, ({int? max, int? min}) range)? nameRange, + TResult Function(List options, ({int? max, int? min}) range)? + optionsRange, + TResult Function(int maxVotesAllowed, ({int? max, int? min}) range)? + maxVotesAllowed, + required TResult orElse(), + }) { + if (duplicateOptions != null) { + return duplicateOptions(options); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(_PollValidationErrorDuplicateOptions value) + duplicateOptions, + required TResult Function(_PollValidationErrorNameRange value) nameRange, + required TResult Function(_PollValidationErrorOptionsRange value) + optionsRange, + required TResult Function(_PollValidationErrorMaxVotesAllowed value) + maxVotesAllowed, + }) { + return duplicateOptions(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(_PollValidationErrorDuplicateOptions value)? + duplicateOptions, + TResult? Function(_PollValidationErrorNameRange value)? nameRange, + TResult? Function(_PollValidationErrorOptionsRange value)? optionsRange, + TResult? Function(_PollValidationErrorMaxVotesAllowed value)? + maxVotesAllowed, + }) { + return duplicateOptions?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(_PollValidationErrorDuplicateOptions value)? + duplicateOptions, + TResult Function(_PollValidationErrorNameRange value)? nameRange, + TResult Function(_PollValidationErrorOptionsRange value)? optionsRange, + TResult Function(_PollValidationErrorMaxVotesAllowed value)? + maxVotesAllowed, + required TResult orElse(), + }) { + if (duplicateOptions != null) { + return duplicateOptions(this); + } + return orElse(); + } +} + +abstract class _PollValidationErrorDuplicateOptions + implements PollValidationError { + const factory _PollValidationErrorDuplicateOptions( + final List options) = + _$PollValidationErrorDuplicateOptionsImpl; + + List get options; + + /// Create a copy of PollValidationError + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + _$$PollValidationErrorDuplicateOptionsImplCopyWith< + _$PollValidationErrorDuplicateOptionsImpl> + get copyWith => throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class _$$PollValidationErrorNameRangeImplCopyWith<$Res> { + factory _$$PollValidationErrorNameRangeImplCopyWith( + _$PollValidationErrorNameRangeImpl value, + $Res Function(_$PollValidationErrorNameRangeImpl) then) = + __$$PollValidationErrorNameRangeImplCopyWithImpl<$Res>; + @useResult + $Res call({String name, ({int? max, int? min}) range}); +} + +/// @nodoc +class __$$PollValidationErrorNameRangeImplCopyWithImpl<$Res> + extends _$PollValidationErrorCopyWithImpl<$Res, + _$PollValidationErrorNameRangeImpl> + implements _$$PollValidationErrorNameRangeImplCopyWith<$Res> { + __$$PollValidationErrorNameRangeImplCopyWithImpl( + _$PollValidationErrorNameRangeImpl _value, + $Res Function(_$PollValidationErrorNameRangeImpl) _then) + : super(_value, _then); + + /// Create a copy of PollValidationError + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? name = null, + Object? range = null, + }) { + return _then(_$PollValidationErrorNameRangeImpl( + null == name + ? _value.name + : name // ignore: cast_nullable_to_non_nullable + as String, + range: null == range + ? _value.range + : range // ignore: cast_nullable_to_non_nullable + as ({int? max, int? min}), + )); + } +} + +/// @nodoc + +class _$PollValidationErrorNameRangeImpl + implements _PollValidationErrorNameRange { + const _$PollValidationErrorNameRangeImpl(this.name, {required this.range}); + + @override + final String name; + @override + final ({int? max, int? min}) range; + + @override + String toString() { + return 'PollValidationError.nameRange(name: $name, range: $range)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$PollValidationErrorNameRangeImpl && + (identical(other.name, name) || other.name == name) && + (identical(other.range, range) || other.range == range)); + } + + @override + int get hashCode => Object.hash(runtimeType, name, range); + + /// Create a copy of PollValidationError + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$PollValidationErrorNameRangeImplCopyWith< + _$PollValidationErrorNameRangeImpl> + get copyWith => __$$PollValidationErrorNameRangeImplCopyWithImpl< + _$PollValidationErrorNameRangeImpl>(this, _$identity); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function(List options) duplicateOptions, + required TResult Function(String name, ({int? max, int? min}) range) + nameRange, + required TResult Function( + List options, ({int? max, int? min}) range) + optionsRange, + required TResult Function(int maxVotesAllowed, ({int? max, int? min}) range) + maxVotesAllowed, + }) { + return nameRange(name, range); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function(List options)? duplicateOptions, + TResult? Function(String name, ({int? max, int? min}) range)? nameRange, + TResult? Function(List options, ({int? max, int? min}) range)? + optionsRange, + TResult? Function(int maxVotesAllowed, ({int? max, int? min}) range)? + maxVotesAllowed, + }) { + return nameRange?.call(name, range); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function(List options)? duplicateOptions, + TResult Function(String name, ({int? max, int? min}) range)? nameRange, + TResult Function(List options, ({int? max, int? min}) range)? + optionsRange, + TResult Function(int maxVotesAllowed, ({int? max, int? min}) range)? + maxVotesAllowed, + required TResult orElse(), + }) { + if (nameRange != null) { + return nameRange(name, range); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(_PollValidationErrorDuplicateOptions value) + duplicateOptions, + required TResult Function(_PollValidationErrorNameRange value) nameRange, + required TResult Function(_PollValidationErrorOptionsRange value) + optionsRange, + required TResult Function(_PollValidationErrorMaxVotesAllowed value) + maxVotesAllowed, + }) { + return nameRange(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(_PollValidationErrorDuplicateOptions value)? + duplicateOptions, + TResult? Function(_PollValidationErrorNameRange value)? nameRange, + TResult? Function(_PollValidationErrorOptionsRange value)? optionsRange, + TResult? Function(_PollValidationErrorMaxVotesAllowed value)? + maxVotesAllowed, + }) { + return nameRange?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(_PollValidationErrorDuplicateOptions value)? + duplicateOptions, + TResult Function(_PollValidationErrorNameRange value)? nameRange, + TResult Function(_PollValidationErrorOptionsRange value)? optionsRange, + TResult Function(_PollValidationErrorMaxVotesAllowed value)? + maxVotesAllowed, + required TResult orElse(), + }) { + if (nameRange != null) { + return nameRange(this); + } + return orElse(); + } +} + +abstract class _PollValidationErrorNameRange implements PollValidationError { + const factory _PollValidationErrorNameRange(final String name, + {required final ({int? max, int? min}) range}) = + _$PollValidationErrorNameRangeImpl; + + String get name; + ({int? max, int? min}) get range; + + /// Create a copy of PollValidationError + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + _$$PollValidationErrorNameRangeImplCopyWith< + _$PollValidationErrorNameRangeImpl> + get copyWith => throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class _$$PollValidationErrorOptionsRangeImplCopyWith<$Res> { + factory _$$PollValidationErrorOptionsRangeImplCopyWith( + _$PollValidationErrorOptionsRangeImpl value, + $Res Function(_$PollValidationErrorOptionsRangeImpl) then) = + __$$PollValidationErrorOptionsRangeImplCopyWithImpl<$Res>; + @useResult + $Res call({List options, ({int? max, int? min}) range}); +} + +/// @nodoc +class __$$PollValidationErrorOptionsRangeImplCopyWithImpl<$Res> + extends _$PollValidationErrorCopyWithImpl<$Res, + _$PollValidationErrorOptionsRangeImpl> + implements _$$PollValidationErrorOptionsRangeImplCopyWith<$Res> { + __$$PollValidationErrorOptionsRangeImplCopyWithImpl( + _$PollValidationErrorOptionsRangeImpl _value, + $Res Function(_$PollValidationErrorOptionsRangeImpl) _then) + : super(_value, _then); + + /// Create a copy of PollValidationError + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? options = null, + Object? range = null, + }) { + return _then(_$PollValidationErrorOptionsRangeImpl( + null == options + ? _value._options + : options // ignore: cast_nullable_to_non_nullable + as List, + range: null == range + ? _value.range + : range // ignore: cast_nullable_to_non_nullable + as ({int? max, int? min}), + )); + } +} + +/// @nodoc + +class _$PollValidationErrorOptionsRangeImpl + implements _PollValidationErrorOptionsRange { + const _$PollValidationErrorOptionsRangeImpl(final List options, + {required this.range}) + : _options = options; + + final List _options; + @override + List get options { + if (_options is EqualUnmodifiableListView) return _options; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_options); + } + + @override + final ({int? max, int? min}) range; + + @override + String toString() { + return 'PollValidationError.optionsRange(options: $options, range: $range)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$PollValidationErrorOptionsRangeImpl && + 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); + + /// Create a copy of PollValidationError + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$PollValidationErrorOptionsRangeImplCopyWith< + _$PollValidationErrorOptionsRangeImpl> + get copyWith => __$$PollValidationErrorOptionsRangeImplCopyWithImpl< + _$PollValidationErrorOptionsRangeImpl>(this, _$identity); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function(List options) duplicateOptions, + required TResult Function(String name, ({int? max, int? min}) range) + nameRange, + required TResult Function( + List options, ({int? max, int? min}) range) + optionsRange, + required TResult Function(int maxVotesAllowed, ({int? max, int? min}) range) + maxVotesAllowed, + }) { + return optionsRange(options, range); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function(List options)? duplicateOptions, + TResult? Function(String name, ({int? max, int? min}) range)? nameRange, + TResult? Function(List options, ({int? max, int? min}) range)? + optionsRange, + TResult? Function(int maxVotesAllowed, ({int? max, int? min}) range)? + maxVotesAllowed, + }) { + return optionsRange?.call(options, range); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function(List options)? duplicateOptions, + TResult Function(String name, ({int? max, int? min}) range)? nameRange, + TResult Function(List options, ({int? max, int? min}) range)? + optionsRange, + TResult Function(int maxVotesAllowed, ({int? max, int? min}) range)? + maxVotesAllowed, + required TResult orElse(), + }) { + if (optionsRange != null) { + return optionsRange(options, range); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(_PollValidationErrorDuplicateOptions value) + duplicateOptions, + required TResult Function(_PollValidationErrorNameRange value) nameRange, + required TResult Function(_PollValidationErrorOptionsRange value) + optionsRange, + required TResult Function(_PollValidationErrorMaxVotesAllowed value) + maxVotesAllowed, + }) { + return optionsRange(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(_PollValidationErrorDuplicateOptions value)? + duplicateOptions, + TResult? Function(_PollValidationErrorNameRange value)? nameRange, + TResult? Function(_PollValidationErrorOptionsRange value)? optionsRange, + TResult? Function(_PollValidationErrorMaxVotesAllowed value)? + maxVotesAllowed, + }) { + return optionsRange?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(_PollValidationErrorDuplicateOptions value)? + duplicateOptions, + TResult Function(_PollValidationErrorNameRange value)? nameRange, + TResult Function(_PollValidationErrorOptionsRange value)? optionsRange, + TResult Function(_PollValidationErrorMaxVotesAllowed value)? + maxVotesAllowed, + required TResult orElse(), + }) { + if (optionsRange != null) { + return optionsRange(this); + } + return orElse(); + } +} + +abstract class _PollValidationErrorOptionsRange implements PollValidationError { + const factory _PollValidationErrorOptionsRange(final List options, + {required final ({int? max, int? min}) range}) = + _$PollValidationErrorOptionsRangeImpl; + + List get options; + ({int? max, int? min}) get range; + + /// Create a copy of PollValidationError + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + _$$PollValidationErrorOptionsRangeImplCopyWith< + _$PollValidationErrorOptionsRangeImpl> + get copyWith => throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class _$$PollValidationErrorMaxVotesAllowedImplCopyWith<$Res> { + factory _$$PollValidationErrorMaxVotesAllowedImplCopyWith( + _$PollValidationErrorMaxVotesAllowedImpl value, + $Res Function(_$PollValidationErrorMaxVotesAllowedImpl) then) = + __$$PollValidationErrorMaxVotesAllowedImplCopyWithImpl<$Res>; + @useResult + $Res call({int maxVotesAllowed, ({int? max, int? min}) range}); +} + +/// @nodoc +class __$$PollValidationErrorMaxVotesAllowedImplCopyWithImpl<$Res> + extends _$PollValidationErrorCopyWithImpl<$Res, + _$PollValidationErrorMaxVotesAllowedImpl> + implements _$$PollValidationErrorMaxVotesAllowedImplCopyWith<$Res> { + __$$PollValidationErrorMaxVotesAllowedImplCopyWithImpl( + _$PollValidationErrorMaxVotesAllowedImpl _value, + $Res Function(_$PollValidationErrorMaxVotesAllowedImpl) _then) + : super(_value, _then); + + /// Create a copy of PollValidationError + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? maxVotesAllowed = null, + Object? range = null, + }) { + return _then(_$PollValidationErrorMaxVotesAllowedImpl( + null == maxVotesAllowed + ? _value.maxVotesAllowed + : maxVotesAllowed // ignore: cast_nullable_to_non_nullable + as int, + range: null == range + ? _value.range + : range // ignore: cast_nullable_to_non_nullable + as ({int? max, int? min}), + )); + } +} + +/// @nodoc + +class _$PollValidationErrorMaxVotesAllowedImpl + implements _PollValidationErrorMaxVotesAllowed { + const _$PollValidationErrorMaxVotesAllowedImpl(this.maxVotesAllowed, + {required this.range}); + + @override + final int maxVotesAllowed; + @override + final ({int? max, int? min}) range; + + @override + String toString() { + return 'PollValidationError.maxVotesAllowed(maxVotesAllowed: $maxVotesAllowed, range: $range)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$PollValidationErrorMaxVotesAllowedImpl && + (identical(other.maxVotesAllowed, maxVotesAllowed) || + other.maxVotesAllowed == maxVotesAllowed) && + (identical(other.range, range) || other.range == range)); + } + + @override + int get hashCode => Object.hash(runtimeType, maxVotesAllowed, range); + + /// Create a copy of PollValidationError + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$PollValidationErrorMaxVotesAllowedImplCopyWith< + _$PollValidationErrorMaxVotesAllowedImpl> + get copyWith => __$$PollValidationErrorMaxVotesAllowedImplCopyWithImpl< + _$PollValidationErrorMaxVotesAllowedImpl>(this, _$identity); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function(List options) duplicateOptions, + required TResult Function(String name, ({int? max, int? min}) range) + nameRange, + required TResult Function( + List options, ({int? max, int? min}) range) + optionsRange, + required TResult Function(int maxVotesAllowed, ({int? max, int? min}) range) + maxVotesAllowed, + }) { + return maxVotesAllowed(this.maxVotesAllowed, range); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function(List options)? duplicateOptions, + TResult? Function(String name, ({int? max, int? min}) range)? nameRange, + TResult? Function(List options, ({int? max, int? min}) range)? + optionsRange, + TResult? Function(int maxVotesAllowed, ({int? max, int? min}) range)? + maxVotesAllowed, + }) { + return maxVotesAllowed?.call(this.maxVotesAllowed, range); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function(List options)? duplicateOptions, + TResult Function(String name, ({int? max, int? min}) range)? nameRange, + TResult Function(List options, ({int? max, int? min}) range)? + optionsRange, + TResult Function(int maxVotesAllowed, ({int? max, int? min}) range)? + maxVotesAllowed, + required TResult orElse(), + }) { + if (maxVotesAllowed != null) { + return maxVotesAllowed(this.maxVotesAllowed, range); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(_PollValidationErrorDuplicateOptions value) + duplicateOptions, + required TResult Function(_PollValidationErrorNameRange value) nameRange, + required TResult Function(_PollValidationErrorOptionsRange value) + optionsRange, + required TResult Function(_PollValidationErrorMaxVotesAllowed value) + maxVotesAllowed, + }) { + return maxVotesAllowed(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(_PollValidationErrorDuplicateOptions value)? + duplicateOptions, + TResult? Function(_PollValidationErrorNameRange value)? nameRange, + TResult? Function(_PollValidationErrorOptionsRange value)? optionsRange, + TResult? Function(_PollValidationErrorMaxVotesAllowed value)? + maxVotesAllowed, + }) { + return maxVotesAllowed?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(_PollValidationErrorDuplicateOptions value)? + duplicateOptions, + TResult Function(_PollValidationErrorNameRange value)? nameRange, + TResult Function(_PollValidationErrorOptionsRange value)? optionsRange, + TResult Function(_PollValidationErrorMaxVotesAllowed value)? + maxVotesAllowed, + required TResult orElse(), + }) { + if (maxVotesAllowed != null) { + return maxVotesAllowed(this); + } + return orElse(); + } +} + +abstract class _PollValidationErrorMaxVotesAllowed + implements PollValidationError { + const factory _PollValidationErrorMaxVotesAllowed(final int maxVotesAllowed, + {required final ({int? max, int? min}) range}) = + _$PollValidationErrorMaxVotesAllowedImpl; + + int get maxVotesAllowed; + ({int? max, int? min}) get range; + + /// Create a copy of PollValidationError + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + _$$PollValidationErrorMaxVotesAllowedImplCopyWith< + _$PollValidationErrorMaxVotesAllowedImpl> + get copyWith => throw _privateConstructorUsedError; +} diff --git a/packages/stream_chat_flutter_core/lib/stream_chat_flutter_core.dart b/packages/stream_chat_flutter_core/lib/stream_chat_flutter_core.dart index e903da770..3330a1e45 100644 --- a/packages/stream_chat_flutter_core/lib/stream_chat_flutter_core.dart +++ b/packages/stream_chat_flutter_core/lib/stream_chat_flutter_core.dart @@ -17,5 +17,6 @@ export 'src/stream_chat_core.dart'; export 'src/stream_member_list_controller.dart'; export 'src/stream_message_input_controller.dart'; export 'src/stream_message_search_list_controller.dart'; +export 'src/stream_poll_controller.dart'; export 'src/stream_user_list_controller.dart'; export 'src/typedef.dart'; diff --git a/packages/stream_chat_flutter_core/test/stream_poll_controller_test.dart b/packages/stream_chat_flutter_core/test/stream_poll_controller_test.dart new file mode 100644 index 000000000..dc4d6e6f0 --- /dev/null +++ b/packages/stream_chat_flutter_core/test/stream_poll_controller_test.dart @@ -0,0 +1,282 @@ +import 'package:collection/collection.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_flutter_core/src/stream_poll_controller.dart'; + +void main() { + group('Initialization Tests', () { + test('Default Initialization', () { + final pollController = StreamPollController(); + expect(pollController.value.name, ''); + expect(pollController.value.options.length, 1); + }); + + test('Initialization with Custom Poll and Config', () { + final poll = Poll(name: 'Initial Poll', options: const [ + PollOption(text: 'Option 1'), + PollOption(text: 'Option 2'), + ]); + const config = PollConfig(nameRange: (min: 2, max: 50)); + final pollController = StreamPollController(poll: poll, config: config); + + expect(pollController.value.name, 'Initial Poll'); + expect(pollController.config.nameRange?.min, 2); + expect(pollController.config.nameRange?.max, 50); + }); + }); + + group('Poll Property Setter Tests', () { + final pollController = StreamPollController(); + + test('Set Question', () { + pollController.question = 'New Question'; + expect(pollController.value.name, 'New Question'); + }); + + test('Set Options', () { + pollController.options = [const PollOption(text: 'Option A')]; + expect(pollController.value.options.length, 1); + }); + + test('Set enforceUniqueVote', () { + pollController.enforceUniqueVote = true; + expect(pollController.value.enforceUniqueVote, isTrue); + }); + + test('Set maxVotesAllowed', () { + pollController.maxVotesAllowed = 5; + expect(pollController.value.maxVotesAllowed, 5); + }); + + test('Set allowSuggestions', () { + pollController.allowSuggestions = true; + expect(pollController.value.allowUserSuggestedOptions, isTrue); + }); + + test('Set votingVisibility', () { + pollController.votingVisibility = VotingVisibility.anonymous; + expect(pollController.value.votingVisibility, VotingVisibility.anonymous); + }); + + test('Set allowComments', () { + pollController.allowComments = true; + expect(pollController.value.allowAnswers, isTrue); + }); + }); + + group('Add/Update/Remove Option Tests', () { + test('Add Option', () { + final pollController = StreamPollController()..addOption('Option 1'); + expect(pollController.value.options.length, 2); + expect(pollController.value.options.last.text, 'Option 1'); + }); + + test('Add Option with Extra Data', () { + final pollController = StreamPollController() + ..addOption( + 'Option 1', + extraData: {'key': 'value'}, + ); + + expect(pollController.value.options.length, 2); + expect(pollController.value.options.last.extraData['key'], 'value'); + }); + + test('Update Option', () { + final pollController = StreamPollController()..addOption('Option 1'); + expect(pollController.value.options.last.text, 'Option 1'); + + pollController.updateOption('Updated Option 1', index: 1); + expect(pollController.value.options.last.text, 'Updated Option 1'); + }); + + test('Remove Option', () { + final pollController = StreamPollController()..removeOption(0); + expect(pollController.value.options.length, 0); + }); + }); + + group('Validation Tests', () { + test('Validate Poll Name Length', () { + final pollController = StreamPollController()..question = 'A' * 100; + final errors = pollController.validateGranularly(); + expect(errors.isEmpty, isFalse); + + final containsNameRangeError = errors + .map((e) => e.mapOrNull(nameRange: (e) => e)) + .whereNotNull() + .isNotEmpty; + + expect(containsNameRangeError, isTrue); + }); + + test('Validate Unique Options', () { + final pollController = StreamPollController() + ..addOption('Option 1') + ..addOption('Option 1'); + + final errors = pollController.validateGranularly(); + expect(errors.isEmpty, isFalse); + + final containsDuplicateOptions = errors + .map((e) => e.mapOrNull(duplicateOptions: (e) => e)) + .whereNotNull() + .isNotEmpty; + + expect(containsDuplicateOptions, isTrue); + }); + + test('Validate Options Count', () { + final pollController = StreamPollController()..options = const []; + final errors = pollController.validateGranularly(); + expect(errors.isEmpty, isFalse); + + final containsOptionsRangeError = errors + .map((e) => e.mapOrNull(optionsRange: (e) => e)) + .whereNotNull() + .isNotEmpty; + + expect(containsOptionsRangeError, isTrue); + }); + + test('Validate Max Votes Allowed', () { + final pollController = StreamPollController() + ..enforceUniqueVote = false + ..maxVotesAllowed = 20; + + final errors = pollController.validateGranularly(); + expect(errors.isEmpty, isFalse); + + final containsMaxVotesAllowedError = errors + .map((e) => e.mapOrNull(maxVotesAllowed: (e) => e)) + .whereNotNull() + .isNotEmpty; + + expect(containsMaxVotesAllowedError, isTrue); + }); + }); + + group('Reset Tests', () { + test('Reset Poll Without Changing ID', () { + final poll = Poll( + name: 'Initial Poll', + options: const [PollOption(text: 'Option 1')], + ); + + final pollController = StreamPollController(poll: poll) + ..question = 'New Question' + ..addOption('New Option'); + + expect(pollController.value.name, 'New Question'); + expect(pollController.value.options.length, 2); + + pollController.reset(resetId: false); + + expect(pollController.value.name, 'Initial Poll'); + expect(pollController.value.options.length, 1); + expect(pollController.value.id, poll.id); + }); + + test('Reset Poll With New ID', () { + final poll = Poll( + name: 'Initial Poll', + options: const [PollOption(text: 'Option 1')], + ); + + final pollController = StreamPollController(poll: poll) + ..question = 'New Question' + ..addOption('New Option'); + + expect(pollController.value.name, 'New Question'); + expect(pollController.value.options.length, 2); + + pollController.reset(); + + expect(pollController.value.name, 'Initial Poll'); + expect(pollController.value.options.length, 1); + expect(pollController.value.id, isNot(poll.id)); + }); + }); + + group('Sanitization Tests', () { + test('Sanitized Poll Removes New Option IDs', () { + final pollController = StreamPollController() + ..options = [ + const PollOption(id: 'new_id', text: 'New Option'), + ]; + + final sanitizedPoll = pollController.sanitizedPoll; + expect(sanitizedPoll.options.last.id, isNull); + expect(sanitizedPoll.options.last.text, 'New Option'); + }); + + test('Sanitized Poll Preserves Existing Option IDs', () { + final poll = Poll( + name: 'Initial Poll', + options: const [ + PollOption(id: 'existing_id', text: 'Existing Option'), + ], + ); + + final pollController = StreamPollController(poll: poll) + ..options = [ + ...poll.options, + const PollOption(id: 'new_id', text: 'New Option'), + ]; + + final sanitizedPoll = pollController.sanitizedPoll; + expect(sanitizedPoll.options.first.text, 'Existing Option'); + expect(sanitizedPoll.options.first.id, isNotNull); + expect(sanitizedPoll.options.last.text, 'New Option'); + expect(sanitizedPoll.options.last.id, isNull); + }); + }); + + group('Edge Cases and Config Tests', () { + test('Config Allows Unlimited Poll Name Length', () { + final pollController = StreamPollController( + config: const PollConfig(nameRange: null), + )..question = 'A' * 200; + + final errors = pollController.validateGranularly(); + final containsNameRangeError = errors + .map((e) => e.mapOrNull(nameRange: (e) => e)) + .whereNotNull() + .isNotEmpty; + + expect(containsNameRangeError, isFalse); + }); + + test('Config Allows Unlimited Options', () { + final pollController = StreamPollController( + config: const PollConfig(optionsRange: null), + ); + + for (var i = 0; i < 50; i++) { + pollController.addOption('Option $i'); + } + + final errors = pollController.validateGranularly(); + final containsOptionsRangeError = errors + .map((e) => e.mapOrNull(optionsRange: (e) => e)) + .whereNotNull() + .isNotEmpty; + + expect(containsOptionsRangeError, isFalse); + }); + + test('Config Allows Unlimited Votes', () { + final pollController = StreamPollController( + config: const PollConfig(allowedVotesRange: null), + )..maxVotesAllowed = 100; + + final errors = pollController.validateGranularly(); + final containsMaxVotesAllowedError = errors + .map((e) => e.mapOrNull(maxVotesAllowed: (e) => e)) + .whereNotNull() + .isNotEmpty; + + expect(containsMaxVotesAllowedError, isFalse); + }); + }); +} diff --git a/packages/stream_chat_localizations/CHANGELOG.md b/packages/stream_chat_localizations/CHANGELOG.md index c2318b706..a3298eb3c 100644 --- a/packages/stream_chat_localizations/CHANGELOG.md +++ b/packages/stream_chat_localizations/CHANGELOG.md @@ -1,3 +1,7 @@ +## Upcoming + +- Added multiple new localization strings related to poll creation and validation. + ## 8.3.0 - Updated `stream_chat_flutter` dependency to [`8.3.0`](https://pub.dev/packages/stream_chat/changelog). diff --git a/packages/stream_chat_localizations/example/lib/add_new_lang.dart b/packages/stream_chat_localizations/example/lib/add_new_lang.dart index 83538e193..9a8abb2f0 100644 --- a/packages/stream_chat_localizations/example/lib/add_new_lang.dart +++ b/packages/stream_chat_localizations/example/lib/add_new_lang.dart @@ -484,6 +484,83 @@ class NnStreamChatLocalizations extends GlobalStreamChatLocalizations { String get markUnreadError => 'Error marking message unread. Cannot mark unread messages older than' ' the newest 100 channel messages.'; + + @override + String createPollLabel({bool isNew = false}) { + if (isNew) return 'Create a new poll'; + return 'Create Poll'; + } + + @override + String get questionsLabel => 'Questions'; + + @override + String get askAQuestionLabel => 'Ask a question'; + + @override + String? pollQuestionValidationError(int length, Range range) { + final (:min, :max) = range; + + // Check if the question is too short. + if (min != null && length < min) { + return 'Question must be at least $min characters long'; + } + + // Check if the question is too long. + if (max != null && length > max) { + return 'Question must be at most $max characters long'; + } + + return null; + } + + @override + String optionLabel({bool isPlural = false}) { + if (isPlural) return 'Options'; + return 'Option'; + } + + @override + String get pollOptionEmptyError => 'Option cannot be empty'; + + @override + String get pollOptionDuplicateError => 'This is already an option'; + + @override + String get addAnOptionLabel => 'Add an option'; + + @override + String get multipleAnswersLabel => 'Multiple answers'; + + @override + String get maximumVotesPerPersonLabel => 'Maximum votes per person'; + + @override + String? maxVotesPerPersonValidationError(int votes, Range range) { + final (:min, :max) = range; + + 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; + } + + @override + String get anonymousPollLabel => 'Anonymous poll'; + + @override + String get suggestAnOptionLabel => 'Suggest an option'; + + @override + String get addACommentLabel => 'Add a comment'; + + @override + String get createLabel => 'Create'; } void main() async { diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ca.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ca.dart index 8e0497805..db19d56d9 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ca.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ca.dart @@ -465,4 +465,81 @@ class StreamChatLocalizationsCa extends GlobalStreamChatLocalizations { 'Error en marcar el missatge com a no llegit. No es poden marcar' ' missatges no llegits més antics que els 100 missatges més recents del' ' canal.'; + + @override + String createPollLabel({bool isNew = false}) { + if (isNew) return 'Crear una enquesta nova'; + return 'Crea enquesta'; + } + + @override + String get questionsLabel => 'Preguntes'; + + @override + String get askAQuestionLabel => 'Fes una pregunta'; + + @override + String? pollQuestionValidationError(int length, Range range) { + final (:min, :max) = range; + + // Check if the question is too short. + if (min != null && length < min) { + return 'La pregunta ha de tenir com a mínim $min caràcters'; + } + + // Check if the question is too long. + if (max != null && length > max) { + return 'La pregunta ha de tenir com a màxim $max caràcters'; + } + + return null; + } + + @override + String optionLabel({bool isPlural = false}) { + if (isPlural) return 'Opcions'; + return 'Opció'; + } + + @override + String get pollOptionEmptyError => "L'opció no pot estar buida"; + + @override + String get pollOptionDuplicateError => 'Això ja és una opció'; + + @override + String get addAnOptionLabel => 'Afegeix una opció'; + + @override + String get multipleAnswersLabel => 'Respostes múltiples'; + + @override + String get maximumVotesPerPersonLabel => 'Màxim de vots per persona'; + + @override + String? maxVotesPerPersonValidationError(int votes, Range range) { + final (:min, :max) = range; + + if (min != null && votes < min) { + return 'El recompte de vots ha de ser com a mínim de $min'; + } + + if (max != null && votes > max) { + return 'El recompte de vots ha de ser com a màxim de $max'; + } + + return null; + } + + @override + String get anonymousPollLabel => 'Enquesta anònima'; + + @override + String get suggestAnOptionLabel => 'Suggerir una opció'; + + @override + String get addACommentLabel => 'Afegeix un comentari'; + + @override + String get createLabel => 'Crear'; } diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_de.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_de.dart index 79f443752..d81f0a898 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_de.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_de.dart @@ -459,4 +459,81 @@ class StreamChatLocalizationsDe extends GlobalStreamChatLocalizations { 'Fehler beim Markieren der Nachricht als ungelesen. Kann keine älteren' ' ungelesenen Nachrichten markieren als die neuesten 100' ' Kanalnachrichten.'; + + @override + String createPollLabel({bool isNew = false}) { + if (isNew) return 'Erstellen einer neuen Umfrage'; + return 'Umfrage erstellen'; + } + + @override + String get questionsLabel => 'Fragen'; + + @override + String get askAQuestionLabel => 'Stellen Sie eine Frage'; + + @override + String? pollQuestionValidationError(int length, Range range) { + final (:min, :max) = range; + + // Check if the question is too short. + if (min != null && length < min) { + return 'Die Frage muss mindestens $min Zeichen lang sein'; + } + + // Check if the question is too long. + if (max != null && length > max) { + return 'Die Frage darf höchstens $max Zeichen lang sein'; + } + + return null; + } + + @override + String optionLabel({bool isPlural = false}) { + if (isPlural) return 'Optionen'; + return 'Option'; + } + + @override + String get pollOptionEmptyError => 'Option darf nicht leer sein'; + + @override + String get pollOptionDuplicateError => 'Dies ist bereits eine Option'; + + @override + String get addAnOptionLabel => 'Option hinzufügen'; + + @override + String get multipleAnswersLabel => 'Mehrere Antworten'; + + @override + String get maximumVotesPerPersonLabel => 'Maximale Stimmen pro Person'; + + @override + String? maxVotesPerPersonValidationError(int votes, Range range) { + final (:min, :max) = range; + + if (min != null && votes < min) { + return 'Die Stimmenauszählung muss mindestens $min betragen'; + } + + if (max != null && votes > max) { + return 'Die Stimmenauszählung darf höchstens $max betragen'; + } + + return null; + } + + @override + String get anonymousPollLabel => 'Anonyme Umfrage'; + + @override + String get suggestAnOptionLabel => 'Option vorschlagen'; + + @override + String get addACommentLabel => 'Kommentar hinzufügen'; + + @override + String get createLabel => 'Schaffen'; } diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_en.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_en.dart index 896c9ec99..81628b028 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_en.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_en.dart @@ -461,4 +461,81 @@ class StreamChatLocalizationsEn extends GlobalStreamChatLocalizations { String get markUnreadError => 'Error marking message unread. Cannot mark unread messages older' ' than the newest 100 channel messages.'; + + @override + String createPollLabel({bool isNew = false}) { + if (isNew) return 'Create a new poll'; + return 'Create Poll'; + } + + @override + String get questionsLabel => 'Questions'; + + @override + String get askAQuestionLabel => 'Ask a question'; + + @override + String? pollQuestionValidationError(int length, Range range) { + final (:min, :max) = range; + + // Check if the question is too short. + if (min != null && length < min) { + return 'Question must be at least $min characters long'; + } + + // Check if the question is too long. + if (max != null && length > max) { + return 'Question must be at most $max characters long'; + } + + return null; + } + + @override + String optionLabel({bool isPlural = false}) { + if (isPlural) return 'Options'; + return 'Option'; + } + + @override + String get pollOptionEmptyError => 'Option cannot be empty'; + + @override + String get pollOptionDuplicateError => 'This is already an option'; + + @override + String get addAnOptionLabel => 'Add an option'; + + @override + String get multipleAnswersLabel => 'Multiple answers'; + + @override + String get maximumVotesPerPersonLabel => 'Maximum votes per person'; + + @override + String? maxVotesPerPersonValidationError(int votes, Range range) { + final (:min, :max) = range; + + 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; + } + + @override + String get anonymousPollLabel => 'Anonymous poll'; + + @override + String get suggestAnOptionLabel => 'Suggest an option'; + + @override + String get addACommentLabel => 'Add a comment'; + + @override + String get createLabel => 'Create'; } diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart index ae5af69cf..1418c47e7 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart @@ -466,4 +466,81 @@ No es posible añadir más de $limit archivos adjuntos String get markUnreadError => 'Error al marcar el mensaje como no leído. No se pueden marcar mensajes' ' no leídos más antiguos que los últimos 100 mensajes del canal.'; + + @override + String createPollLabel({bool isNew = false}) { + if (isNew) return 'Crear un nuevo sondeo'; + return 'Crear sondeo'; + } + + @override + String get questionsLabel => 'Preguntas'; + + @override + String get askAQuestionLabel => 'Hacer una pregunta'; + + @override + String? pollQuestionValidationError(int length, Range range) { + final (:min, :max) = range; + + // Check if the question is too short. + if (min != null && length < min) { + return 'La pregunta debe tener al menos $min caracteres'; + } + + // Check if the question is too long. + if (max != null && length > max) { + return 'La pregunta no puede tener más de $max caracteres'; + } + + return null; + } + + @override + String optionLabel({bool isPlural = false}) { + if (isPlural) return 'Opciones'; + return 'Opción'; + } + + @override + String get pollOptionEmptyError => 'Esta opción no puede estar vacía'; + + @override + String get pollOptionDuplicateError => 'Las opciones no pueden ser iguales'; + + @override + String get addAnOptionLabel => 'Añadir una opción'; + + @override + String get multipleAnswersLabel => 'Respuestas múltiples'; + + @override + String get maximumVotesPerPersonLabel => 'Máximo de votos por persona'; + + @override + String? maxVotesPerPersonValidationError(int votes, Range range) { + final (:min, :max) = range; + + if (min != null && votes < min) { + return 'El recuento de votos debe ser al menos $min'; + } + + if (max != null && votes > max) { + return 'El recuento de votos no puede ser superior a $max'; + } + + return null; + } + + @override + String get anonymousPollLabel => 'Sondeo anónimo'; + + @override + String get suggestAnOptionLabel => 'Sugerir una opción'; + + @override + String get addACommentLabel => 'Añadir un comentario'; + + @override + String get createLabel => 'Crear'; } diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_fr.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_fr.dart index 65caa9da1..1f54775c5 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_fr.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_fr.dart @@ -467,4 +467,82 @@ Limite de pièces jointes dépassée : il n'est pas possible d'ajouter plus de $ 'Erreur lors de la marque du message comme non lu. Impossible de marquer' ' des messages non lus plus anciens que les 100 derniers messages' ' du canal.'; + + @override + String createPollLabel({bool isNew = false}) { + if (isNew) return 'Créer un sondage'; + return 'Créer sondage'; + } + + @override + String get questionsLabel => 'Questions'; + + @override + String get askAQuestionLabel => 'Poser une question'; + + @override + String? pollQuestionValidationError(int length, Range range) { + final (:min, :max) = range; + + // Check if the question is too short. + if (min != null && length < min) { + return 'La question doit comporter au moins $min caractères'; + } + + // Check if the question is too long. + if (max != null && length > max) { + return 'La question doit comporter au plus $max caractères'; + } + + return null; + } + + @override + String optionLabel({bool isPlural = false}) { + if (isPlural) return 'Options'; + return 'Option'; + } + + @override + String get pollOptionEmptyError => 'L’option ne peut pas être vide'; + + @override + String get pollOptionDuplicateError => 'C’est déjà une option'; + + @override + String get addAnOptionLabel => 'Ajouter une option'; + + @override + String get multipleAnswersLabel => 'Réponses multiples'; + + @override + String get maximumVotesPerPersonLabel => + 'Nombre maximum de votes par personne'; + + @override + String? maxVotesPerPersonValidationError(int votes, Range range) { + final (:min, :max) = range; + + if (min != null && votes < min) { + return 'Le décompte des votes doit être d’au moins $min'; + } + + if (max != null && votes > max) { + return 'Le décompte des votes doit être d’au plus $max'; + } + + return null; + } + + @override + String get anonymousPollLabel => 'Sondage anonyme'; + + @override + String get suggestAnOptionLabel => 'Suggérer une option'; + + @override + String get addACommentLabel => 'Ajouter un commentaire'; + + @override + String get createLabel => 'Créer'; } diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_hi.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_hi.dart index 8c3fe43bb..f0009e582 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_hi.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_hi.dart @@ -459,4 +459,78 @@ class StreamChatLocalizationsHi extends GlobalStreamChatLocalizations { String get markUnreadError => 'संदेश को अपठित मार्क करने में त्रुटि। सबसे नए 100 चैनल संदेश से पहले के' ' सभी अपठित संदेशों को अपठित मार्क नहीं किया जा सकता है।'; + + @override + String createPollLabel({bool isNew = false}) { + if (isNew) return 'एक नया पोल बनाएँ'; + return 'पोल बनाएँ'; + } + + @override + String get questionsLabel => 'प्रश्न'; + + @override + String get askAQuestionLabel => 'प्रश्न पूछें'; + + @override + String? pollQuestionValidationError(int length, Range range) { + final (:min, :max) = range; + + // Check if the question is too short. + if (min != null && length < min) { + return 'प्रश्न कम से कम $min अक्षर का होना चाहिए'; + } + + // Check if the question is too long. + if (max != null && length > max) { + return 'प्रश्न अधिकतम $max अक्षर का हो सकता है'; + } + + return null; + } + + @override + String optionLabel({bool isPlural = false}) => 'विकल्प'; + + @override + String get pollOptionEmptyError => 'विकल्प खाली नहीं हो सकता'; + + @override + String get pollOptionDuplicateError => 'यह पहले से ही एक विकल्प है'; + + @override + String get addAnOptionLabel => 'विकल्प जोड़ें'; + + @override + String get multipleAnswersLabel => 'एक से अधिक उत्तर'; + + @override + String get maximumVotesPerPersonLabel => 'प्रति व्यक्ति अधिकतम वोट'; + + @override + String? maxVotesPerPersonValidationError(int votes, Range range) { + final (:min, :max) = range; + + if (min != null && votes < min) { + return 'वोटों की गिनती कम से कम $min होनी चाहिए'; + } + + if (max != null && votes > max) { + return 'वोटों की गिनती ज्यादा से ज्यादा $max हो सकती है'; + } + + return null; + } + + @override + String get anonymousPollLabel => 'अज्ञात पोल'; + + @override + String get suggestAnOptionLabel => 'विकल्प सुझाएं'; + + @override + String get addACommentLabel => 'कमेंट जोड़ें'; + + @override + String get createLabel => 'क्रिएट करें'; } diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_it.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_it.dart index 3a907d58a..7d1ff7883 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_it.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_it.dart @@ -469,4 +469,81 @@ Attenzione: il limite massimo di $limit file è stato superato. 'Errore durante la marcatura del messaggio come non letto. Impossibile' ' marcare messaggi non letti più vecchi dei più recenti 100 messaggi' ' del canale.'; + + @override + String createPollLabel({bool isNew = false}) { + if (isNew) return 'Crea un nuovo sondaggio'; + return 'Crea sondaggio'; + } + + @override + String get questionsLabel => 'Domande'; + + @override + String get askAQuestionLabel => 'Fai una domanda'; + + @override + String? pollQuestionValidationError(int length, Range range) { + final (:min, :max) = range; + + // Check if the question is too short. + if (min != null && length < min) { + return 'La domanda deve essere composta da almeno $min caratteri'; + } + + // Check if the question is too long. + if (max != null && length > max) { + return 'La domanda deve essere lunga al massimo $max caratteri'; + } + + return null; + } + + @override + String optionLabel({bool isPlural = false}) { + if (isPlural) return 'Opzioni'; + return 'Opzione'; + } + + @override + String get pollOptionEmptyError => "L'opzione non può essere vuota"; + + @override + String get pollOptionDuplicateError => "Questa è già un'opzione"; + + @override + String get addAnOptionLabel => "Aggiungi un'opzione"; + + @override + String get multipleAnswersLabel => 'Risposte multiple'; + + @override + String get maximumVotesPerPersonLabel => 'Numero massimo di voti per persona'; + + @override + String? maxVotesPerPersonValidationError(int votes, Range range) { + final (:min, :max) = range; + + if (min != null && votes < min) { + return 'Il conteggio dei voti deve essere almeno $min'; + } + + if (max != null && votes > max) { + return 'Il conteggio dei voti deve essere al massimo $max'; + } + + return null; + } + + @override + String get anonymousPollLabel => 'Sondaggio anonimo'; + + @override + String get suggestAnOptionLabel => "Suggerisci un'opzione"; + + @override + String get addACommentLabel => 'Aggiungi un commento'; + + @override + String get createLabel => 'Creare'; } diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart index 704c87946..e1386e607 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart @@ -443,4 +443,81 @@ class StreamChatLocalizationsJa extends GlobalStreamChatLocalizations { @override String get markUnreadError => 'メッセージを未読にする際にエラーが発生しました。最新の100件のチャンネルメッセージより古い未読メッセージはマークできません。'; + + @override + String createPollLabel({bool isNew = false}) { + if (isNew) return '新しい投票を作成する'; + return '投票の作成'; + } + + @override + String get questionsLabel => '問'; + + @override + String get askAQuestionLabel => '質問する'; + + @override + String? pollQuestionValidationError(int length, Range range) { + final (:min, :max) = range; + + // Check if the question is too short. + if (min != null && length < min) { + return '質問は $min 文字以上である必要があります'; + } + + // Check if the question is too long. + if (max != null && length > max) { + return '質問の長さは最大$max文字にする必要があります'; + } + + return null; + } + + @override + String optionLabel({bool isPlural = false}) { + if (isPlural) return 'オプション'; + return 'オプション'; + } + + @override + String get pollOptionEmptyError => 'オプションを空にすることはできません'; + + @override + String get pollOptionDuplicateError => 'これはすでにオプションです'; + + @override + String get addAnOptionLabel => 'オプションを追加する'; + + @override + String get multipleAnswersLabel => '複数の回答'; + + @override + String get maximumVotesPerPersonLabel => '一人当たりの最大投票数'; + + @override + String? maxVotesPerPersonValidationError(int votes, Range range) { + final (:min, :max) = range; + + if (min != null && votes < min) { + return '投票数は$min以上である必要があります'; + } + + if (max != null && votes > max) { + return '投票数は最大$max票でなければなりません'; + } + + return null; + } + + @override + String get anonymousPollLabel => '匿名投票'; + + @override + String get suggestAnOptionLabel => 'オプションを提案する'; + + @override + String get addACommentLabel => 'コメントを追加'; + + @override + String get createLabel => '創造する'; } diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart index e7b1f1951..367cb2f20 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart @@ -444,4 +444,81 @@ class StreamChatLocalizationsKo extends GlobalStreamChatLocalizations { String get markUnreadError => '메시지를 읽지 않음으로 표시하는 중 오류가 발생했습니다. 가장 최근 100개의 채널 메시지보다 오래된 읽지 않은 메시지는' ' 표시할 수 없습니다.'; + + @override + String createPollLabel({bool isNew = false}) { + if (isNew) return '새 투표 만들기'; + return '투표 만들기'; + } + + @override + String get questionsLabel => '질문'; + + @override + String get askAQuestionLabel => '질문하기'; + + @override + String? pollQuestionValidationError(int length, Range range) { + final (:min, :max) = range; + + // Check if the question is too short. + if (min != null && length < min) { + return '질문은 $min자 이상이어야 합니다.'; + } + + // Check if the question is too long. + if (max != null && length > max) { + return '질문은 최대 $max자여야 합니다.'; + } + + return null; + } + + @override + String optionLabel({bool isPlural = false}) { + if (isPlural) return '옵션'; + return '선택'; + } + + @override + String get pollOptionEmptyError => '옵션은 비워 둘 수 없습니다.'; + + @override + String get pollOptionDuplicateError => '이것은 이미 선택 사항입니다'; + + @override + String get addAnOptionLabel => '옵션 추가'; + + @override + String get multipleAnswersLabel => '복수 답변'; + + @override + String get maximumVotesPerPersonLabel => '1인당 최대 투표 수'; + + @override + String? maxVotesPerPersonValidationError(int votes, Range range) { + final (:min, :max) = range; + + if (min != null && votes < min) { + return '투표 수는 $min개 이상이어야 합니다.'; + } + + if (max != null && votes > max) { + return '투표 수는 최대 $max개여야 합니다.'; + } + + return null; + } + + @override + String get anonymousPollLabel => '익명 투표'; + + @override + String get suggestAnOptionLabel => '옵션 제안하기'; + + @override + String get addACommentLabel => '댓글 추가'; + + @override + String get createLabel => '창조하다'; } diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_no.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_no.dart index 77fd8afaa..75d01e687 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_no.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_no.dart @@ -451,4 +451,82 @@ class StreamChatLocalizationsNo extends GlobalStreamChatLocalizations { String get markUnreadError => 'Feil ved merking av melding som ulest. Kan ikke merke meldinger som' ' uleste som er eldre enn de 100 nyeste kanalmeldingene.'; + + @override + String createPollLabel({bool isNew = false}) { + if (isNew) return 'Opprett en ny avstemning'; + return 'Opprett avstemning'; + } + + @override + String get questionsLabel => 'Spørsmål'; + + @override + String get askAQuestionLabel => 'Still et spørsmål'; + + @override + String? pollQuestionValidationError(int length, Range range) { + final (:min, :max) = range; + + // Check if the question is too short. + if (min != null && length < min) { + return 'Spørsmålet må være minst $min tegn langt'; + } + + // Check if the question is too long. + if (max != null && length > max) { + return 'Spørsmålet må være maksimalt $max tegn langt'; + } + + return null; + } + + @override + String optionLabel({bool isPlural = false}) { + if (isPlural) return 'Alternativer'; + return 'Opsjon'; + } + + @override + String get pollOptionEmptyError => 'Alternativet kan ikke være tomt'; + + @override + String get pollOptionDuplicateError => 'Dette er allerede et alternativ'; + + @override + String get addAnOptionLabel => 'Legg til et alternativ'; + + @override + String get multipleAnswersLabel => 'Flere svar'; + + @override + String get maximumVotesPerPersonLabel => + 'Maksimalt antall stemmer per person'; + + @override + String? maxVotesPerPersonValidationError(int votes, Range range) { + final (:min, :max) = range; + + if (min != null && votes < min) { + return 'Stemmetellingen må være minst $min'; + } + + if (max != null && votes > max) { + return 'Stemmeopptellingen må være på maksimalt $max'; + } + + return null; + } + + @override + String get anonymousPollLabel => 'Anonym avstemning'; + + @override + String get suggestAnOptionLabel => 'Foreslå et alternativ'; + + @override + String get addACommentLabel => 'Legg til en kommentar'; + + @override + String get createLabel => 'Skape'; } diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_pt.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_pt.dart index 9ea9ef1dd..2e0fff5cf 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_pt.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_pt.dart @@ -464,4 +464,81 @@ Não é possível adicionar mais de $limit arquivos de uma vez String get markUnreadError => 'Erro ao marcar a mensagem como não lida. Não é possível marcar mensagens' ' não lidas mais antigas do que as 100 mensagens mais recentes do canal.'; + + @override + String createPollLabel({bool isNew = false}) { + if (isNew) return 'Criar uma nova sondagem'; + return 'Criar sondagem'; + } + + @override + String get questionsLabel => 'Perguntas'; + + @override + String get askAQuestionLabel => 'Fazer uma pergunta'; + + @override + String? pollQuestionValidationError(int length, Range range) { + final (:min, :max) = range; + + // Check if the question is too short. + if (min != null && length < min) { + return 'A pergunta deve ter pelo menos $min caracteres'; + } + + // Check if the question is too long. + if (max != null && length > max) { + return 'A pergunta deve ter, no máximo, $max caracteres'; + } + + return null; + } + + @override + String optionLabel({bool isPlural = false}) { + if (isPlural) return 'Opções'; + return 'Opção'; + } + + @override + String get pollOptionEmptyError => 'A opção não pode estar vazia'; + + @override + String get pollOptionDuplicateError => 'Esta já é uma opção'; + + @override + String get addAnOptionLabel => 'Adicionar uma opção'; + + @override + String get multipleAnswersLabel => 'Respostas múltiplas'; + + @override + String get maximumVotesPerPersonLabel => 'Máximo de votos por pessoa'; + + @override + String? maxVotesPerPersonValidationError(int votes, Range range) { + final (:min, :max) = range; + + if (min != null && votes < min) { + return 'A contagem dos votos deve ser de, pelo menos, $min'; + } + + if (max != null && votes > max) { + return 'A contagem dos votos deve ser, no máximo, $max'; + } + + return null; + } + + @override + String get anonymousPollLabel => 'Sondagem anónima'; + + @override + String get suggestAnOptionLabel => 'Sugira uma opção'; + + @override + String get addACommentLabel => 'Adicionar um comentário'; + + @override + String get createLabel => 'Criar'; } diff --git a/packages/stream_chat_localizations/test/translations_test.dart b/packages/stream_chat_localizations/test/translations_test.dart index d581da8fc..a58dc5a80 100644 --- a/packages/stream_chat_localizations/test/translations_test.dart +++ b/packages/stream_chat_localizations/test/translations_test.dart @@ -205,6 +205,51 @@ void main() { expect(localizations.unreadMessagesSeparatorText(), isNotNull); expect(localizations.markUnreadError, isNotNull); expect(localizations.markAsUnreadLabel, isNotNull); + // Create poll + expect(localizations.createPollLabel(), isNotNull); + // Create a new poll + expect(localizations.createPollLabel(isNew: true), isNotNull); + expect(localizations.questionsLabel, isNotNull); + expect(localizations.askAQuestionLabel, isNotNull); + // Question must be at least 5 characters long + expect( + localizations.pollQuestionValidationError(3, const (min: 5, max: 10)), + isNotNull, + ); + // Question must be at most 10 characters long + expect( + localizations.pollQuestionValidationError(11, const (min: 5, max: 10)), + isNotNull, + ); + // Option + expect(localizations.optionLabel(), isNotNull); + // Options + expect(localizations.optionLabel(isPlural: true), isNotNull); + expect(localizations.pollOptionEmptyError, isNotNull); + expect(localizations.pollOptionDuplicateError, isNotNull); + expect(localizations.addAnOptionLabel, isNotNull); + expect(localizations.multipleAnswersLabel, isNotNull); + expect(localizations.maximumVotesPerPersonLabel, isNotNull); + // Vote count must be at least 1 + expect( + localizations.maxVotesPerPersonValidationError( + 0, + const (min: 1, max: 10), + ), + isNotNull, + ); + // Vote count must be at most 10 + expect( + localizations.maxVotesPerPersonValidationError( + 11, + const (min: 1, max: 10), + ), + isNotNull, + ); + expect(localizations.anonymousPollLabel, isNotNull); + expect(localizations.suggestAnOptionLabel, isNotNull); + expect(localizations.addACommentLabel, isNotNull); + expect(localizations.createLabel, isNotNull); }); }