Merge branch 'v4' into feat/stream-channel-listview

This commit is contained in:
Salvatore Giordano
2022-02-01 10:36:37 +01:00
60 changed files with 4856 additions and 1006 deletions
@@ -34,7 +34,7 @@ jobs:
run: |
flutter pub global activate melos ${{ env.melos_version }}
- name: "Bootstrap Workspace"
run: melos bootstrap
run: melos bootstrap --verbose
- name: "Dart Analyze"
run: |
melos run analyze
+2 -1
View File
@@ -11,7 +11,8 @@ dependencies:
cupertino_icons: ^1.0.0
flutter:
sdk: flutter
stream_chat: ^2.2.1
stream_chat:
path: ../
dev_dependencies:
flutter_test:
@@ -294,6 +294,18 @@ class Channel {
return data;
}
/// List of user permissions on this channel
List<String> get ownCapabilities =>
state?._channelState.channel?.ownCapabilities ?? [];
/// List of user permissions on this channel
Stream<List<String>> get ownCapabilitiesStream {
_checkInitialized();
return state!.channelStateStream
.map((cs) => cs.channel?.ownCapabilities ?? [])
.distinct();
}
/// Channel extra data as a stream.
Stream<Map<String, Object?>> get extraDataStream {
_checkInitialized();
@@ -487,6 +499,7 @@ class Channel {
Future<SendMessageResponse> sendMessage(
Message message, {
bool skipPush = false,
bool skipEnrichUrl = false,
}) async {
_checkInitialized();
// Cancelling previous completer in case it's called again in the process
@@ -534,6 +547,7 @@ class Channel {
id!,
type,
skipPush: skipPush,
skipEnrichUrl: skipEnrichUrl,
);
state!.addMessage(response.message);
if (cooldown > 0) cooldownStartedAt = DateTime.now();
@@ -550,7 +564,10 @@ class Channel {
///
/// Waits for a [_messageAttachmentsUploadCompleter] to complete
/// before actually updating the message.
Future<UpdateMessageResponse> updateMessage(Message message) async {
Future<UpdateMessageResponse> updateMessage(
Message message, {
bool skipEnrichUrl = false,
}) async {
final originalMessage = message;
// Cancelling previous completer in case it's called again in the process
@@ -588,7 +605,10 @@ class Channel {
message = await attachmentsUploadCompleter.future;
}
final response = await _client.updateMessage(message);
final response = await _client.updateMessage(
message,
skipEnrichUrl: skipEnrichUrl,
);
final m = response.message.copyWith(
ownReactions: message.ownReactions,
@@ -618,12 +638,14 @@ class Channel {
Message message, {
Map<String, Object?>? set,
List<String>? unset,
bool skipEnrichUrl = false,
}) async {
try {
final response = await _client.partialUpdateMessage(
message.id,
set: set,
unset: unset,
skipEnrichUrl: skipEnrichUrl,
);
final updatedMessage = response.message.copyWith(
@@ -1572,7 +1594,9 @@ class ChannelClientState {
_subscriptions.add(_channel.on(EventType.channelUpdated).listen((Event e) {
final channel = e.channel!;
updateChannelState(channelState.copyWith(
channel: channel,
channel: channel.copyWith(
ownCapabilities: channelState.channel?.ownCapabilities,
),
members: channel.members,
));
}));
@@ -1183,12 +1183,14 @@ class StreamChatClient {
String channelId,
String channelType, {
bool skipPush = false,
bool skipEnrichUrl = false,
}) =>
_chatApi.message.sendMessage(
channelId,
channelType,
message,
skipPush: skipPush,
skipEnrichUrl: skipEnrichUrl,
);
/// Lists all the message replies for the [parentId]
@@ -1212,8 +1214,14 @@ class StreamChatClient {
);
/// Update the given message
Future<UpdateMessageResponse> updateMessage(Message message) =>
_chatApi.message.updateMessage(message);
Future<UpdateMessageResponse> updateMessage(
Message message, {
bool skipEnrichUrl = false,
}) =>
_chatApi.message.updateMessage(
message,
skipEnrichUrl: skipEnrichUrl,
);
/// Partially update the given [messageId]
/// Use [set] to define values to be set
@@ -1222,11 +1230,13 @@ class StreamChatClient {
String messageId, {
Map<String, Object?>? set,
List<String>? unset,
bool skipEnrichUrl = false,
}) =>
_chatApi.message.partialUpdateMessage(
messageId,
set: set,
unset: unset,
skipEnrichUrl: skipEnrichUrl,
);
/// Deletes the given message
@@ -5,9 +5,9 @@ import 'package:rxdart/rxdart.dart';
import 'package:stream_chat/src/client/retry_policy.dart';
import 'package:stream_chat/stream_chat.dart';
/// The retry queue associated to a channel
/// The retry queue associated to a channel.
class RetryQueue {
/// Instantiate a new RetryQueue object
/// Instantiate a new RetryQueue object.
RetryQueue({
required this.channel,
this.logger,
@@ -17,13 +17,13 @@ class RetryQueue {
_listenFailedEvents();
}
/// The channel of this queue
/// The channel of this queue.
final Channel channel;
/// The client associated with this [channel]
/// The client associated with this [channel].
final StreamChatClient client;
/// The logger associated to this queue
/// The logger associated to this queue.
final Logger? logger;
late final RetryPolicy _retryPolicy;
@@ -63,7 +63,7 @@ class RetryQueue {
}).addTo(_compositeSubscription);
}
/// Add a list of messages
/// Add a list of messages.
void add(List<Message> messages) {
if (messages.isEmpty) return;
if (!_messageQueue.containsAllMessage(messages)) {
@@ -113,6 +113,7 @@ class RetryQueue {
} catch (e) {
if (e is! StreamChatNetworkError || !e.isRetriable) {
_messageQueue.removeMessage(message);
_sendFailedEvent(message);
return true;
}
// retry logic
@@ -174,10 +175,10 @@ class RetryQueue {
}
}
/// Whether our [_messageQueue] has messages or not
/// Whether our [_messageQueue] has messages or not.
bool get hasMessages => _messageQueue.isNotEmpty;
/// Call this method to dispose this object
/// Call this method to dispose this object.
void dispose() {
_messageQueue.clear();
_compositeSubscription.dispose();
@@ -266,6 +266,7 @@ class ChannelApi {
) async {
final response = await _client.post(
'${_getChannelUrl(channelId, channelType)}/truncate',
data: {},
);
return EmptyResponse.fromJson(response.data);
}
@@ -16,12 +16,14 @@ class MessageApi {
String channelType,
Message message, {
bool skipPush = false,
bool skipEnrichUrl = false,
}) async {
final response = await _client.post(
'/channels/$channelType/$channelId/message',
data: {
'message': message,
'skip_push': skipPush,
'skip_enrich_url': skipEnrichUrl,
},
);
return SendMessageResponse.fromJson(response.data);
@@ -51,11 +53,15 @@ class MessageApi {
/// Updates the given [message]
Future<UpdateMessageResponse> updateMessage(
Message message,
) async {
Message message, {
bool skipEnrichUrl = false,
}) async {
final response = await _client.post(
'/messages/${message.id}',
data: {'message': message},
data: {
'message': message,
'skip_enrich_url': skipEnrichUrl,
},
);
return UpdateMessageResponse.fromJson(response.data);
}
@@ -67,12 +73,14 @@ class MessageApi {
String messageId, {
Map<String, Object?>? set,
List<String>? unset,
bool skipEnrichUrl = false,
}) async {
final response = await _client.put(
'/messages/$messageId',
data: {
if (set != null) 'set': set,
if (unset != null) 'unset': unset,
'skip_enrich_url': skipEnrichUrl,
},
);
return UpdateMessageResponse.fromJson(response.data);
@@ -2,6 +2,7 @@
import 'package:equatable/equatable.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:stream_chat/src/core/api/responses.dart';
import 'package:stream_chat/src/core/models/action.dart';
import 'package:stream_chat/src/core/models/attachment_file.dart';
import 'package:stream_chat/src/core/util/serializer.dart';
@@ -66,6 +67,21 @@ class Attachment extends Equatable {
topLevelFields + dbSpecificTopLevelFields,
));
factory Attachment.fromOGAttachment(OGAttachmentResponse ogAttachment) =>
Attachment(
type: ogAttachment.type,
title: ogAttachment.title,
titleLink: ogAttachment.titleLink,
text: ogAttachment.text,
imageUrl: ogAttachment.imageUrl,
thumbUrl: ogAttachment.thumbUrl,
authorName: ogAttachment.authorName,
authorLink: ogAttachment.authorLink,
assetUrl: ogAttachment.assetUrl,
ogScrapeUrl: ogAttachment.ogScrapeUrl,
uploadState: const UploadState.success(),
);
///The attachment type based on the URL resource. This can be: audio,
///image or video
final String? type;
@@ -229,6 +245,33 @@ class Attachment extends Equatable {
extraData: extraData ?? this.extraData,
);
Attachment merge(Attachment? other) {
if (other == null) return this;
return copyWith(
type: other.type,
titleLink: other.titleLink,
title: other.title,
thumbUrl: other.thumbUrl,
text: other.text,
pretext: other.pretext,
ogScrapeUrl: other.ogScrapeUrl,
imageUrl: other.imageUrl,
footerIcon: other.footerIcon,
footer: other.footer,
fields: other.fields,
fallback: other.fallback,
color: other.color,
authorName: other.authorName,
authorLink: other.authorLink,
authorIcon: other.authorIcon,
assetUrl: other.assetUrl,
actions: other.actions,
file: other.file,
uploadState: other.uploadState,
extraData: other.extraData,
);
}
@override
List<Object?> get props => [
id,
@@ -13,6 +13,7 @@ class ChannelModel {
String? id,
String? type,
String? cid,
this.ownCapabilities = const [],
ChannelConfig? config,
this.createdBy,
this.frozen = false,
@@ -51,6 +52,10 @@ class ChannelModel {
@JsonKey(includeIfNull: false, toJson: Serializer.readOnly)
final String cid;
/// List of user permissions on this channel
@JsonKey(includeIfNull: false, toJson: Serializer.readOnly)
final List<String> ownCapabilities;
/// The channel configuration data
@JsonKey(includeIfNull: false, toJson: Serializer.readOnly)
final ChannelConfig config;
@@ -101,6 +106,7 @@ class ChannelModel {
'id',
'type',
'cid',
'own_capabilities',
'config',
'created_by',
'frozen',
@@ -127,6 +133,7 @@ class ChannelModel {
String? id,
String? type,
String? cid,
List<String>? ownCapabilities,
ChannelConfig? config,
User? createdBy,
bool? frozen,
@@ -143,6 +150,7 @@ class ChannelModel {
id: id ?? this.id,
type: type ?? this.type,
cid: cid ?? this.cid,
ownCapabilities: ownCapabilities ?? this.ownCapabilities,
config: config ?? this.config,
createdBy: createdBy ?? this.createdBy,
frozen: frozen ?? this.frozen,
@@ -164,6 +172,7 @@ class ChannelModel {
id: other.id,
type: other.type,
cid: other.cid,
ownCapabilities: other.ownCapabilities,
config: other.config,
createdBy: other.createdBy,
frozen: other.frozen,
@@ -10,6 +10,10 @@ ChannelModel _$ChannelModelFromJson(Map<String, dynamic> json) => ChannelModel(
id: json['id'] as String?,
type: json['type'] as String?,
cid: json['cid'] as String?,
ownCapabilities: (json['own_capabilities'] as List<dynamic>?)
?.map((e) => e as String)
.toList() ??
const [],
config: json['config'] == null
? null
: ChannelConfig.fromJson(json['config'] as Map<String, dynamic>),
@@ -48,6 +52,7 @@ Map<String, dynamic> _$ChannelModelToJson(ChannelModel instance) {
}
writeNotNull('cid', readonly(instance.cid));
writeNotNull('own_capabilities', readonly(instance.ownCapabilities));
writeNotNull('config', readonly(instance.config));
writeNotNull('created_by', readonly(instance.createdBy));
val['frozen'] = instance.frozen;
@@ -8,13 +8,13 @@ import 'package:uuid/uuid.dart';
part 'message.g.dart';
class _PinExpires {
const _PinExpires();
class _NullConst {
const _NullConst();
}
const _pinExpires = _PinExpires();
const _nullConst = _NullConst();
/// Enum defining the status of a sending message
/// Enum defining the status of a sending message.
enum MessageSendingStatus {
/// Message is being sent
sending,
@@ -40,10 +40,10 @@ enum MessageSendingStatus {
sent,
}
/// The class that contains the information about a message
/// The class that contains the information about a message.
@JsonSerializable()
class Message extends Equatable {
/// Constructor used for json serialization
/// Constructor used for json serialization.
Message({
String? id,
this.text,
@@ -58,44 +58,47 @@ class Message extends Equatable {
this.ownReactions,
this.parentId,
this.quotedMessage,
this.quotedMessageId,
String? quotedMessageId,
this.replyCount = 0,
this.threadParticipants,
this.showInChannel,
this.command,
DateTime? createdAt,
DateTime? updatedAt,
this.deletedAt,
this.user,
this.pinned = false,
this.pinnedAt,
DateTime? pinExpires,
this.pinnedBy,
this.extraData = const {},
this.deletedAt,
this.status = MessageSendingStatus.sent,
this.status = MessageSendingStatus.sending,
this.i18n,
}) : id = id ?? const Uuid().v4(),
pinExpires = pinExpires?.toUtc(),
createdAt = createdAt ?? DateTime.now(),
updatedAt = updatedAt ?? DateTime.now();
_createdAt = createdAt,
_updatedAt = updatedAt,
_quotedMessageId = quotedMessageId;
/// Create a new instance from a json
/// Create a new instance from JSON.
factory Message.fromJson(Map<String, dynamic> json) => _$MessageFromJson(
Serializer.moveToExtraDataFromRoot(json, topLevelFields),
).copyWith(
status: MessageSendingStatus.sent,
);
/// The message ID. This is either created by Stream or set client side when
/// the message is added.
final String id;
/// The text of this message
/// The text of this message.
final String? text;
/// The status of a sending message
/// The status of a sending message.
@JsonKey(ignore: true)
final MessageSendingStatus status;
/// The message type
/// The message type.
@JsonKey(
includeIfNull: false,
toJson: Serializer.readOnly,
@@ -107,15 +110,15 @@ class Message extends Equatable {
@JsonKey(includeIfNull: false)
final List<Attachment> attachments;
/// The list of user mentioned in the message
/// The list of user mentioned in the message.
@JsonKey(toJson: User.toIds)
final List<User> mentionedUsers;
/// A map describing the count of number of every reaction
/// A map describing the count of number of every reaction.
@JsonKey(includeIfNull: false, toJson: Serializer.readOnly)
final Map<String, int>? reactionCounts;
/// A map describing the count of score of every reaction
/// A map describing the count of score of every reaction.
@JsonKey(includeIfNull: false, toJson: Serializer.readOnly)
final Map<String, int>? reactionScores;
@@ -130,12 +133,14 @@ class Message extends Equatable {
/// The ID of the parent message, if the message is a thread reply.
final String? parentId;
/// A quoted reply message
/// A quoted reply message.
@JsonKey(toJson: Serializer.readOnly)
final Message? quotedMessage;
final String? _quotedMessageId;
/// The ID of the quoted message, if the message is a quoted reply.
final String? quotedMessageId;
String? get quotedMessageId => _quotedMessageId ?? quotedMessage?.id;
/// Reserved field indicating the number of replies for this message.
@JsonKey(includeIfNull: false, toJson: Serializer.readOnly)
@@ -148,10 +153,10 @@ class Message extends Equatable {
/// Check if this message needs to show in the channel.
final bool? showInChannel;
/// If true the message is silent
/// If true the message is silent.
final bool silent;
/// If true the message is shadowed
/// If true the message is shadowed.
@JsonKey(
includeIfNull: false,
toJson: Serializer.readOnly,
@@ -162,56 +167,61 @@ class Message extends Equatable {
@JsonKey(includeIfNull: false, toJson: Serializer.readOnly)
final String? command;
/// Reserved field indicating when the message was created.
@JsonKey(includeIfNull: false, toJson: Serializer.readOnly)
final DateTime createdAt;
/// Reserved field indicating when the message was updated last time.
@JsonKey(includeIfNull: false, toJson: Serializer.readOnly)
final DateTime updatedAt;
/// User who sent the message
@JsonKey(includeIfNull: false, toJson: Serializer.readOnly)
final User? user;
/// If true the message is pinned
final bool pinned;
/// Reserved field indicating when the message was pinned
@JsonKey(toJson: Serializer.readOnly)
final DateTime? pinnedAt;
/// Reserved field indicating when the message will expire
///
/// if `null` message has no expiry
final DateTime? pinExpires;
/// Reserved field indicating who pinned the message
@JsonKey(toJson: Serializer.readOnly)
final User? pinnedBy;
/// Message custom extraData
@JsonKey(includeIfNull: false)
final Map<String, Object?> extraData;
/// True if the message is a system info
bool get isSystem => type == 'system';
/// True if the message has been deleted
bool get isDeleted => type == 'deleted';
/// True if the message is ephemeral
bool get isEphemeral => type == 'ephemeral';
final DateTime? _createdAt;
/// Reserved field indicating when the message was deleted.
@JsonKey(includeIfNull: false, toJson: Serializer.readOnly)
final DateTime? deletedAt;
/// Reserved field indicating when the message was created.
@JsonKey(includeIfNull: false, toJson: Serializer.readOnly)
DateTime get createdAt => _createdAt ?? DateTime.now();
final DateTime? _updatedAt;
/// Reserved field indicating when the message was updated last time.
@JsonKey(includeIfNull: false, toJson: Serializer.readOnly)
DateTime get updatedAt => _updatedAt ?? DateTime.now();
/// User who sent the message.
@JsonKey(includeIfNull: false, toJson: Serializer.readOnly)
final User? user;
/// If true the message is pinned.
final bool pinned;
/// Reserved field indicating when the message was pinned.
@JsonKey(toJson: Serializer.readOnly)
final DateTime? pinnedAt;
/// Reserved field indicating when the message will expire.
///
/// If `null` message has no expiry.
final DateTime? pinExpires;
/// Reserved field indicating who pinned the message.
@JsonKey(toJson: Serializer.readOnly)
final User? pinnedBy;
/// Message custom extraData.
@JsonKey(includeIfNull: false)
final Map<String, Object?> extraData;
/// True if the message is a system info.
bool get isSystem => type == 'system';
/// True if the message has been deleted.
bool get isDeleted => type == 'deleted';
/// True if the message is ephemeral.
bool get isEphemeral => type == 'ephemeral';
/// A Map of translations.
@JsonKey(includeIfNull: false)
final Map<String, String>? i18n;
/// Known top level fields.
///
/// Useful for [Serializer] methods.
static const topLevelFields = [
'id',
@@ -244,7 +254,7 @@ class Message extends Equatable {
'i18n',
];
/// Serialize to json
/// Serialize to json.
Map<String, dynamic> toJson() => Serializer.moveFromExtraDataToRoot(
_$MessageToJson(this),
);
@@ -256,18 +266,18 @@ class Message extends Equatable {
String? type,
List<Attachment>? attachments,
List<User>? mentionedUsers,
bool? silent,
bool? shadowed,
Map<String, int>? reactionCounts,
Map<String, int>? reactionScores,
List<Reaction>? latestReactions,
List<Reaction>? ownReactions,
String? parentId,
Message? quotedMessage,
String? quotedMessageId,
Object? quotedMessage = _nullConst,
Object? quotedMessageId = _nullConst,
int? replyCount,
List<User>? threadParticipants,
bool? showInChannel,
bool? shadowed,
bool? silent,
String? command,
DateTime? createdAt,
DateTime? updatedAt,
@@ -275,7 +285,7 @@ class Message extends Equatable {
User? user,
bool? pinned,
DateTime? pinnedAt,
Object? pinExpires = _pinExpires,
Object? pinExpires = _nullConst,
User? pinnedBy,
Map<String, Object?>? extraData,
MessageSendingStatus? status,
@@ -284,41 +294,68 @@ class Message extends Equatable {
assert(() {
if (pinExpires is! DateTime &&
pinExpires != null &&
pinExpires is! _PinExpires) {
pinExpires is! _NullConst) {
throw ArgumentError('`pinExpires` can only be set as DateTime or null');
}
return true;
}(), 'Validate type for pinExpires');
assert(() {
if (quotedMessage is! Message &&
quotedMessage != null &&
quotedMessage is! _NullConst) {
throw ArgumentError(
'`quotedMessage` can only be set as Message or null',
);
}
return true;
}(), 'Validate type for quotedMessage');
assert(() {
if (quotedMessageId is! String &&
quotedMessageId != null &&
quotedMessageId is! _NullConst) {
throw ArgumentError(
'`quotedMessage` can only be set as String or null',
);
}
return true;
}(), 'Validate type for quotedMessage');
return Message(
id: id ?? this.id,
text: text ?? this.text,
type: type ?? this.type,
attachments: attachments ?? this.attachments,
mentionedUsers: mentionedUsers ?? this.mentionedUsers,
silent: silent ?? this.silent,
shadowed: shadowed ?? this.shadowed,
reactionCounts: reactionCounts ?? this.reactionCounts,
reactionScores: reactionScores ?? this.reactionScores,
latestReactions: latestReactions ?? this.latestReactions,
ownReactions: ownReactions ?? this.ownReactions,
parentId: parentId ?? this.parentId,
quotedMessage: quotedMessage ?? this.quotedMessage,
quotedMessageId: quotedMessageId ?? this.quotedMessageId,
quotedMessage: quotedMessage == _nullConst
? this.quotedMessage
: quotedMessage as Message?,
quotedMessageId: quotedMessageId == _nullConst
? _quotedMessageId
: quotedMessageId as String?,
replyCount: replyCount ?? this.replyCount,
threadParticipants: threadParticipants ?? this.threadParticipants,
showInChannel: showInChannel ?? this.showInChannel,
command: command ?? this.command,
createdAt: createdAt ?? this.createdAt,
silent: silent ?? this.silent,
extraData: extraData ?? this.extraData,
user: user ?? this.user,
shadowed: shadowed ?? this.shadowed,
updatedAt: updatedAt ?? this.updatedAt,
createdAt: createdAt ?? _createdAt,
updatedAt: updatedAt ?? _updatedAt,
deletedAt: deletedAt ?? this.deletedAt,
status: status ?? this.status,
user: user ?? this.user,
pinned: pinned ?? this.pinned,
pinnedAt: pinnedAt ?? this.pinnedAt,
pinnedBy: pinnedBy ?? this.pinnedBy,
pinExpires:
pinExpires == _pinExpires ? this.pinExpires : pinExpires as DateTime?,
pinExpires == _nullConst ? this.pinExpires : pinExpires as DateTime?,
pinnedBy: pinnedBy ?? this.pinnedBy,
extraData: extraData ?? this.extraData,
status: status ?? this.status,
i18n: i18n ?? this.i18n,
);
}
@@ -331,6 +368,8 @@ class Message extends Equatable {
type: other.type,
attachments: other.attachments,
mentionedUsers: other.mentionedUsers,
silent: other.silent,
shadowed: other.shadowed,
reactionCounts: other.reactionCounts,
reactionScores: other.reactionScores,
latestReactions: other.latestReactions,
@@ -343,17 +382,15 @@ class Message extends Equatable {
showInChannel: other.showInChannel,
command: other.command,
createdAt: other.createdAt,
silent: other.silent,
extraData: other.extraData,
user: other.user,
shadowed: other.shadowed,
updatedAt: other.updatedAt,
deletedAt: other.deletedAt,
status: other.status,
user: other.user,
pinned: other.pinned,
pinnedAt: other.pinnedAt,
pinExpires: other.pinExpires,
pinnedBy: other.pinnedBy,
extraData: other.extraData,
status: other.status,
i18n: other.i18n,
);
@@ -377,8 +414,8 @@ class Message extends Equatable {
shadowed,
silent,
command,
createdAt,
updatedAt,
_createdAt,
_updatedAt,
deletedAt,
user,
pinned,
@@ -0,0 +1,96 @@
/// Describes capabilities of a user vis-a-vis a channel
class PermissionType {
/// Capability required to send a message in the channel
/// Channel is not frozen (or user has UseFrozenChannel permission)
/// and user has CreateMessage permission.
static const String sendMessage = 'send-message';
/// Capability required to receive connect events in the channel
static const String connectEvents = 'connect-events';
/// Capability required to send a message
/// Reactions are enabled for the channel, channel is not frozen
/// (or user has UseFrozenChannel permission) and user has
/// CreateReaction permission
static const String sendReaction = 'send-reaction';
/// Capability required to send links in a channel
/// send-message + user has AddLinks permission
static const String sendLinks = 'send-links';
/// Capability required to send thread reply
/// send-message + channel has replies enabled
static const String sendReply = 'send-reply';
/// Capability to freeze a channel
/// User has UpdateChannelFrozen permission.
/// The name implies freezing,
/// but unfreezing is also allowed when this capability is present
static const String freezeChannel = 'freeze-channel';
/// User has UpdateChannelCooldown permission.
/// Allows to enable/disable slow mode in the channel
static const String setChannelCooldown = 'set-channel-cooldown';
/// User has RemoveOwnChannelMembership or UpdateChannelMembers permission
static const String leaveChannel = 'leave-channel';
/// User can mute channel
static const String muteChannel = 'mute-channel';
/// Ability to receive read events
static const String readEvents = 'read-events';
/// Capability required to pin a message in a channel
/// Corresponds to PinMessage permission
static const String pinMessage = 'pin-message';
/// Capability required to quote a message in a channel
static const String quoteMessage = 'quote-message';
/// Capability required to flag a message in a channel
static const String flagMessage = 'flag-message';
/// User has ability to delete any message in the channel
/// User has DeleteMessage permission
/// which applies to any message in the channel
static const String deleteAnyMessage = 'delete-any-message';
/// User has ability to delete their own message in the channel
/// User has DeleteMessage permission which applies only to owned messages
static const String deleteOwnMessage = 'delete-own-message';
/// User has ability to update/edit any message in the channel
/// User has UpdateMessage permission which
/// applies to any message in the channel
static const String updateAnyMessage = 'update-any-message';
/// User has ability to update/edit their own message in the channel
/// User has UpdateMessage permission which applies only to owned messages
static const String updateOwnMessage = 'update-own-message';
/// User can search for message in a channel
/// Search feature is enabled (it will also have
/// permission check in the future)
static const String searchMessages = 'search-messages';
/// Capability required to send typing events in a channel
/// (Typing events are enabled)
static const String sendTypingEvents = 'send-typing-events';
/// Capability required to upload a file in a channel
/// Uploads are enabled and user has UploadAttachment
static const String uploadFile = 'upload-file';
/// Capability required to delete channel
/// User has DeleteChannel permission
static const String deleteChannel = 'delete-channel';
/// Capability required update/edit channel info
/// User has UpdateChannel permission
static const String updateChannel = 'update-channel';
/// Capability required to update/edit channel members
/// Channel is not distinct and user has UpdateChannelMembers permission
static const String updateChannelMembers = 'update-channel-members';
}
@@ -7,6 +7,7 @@ export 'package:dio/src/options.dart';
export 'package:dio/src/options.dart' show ProgressCallback;
export 'package:logging/logging.dart' show Logger, Level;
export 'package:rate_limiter/rate_limiter.dart';
export 'package:uuid/uuid.dart';
export './src/core/api/attachment_file_uploader.dart'
show AttachmentFileUploader;
@@ -36,6 +37,7 @@ export './src/core/util/extension.dart';
export './src/db/chat_persistence_client.dart';
export './src/event_type.dart';
export './src/location.dart';
export './src/permission_type.dart';
export './src/ws/connection_status.dart';
export 'src/client/channel.dart';
export 'src/client/client.dart';
@@ -244,9 +244,13 @@ void main() {
group('`.sendMessage`', () {
test('should work fine', () async {
final message = Message(id: 'test-message-id');
final message = Message(
id: 'test-message-id',
user: client.state.currentUser,
);
final sendMessageResponse = SendMessageResponse()..message = message;
final sendMessageResponse = SendMessageResponse()
..message = message.copyWith(status: MessageSendingStatus.sent);
when(() => client.sendMessage(
any(that: isSameMessageAs(message)),
@@ -329,6 +333,7 @@ void main() {
.map((it) =>
it.copyWith(uploadState: const UploadState.success()))
.toList(growable: false),
status: MessageSendingStatus.sent,
));
expectLater(
@@ -455,7 +460,10 @@ void main() {
group('`.updateMessage`', () {
test('should work fine', () async {
final message = Message(id: 'test-message-id');
final message = Message(
id: 'test-message-id',
status: MessageSendingStatus.sent,
);
final updateMessageResponse = UpdateMessageResponse()
..message = message;
@@ -530,6 +538,7 @@ void main() {
any(that: isSameMessageAs(message)),
)).thenAnswer((_) async => UpdateMessageResponse()
..message = message.copyWith(
status: MessageSendingStatus.sent,
attachments: attachments
.map((it) =>
it.copyWith(uploadState: const UploadState.success()))
@@ -678,7 +687,7 @@ void main() {
[
isSameMessageAs(
updateMessageResponse.message.copyWith(
status: MessageSendingStatus.sent,
status: MessageSendingStatus.sending,
),
matchText: true,
matchSendingStatus: true,
@@ -707,7 +716,10 @@ void main() {
group('`.deleteMessage`', () {
test('should work fine', () async {
const messageId = 'test-message-id';
final message = Message(id: messageId);
final message = Message(
id: messageId,
status: MessageSendingStatus.sent,
);
when(() => client.deleteMessage(messageId))
.thenAnswer((_) async => EmptyResponse());
@@ -744,7 +756,6 @@ void main() {
const messageId = 'test-message-id';
final message = Message(
id: messageId,
status: MessageSendingStatus.sending,
);
expectLater(
@@ -1077,7 +1088,10 @@ void main() {
group('`.sendReaction`', () {
test('should work fine', () async {
const type = 'test-reaction-type';
final message = Message(id: 'test-message-id');
final message = Message(
id: 'test-message-id',
status: MessageSendingStatus.sent,
);
final reaction = Reaction(type: type, messageId: message.id);
@@ -1120,7 +1134,10 @@ void main() {
'should restore previous message if `client.sendReaction` throws',
() async {
const type = 'test-reaction-type';
final message = Message(id: 'test-message-id');
final message = Message(
id: 'test-message-id',
status: MessageSendingStatus.sent,
);
final reaction = Reaction(type: type, messageId: message.id);
@@ -1181,6 +1198,7 @@ void main() {
latestReactions: [prevReaction],
reactionScores: const {prevType: 1},
reactionCounts: const {prevType: 1},
status: MessageSendingStatus.sent,
);
const type = 'test-reaction-type-2';
@@ -1212,7 +1230,7 @@ void main() {
emitsInOrder([
[
isSameMessageAs(
newMessage.copyWith(status: MessageSendingStatus.sent),
newMessage,
matchReactions: true,
matchSendingStatus: true,
),
@@ -1255,6 +1273,7 @@ void main() {
latestReactions: [reaction],
reactionScores: const {type: 1},
reactionCounts: const {type: 1},
status: MessageSendingStatus.sent,
);
when(() => client.deleteReaction(messageId, type))
@@ -1302,6 +1321,7 @@ void main() {
latestReactions: [reaction],
reactionScores: const {type: 1},
reactionCounts: const {type: 1},
status: MessageSendingStatus.sent,
);
when(() => client.deleteReaction(messageId, type))
@@ -481,14 +481,21 @@ void main() {
final path = '${_getChannelUrl(channelId, channelType)}/truncate';
when(() => client.post(path)).thenAnswer(
(_) async => successResponse(path, data: <String, dynamic>{}));
when(() => client.post(
path,
data: {},
))
.thenAnswer(
(_) async => successResponse(path, data: <String, dynamic>{}));
final res = await channelApi.truncateChannel(channelId, channelType);
expect(res, isNotNull);
verify(() => client.post(path)).called(1);
verify(() => client.post(
path,
data: {},
)).called(1);
verifyNoMoreInteractions(client);
});
@@ -32,6 +32,7 @@ void main() {
data: {
'message': message,
'skip_push': false,
'skip_enrich_url': false,
},
)).thenAnswer((_) async => successResponse(path, data: {
'message': message.toJson(),
@@ -58,6 +59,7 @@ void main() {
data: {
'message': message,
'skip_push': true,
'skip_enrich_url': false,
},
)).thenAnswer((_) async => successResponse(path, data: {
'message': message.toJson(),
@@ -137,7 +139,10 @@ void main() {
when(() => client.post(
path,
data: {'message': message},
data: {
'message': message,
'skip_enrich_url': false,
},
)).thenAnswer(
(_) async => successResponse(path, data: {'message': message.toJson()}),
);
@@ -162,7 +167,11 @@ void main() {
when(() => client.put(
path,
data: {'set': set, 'unset': unset},
data: {
'set': set,
'unset': unset,
'skip_enrich_url': false,
},
)).thenAnswer(
(_) async => successResponse(path, data: {'message': message.toJson()}),
);
@@ -180,7 +189,11 @@ void main() {
verify(() => client.put(
path,
data: {'set': set, 'unset': unset},
data: {
'set': set,
'unset': unset,
'skip_enrich_url': false,
},
)).called(1);
verifyNoMoreInteractions(client);
});
+10
View File
@@ -1,5 +1,10 @@
## Upcoming
🛑️ Breaking Changes
- `pinPermissions` is no longer needed in `MessageListView`.
- `MessageInput` now works with a `MessageInputController` instead of a `TextEditingController`
🐞 Fixed
- SVG rendering fixes.
@@ -9,6 +14,11 @@
✅ Added
- Videos can now be auto-played in `FullScreenMedia`
- Extra customisation options for `MessageInput`
🔄 Changed
- Add `didUpdateWidget` override in `MessageInput` widget to handle changes to `focusNode`.
## 3.3.2
@@ -136,7 +136,9 @@ class ThreadPage extends StatelessWidget {
),
),
MessageInput(
parentMessage: parent,
messageInputController: MessageInputController(
message: Message(parentId: parent!.id),
),
),
],
),
@@ -166,7 +166,9 @@ class ThreadPage extends StatelessWidget {
),
),
MessageInput(
parentMessage: parent,
messageInputController: MessageInputController(
message: Message(parentId: parent!.id),
),
),
],
),
@@ -27,9 +27,12 @@ dependencies:
cupertino_icons: ^1.0.3
flutter:
sdk: flutter
stream_chat_flutter: ^2.2.1
stream_chat_localizations: ^1.1.0
stream_chat_persistence: ^2.2.0
stream_chat_flutter:
path: ../
stream_chat_localizations:
path: ../../stream_chat_localizations
stream_chat_persistence:
path: ../../stream_chat_persistence
dev_dependencies:
flutter_test:
@@ -155,7 +155,9 @@ class _ChannelBottomSheetState extends State<ChannelBottomSheet> {
title: context.translations.viewInfoLabel,
onTap: widget.onViewInfoTap,
),
if (!channel.isDistinct)
if (!channel.isDistinct &&
channel.ownCapabilities
.contains(PermissionType.leaveChannel))
OptionListTile(
leading: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
@@ -174,7 +176,9 @@ class _ChannelBottomSheetState extends State<ChannelBottomSheet> {
});
},
),
if (isOwner)
if (isOwner &&
channel.ownCapabilities
.contains(PermissionType.deleteChannel))
OptionListTile(
leading: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
@@ -60,7 +60,8 @@ class ChannelInfo extends StatelessWidget {
var text = context.translations.membersCountText(memberCount);
final onlineCount =
members?.where((m) => m.user?.online == true).length ?? 0;
if (onlineCount > 0) {
if (channel.ownCapabilities.contains(PermissionType.connectEvents) &&
onlineCount > 0) {
text += ', ${context.translations.watchersCountText(onlineCount)}';
}
alternativeWidget = Text(
@@ -1,4 +1,3 @@
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
import 'package:shimmer/shimmer.dart';
@@ -556,14 +555,8 @@ class _ChannelListViewState extends State<ChannelListView> {
);
},
),
if ([
'admin',
'owner',
].contains(channel.state!.members
.firstWhereOrNull(
(m) => m.userId == channel.client.state.currentUser?.id,
)
?.role))
if (channel.ownCapabilities
.contains(PermissionType.deleteChannel))
IconSlideAction(
color: backgroundColor,
iconWidget: StreamSvgIcon.delete(
@@ -46,7 +46,7 @@ extension IterableX<T> on Iterable<T> {
extension PlatformFileX on PlatformFile {
/// Converts the [PlatformFile] into [AttachmentFile]
AttachmentFile get toAttachmentFile => AttachmentFile(
//ignore: avoid_redundant_argument_values
// ignore: avoid_redundant_argument_values
path: kIsWeb ? null : path,
name: name,
bytes: bytes,
@@ -1,6 +1,6 @@
import 'package:jiffy/jiffy.dart';
import 'package:stream_chat_flutter/src/connection_status_builder.dart';
import 'package:stream_chat_flutter/src/message_input.dart';
import 'package:stream_chat_flutter/src/message_input/message_input.dart';
import 'package:stream_chat_flutter/src/message_list_view.dart';
import 'package:stream_chat_flutter/src/message_search_list_view.dart';
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'
@@ -93,6 +93,9 @@ abstract class Translations {
/// The label for search Gif
String get searchGifLabel;
/// The label for the MessageInput hint when permission denied on sendMessage
String get sendMessagePermissionError;
/// The label for add a comment or send in case of
/// attachments inside [MessageInput]
String get addACommentOrSendLabel;
@@ -141,6 +144,12 @@ abstract class Translations {
/// The label for "OK"
String get okLabel;
/// The label for a link disabled error
String get linkDisabledError;
/// The additional info on a link disabled error
String get linkDisabledDetails;
/// The label for "add more files"
String get addMoreFilesLabel;
@@ -377,6 +386,10 @@ class DefaultTranslations implements Translations {
return 'Pinned by ${pinnedBy.name}';
}
@override
String get sendMessagePermissionError =>
'You don\'t have permission to send messages';
@override
String get emptyMessagesText => 'There are no messages currently';
@@ -685,4 +698,11 @@ class DefaultTranslations implements Translations {
@override
String attachmentLimitExceedError(int limit) => """
Attachment limit exceeded: it's not possible to add more than $limit attachments""";
@override
String get linkDisabledDetails =>
'Sending links is not allowed in this conversation.';
@override
String get linkDisabledError => 'Links are disabled';
}
@@ -11,17 +11,17 @@ class MessageActionsModal extends StatefulWidget {
required this.message,
required this.messageWidget,
required this.messageTheme,
this.showReactions = true,
this.showDeleteMessage = true,
this.showEditMessage = true,
this.showReactions,
this.showDeleteMessage,
this.showEditMessage,
this.onReplyTap,
this.onThreadReplyTap,
this.showCopyMessage = true,
this.showReplyMessage = true,
this.showResendMessage = true,
this.showThreadReplyMessage = true,
this.showFlagButton = true,
this.showPinButton = true,
this.showThreadReplyMessage,
this.showFlagButton,
this.showPinButton,
this.editMessageInputBuilder,
this.reverse = false,
this.customActions = const [],
@@ -47,34 +47,34 @@ class MessageActionsModal extends StatefulWidget {
final MessageThemeData messageTheme;
/// Flag for showing reactions
final bool showReactions;
final bool? showReactions;
/// Callback when copy is tapped
final OnMessageTap? onCopyTap;
/// Callback when delete is tapped
final bool showDeleteMessage;
final bool? showDeleteMessage;
/// Flag for showing copy action
final bool showCopyMessage;
/// Flag for showing edit action
final bool showEditMessage;
final bool? showEditMessage;
/// Flag for showing resend action
final bool showResendMessage;
/// Flag for showing reply action
final bool showReplyMessage;
final bool? showReplyMessage;
/// Flag for showing thread reply action
final bool showThreadReplyMessage;
final bool? showThreadReplyMessage;
/// Flag for showing flag action
final bool showFlagButton;
final bool? showFlagButton;
/// Flag for showing pin action
final bool showPinButton;
final bool? showPinButton;
/// Flag for reversing message
final bool reverse;
@@ -88,6 +88,8 @@ class MessageActionsModal extends StatefulWidget {
class _MessageActionsModalState extends State<MessageActionsModal> {
bool _showActions = true;
late List<String> _userPermissions;
late bool _isMyMessage;
@override
Widget build(BuildContext context) => _showMessageOptionsModal();
@@ -122,6 +124,19 @@ class _MessageActionsModalState extends State<MessageActionsModal> {
final shiftFactor =
numberOfReactions < 5 ? (5 - numberOfReactions) * 0.1 : 0.0;
final hasEditPermission = _userPermissions.contains(
PermissionType.updateAnyMessage,
) ||
_userPermissions.contains(PermissionType.updateOwnMessage);
final hasDeletePermission = _userPermissions.contains(
PermissionType.deleteAnyMessage,
) ||
_userPermissions.contains(PermissionType.deleteOwnMessage);
final hasReactionPermission =
_userPermissions.contains(PermissionType.sendReaction);
final child = Center(
child: SingleChildScrollView(
child: Padding(
@@ -131,7 +146,7 @@ class _MessageActionsModalState extends State<MessageActionsModal> {
? CrossAxisAlignment.end
: CrossAxisAlignment.start,
children: <Widget>[
if (widget.showReactions &&
if ((widget.showReactions ?? hasReactionPermission) &&
(widget.message.status == MessageSendingStatus.sent))
Align(
alignment: Alignment(
@@ -168,21 +183,35 @@ class _MessageActionsModalState extends State<MessageActionsModal> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (widget.showReplyMessage &&
widget.message.status == MessageSendingStatus.sent)
if (widget.showReplyMessage ??
(_userPermissions
.contains(PermissionType.quoteMessage) &&
widget.message.status ==
MessageSendingStatus.sent))
_buildReplyButton(context),
if (widget.showThreadReplyMessage &&
(widget.message.status ==
MessageSendingStatus.sent) &&
widget.message.parentId == null)
if (widget.showThreadReplyMessage ??
_userPermissions
.contains(PermissionType.sendReply) &&
(widget.message.status ==
MessageSendingStatus.sent) &&
widget.message.parentId == null)
_buildThreadReplyButton(context),
if (widget.showResendMessage)
_buildResendMessage(context),
if (widget.showEditMessage) _buildEditMessage(context),
if (widget.showEditMessage ??
_isMyMessage && hasEditPermission)
_buildEditMessage(context),
if (widget.showCopyMessage) _buildCopyButton(context),
if (widget.showFlagButton) _buildFlagButton(context),
if (widget.showPinButton) _buildPinButton(context),
if (widget.showDeleteMessage)
if (widget.showFlagButton ??
_userPermissions
.contains(PermissionType.flagMessage))
_buildFlagButton(context),
if (widget.showPinButton ??
_userPermissions
.contains(PermissionType.pinMessage))
_buildPinButton(context),
if (widget.showDeleteMessage ??
(_isMyMessage && hasDeletePermission))
_buildDeleteButton(context),
...widget.customActions
.map((action) => _buildCustomAction(
@@ -603,7 +632,9 @@ class _MessageActionsModalState extends State<MessageActionsModal> {
widget.editMessageInputBuilder!(context, widget.message)
else
MessageInput(
editMessage: widget.message,
messageInputController: MessageInputController(
message: widget.message,
),
preMessageSending: (m) {
FocusScope.of(context).unfocus();
Navigator.pop(context);
@@ -643,4 +674,13 @@ class _MessageActionsModalState extends State<MessageActionsModal> {
),
);
}
@override
void didChangeDependencies() {
final newStreamChannel = StreamChannel.of(context);
_userPermissions = newStreamChannel.channel.ownCapabilities;
_isMyMessage =
widget.message.user?.id == StreamChat.of(context).currentUser?.id;
super.didChangeDependencies();
}
}
@@ -0,0 +1,32 @@
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
/// Button for showing visual component of slow mode.
class CountdownButton extends StatelessWidget {
/// Constructor for creating [CountdownButton].
const CountdownButton({
Key? key,
required this.count,
}) : super(key: key);
/// Count of time remaining to show to the user.
final int count;
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.all(8),
child: DecoratedBox(
decoration: BoxDecoration(
color: StreamChatTheme.of(context).colorTheme.disabled,
shape: BoxShape.circle,
),
child: SizedBox(
height: 24,
width: 24,
child: Center(
child: Text('$count'),
),
),
),
);
}
@@ -0,0 +1,307 @@
import 'dart:convert';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
/// A value listenable builder related to a [Message].
///
/// Pass in a [MessageInputController] as the `valueListenable`.
typedef MessageValueListenableBuilder = ValueListenableBuilder<Message>;
/// Controller for storing and mutating a [Message] value.
class MessageInputController extends ValueNotifier<Message> {
/// Creates a controller for an editable text field.
///
/// This constructor treats a null [message] argument as if it were the empty
/// message.
factory MessageInputController({
Message? message,
Map<RegExp, TextStyleBuilder>? textPatternStyle,
}) =>
MessageInputController._(
initialMessage: message ?? Message(),
textPatternStyle: textPatternStyle,
);
/// Creates a controller for an editable text field from an initial [text].
factory MessageInputController.fromText(
String? text, {
Map<RegExp, TextStyleBuilder>? textPatternStyle,
}) =>
MessageInputController._(
initialMessage: Message(text: text),
textPatternStyle: textPatternStyle,
);
/// Creates a controller for an editable text field from initial
/// [attachments].
factory MessageInputController.fromAttachments(
List<Attachment> attachments, {
Map<RegExp, TextStyleBuilder>? textPatternStyle,
}) =>
MessageInputController._(
initialMessage: Message(attachments: attachments),
textPatternStyle: textPatternStyle,
);
MessageInputController._({
required Message initialMessage,
Map<RegExp, TextStyleBuilder>? textPatternStyle,
}) : _textEditingController = MessageTextFieldController.fromValue(
initialMessage.text == null
? const TextEditingValue()
: TextEditingValue(
text: initialMessage.text!,
composing: TextRange.collapsed(initialMessage.text!.length),
),
textPatternStyle: textPatternStyle,
),
_initialMessage = initialMessage,
super(initialMessage) {
addListener(_textEditingSyncer);
}
void _textEditingSyncer() {
final cleanText = value.command == null
? value.text
: value.text?.replaceFirst('/${value.command} ', '');
if (cleanText != _textEditingController.text) {
final previousOffset = _textEditingController.value.selection.start;
final previousText = _textEditingController.text;
final diff = (cleanText?.length ?? 0) - previousText.length;
_textEditingController
..text = cleanText ?? ''
..selection = TextSelection.collapsed(
offset: previousOffset + diff,
);
}
}
/// Returns the current message associated with this controller.
Message get message => value;
/// Returns the controller of the text field linked to this controller.
MessageTextFieldController get textEditingController =>
_textEditingController;
final MessageTextFieldController _textEditingController;
/// Returns the text of the message.
String get text => _textEditingController.text;
Message _initialMessage;
/// Sets the message.
set message(Message message) {
value = message;
}
/// Sets the message that's being quoted.
set quotedMessage(Message message) {
value = value.copyWith(
quotedMessage: message,
quotedMessageId: message.id,
);
}
/// Clears the quoted message.
void clearQuotedMessage() {
value = value.copyWith(
quotedMessageId: null,
quotedMessage: null,
);
}
/// Sets a command for the message.
set command(Command command) {
value = value.copyWith(
command: command.name,
text: '/${command.name} ',
);
}
/// Sets the text of the message.
set text(String newText) {
var newTextWithCommand = newText;
if (value.command != null) {
if (!newText.startsWith('/${value.command}')) {
newTextWithCommand = '/${value.command} $newText';
}
}
value = value.copyWith(text: newTextWithCommand);
}
/// Returns the baseOffset of the text field.
int get baseOffset => textEditingController.selection.baseOffset;
/// Returns the start of the selection of the text field.
int get selectionStart => textEditingController.selection.start;
/// Sets the [showInChannel] flag of the message.
set showInChannel(bool newValue) {
value = value.copyWith(showInChannel: newValue);
}
/// Returns true if the message is in a thread and
/// should be shown in the main channel as well.
bool get showInChannel => value.showInChannel ?? false;
/// Returns the attachments of the message.
List<Attachment> get attachments => value.attachments;
/// Sets the list of [attachments] for the message.
set attachments(List<Attachment> attachments) {
value = value.copyWith(attachments: attachments);
}
/// Adds a new attachment to the message.
void addAttachment(Attachment attachment) {
attachments = [...attachments, attachment];
}
/// Adds a new attachment at the specified [index].
void addAttachmentAt(int index, Attachment attachment) {
attachments = [...attachments]..insert(index, attachment);
}
/// Removes the specified [attachment] from the message.
void removeAttachment(Attachment attachment) {
attachments = [...attachments]..remove(attachment);
}
/// Remove the attachment with the given [attachmentId].
void removeAttachmentById(String attachmentId) {
attachments = [...attachments]..removeWhere((it) => it.id == attachmentId);
}
/// Removes the attachment at the given [index].
void removeAttachmentAt(int index) {
attachments = [...attachments]..removeAt(index);
}
/// Clears the message attachments.
void clearAttachments() {
attachments = [];
}
// Only used to store the value locally in order to remove it if we call
// [clearOGAttachment] or [setOGAttachment] again.
Attachment? _ogAttachment;
/// Returns the og attachment of the message if set
Attachment? get ogAttachment =>
attachments.firstWhereOrNull((it) => it.id == _ogAttachment?.id);
/// Sets the og attachment in the message.
void setOGAttachment(Attachment attachment) {
attachments = [...attachments]
..remove(_ogAttachment)
..insert(0, attachment);
_ogAttachment = attachment;
}
/// Removes the og attachment.
void clearOGAttachment() {
if (_ogAttachment != null) {
removeAttachment(_ogAttachment!);
}
_ogAttachment = null;
}
/// Returns the list of mentioned users in the message.
List<User> get mentionedUsers => value.mentionedUsers;
/// Sets the mentioned users.
set mentionedUsers(List<User> users) {
value = value.copyWith(mentionedUsers: users);
}
/// Adds a user to the list of mentioned users.
void addMentionedUser(User user) {
mentionedUsers = [...mentionedUsers, user];
}
/// Removes the specified [user] from the mentioned users list.
void removeMentionedUser(User user) {
mentionedUsers = [...mentionedUsers]..remove(user);
}
/// Removes the mentioned user with the given [userId].
void removeMentionedUserById(String userId) {
mentionedUsers = [...mentionedUsers]..removeWhere((it) => it.id == userId);
}
/// Removes all mentioned users from the message.
void clearMentionedUsers() {
mentionedUsers = [];
}
/// Sets the [message], or [value], to empty.
///
/// After calling this function, [text], [attachments] and [mentionedUsers]
/// will all be empty.
///
/// Calling this will notify all the listeners of this
/// [MessageInputController] that they need to update
/// (calls [notifyListeners]). For this reason,
/// this method should only be called between frames, e.g. in response to user
/// actions, not during the build, layout, or paint phases.
void clear() {
value = Message();
_textEditingController.clear();
}
/// Sets the [value] to the initial [Message] value.
void reset({bool resetId = true}) {
if (resetId) {
final newId = const Uuid().v4();
_initialMessage = _initialMessage.copyWith(id: newId);
}
value = _initialMessage;
}
@override
void dispose() {
removeListener(_textEditingSyncer);
_textEditingController.dispose();
super.dispose();
}
}
/// A [RestorableProperty] that knows how to store and restore a
/// [MessageInputController].
///
/// The [MessageInputController] is accessible via the [value] getter. During
/// state restoration, the property will restore [MessageInputController.value]
/// to the value it had when the restoration data it is getting restored from
/// was collected.
class RestorableMessageInputController
extends RestorableChangeNotifier<MessageInputController> {
/// Creates a [RestorableMessageInputController].
///
/// This constructor creates a default [Message] when no `message` argument
/// is supplied.
RestorableMessageInputController({Message? message})
: _initialValue = message ?? Message();
/// Creates a [RestorableMessageInputController] from an initial
/// [text] value.
factory RestorableMessageInputController.fromText(String? text) =>
RestorableMessageInputController(message: Message(text: text));
final Message _initialValue;
@override
MessageInputController createDefaultValue() =>
MessageInputController(message: _initialValue);
@override
MessageInputController fromPrimitives(Object? data) {
final message = Message.fromJson(json.decode(data! as String));
return MessageInputController(message: message);
}
@override
String toPrimitives() => json.encode(value.value);
}
@@ -0,0 +1,97 @@
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/src/message_input/tld.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
/// A function that takes a [BuildContext] and returns a [TextStyle].
typedef TextStyleBuilder = TextStyle? Function(
BuildContext context,
String text,
);
/// Controller for the [StreamTextField] widget.
class MessageTextFieldController extends TextEditingController {
/// Returns a new MessageTextFieldController
MessageTextFieldController({
String? text,
this.textPatternStyle,
}) : super(text: text);
/// Returns a new MessageTextFieldController with the given text [value].
MessageTextFieldController.fromValue(
TextEditingValue? value, {
this.textPatternStyle,
}) : super.fromValue(value);
/// A map of style to apply to the text matching the RegExp patterns.
final Map<RegExp, TextStyleBuilder>? textPatternStyle;
/// Builds a [TextSpan] from the current text,
/// highlighting the matches for [textPatternStyle].
@override
TextSpan buildTextSpan({
required BuildContext context,
TextStyle? style,
required bool withComposing,
}) {
final pattern = textPatternStyle ??
{
RegExp(r'(?:(?:https?|ftp):\/\/)?[\w/\-?=%.]+\.[\w/\-?=%.]+'):
(context, text) {
if (!text.split('.').last.isValidTLD()) return null;
return TextStyle(
color: MessageInputTheme.of(context).linkHighlightColor,
);
},
};
if (pattern.isEmpty) {
return super.buildTextSpan(
context: context,
style: style,
withComposing: withComposing,
);
}
return TextSpan(text: text, style: style).splitMapJoin(
RegExp(pattern.keys.map((it) => it.pattern).join('|')),
onMatch: (match) {
final text = match[0]!;
final key = pattern.keys.firstWhere((it) => it.hasMatch(text));
return TextSpan(
text: text,
style: pattern[key]?.call(
context,
text,
),
);
},
);
}
}
extension _TextSpanX on TextSpan {
TextSpan splitMapJoin(
Pattern pattern, {
TextSpan Function(Match)? onMatch,
TextSpan Function(TextSpan)? onNonMatch,
}) {
final children = <TextSpan>[];
toPlainText().splitMapJoin(
pattern,
onMatch: (match) {
final span = TextSpan(text: match.group(0), style: style);
final updated = onMatch?.call(match);
children.add(updated ?? span);
return span.toPlainText();
},
onNonMatch: (text) {
final span = TextSpan(text: text, style: style);
final updatedSpan = onNonMatch?.call(span);
children.add(updatedSpan ?? span);
return span.toPlainText();
},
);
return TextSpan(style: style, children: children);
}
}
@@ -0,0 +1,31 @@
import 'package:flutter/material.dart';
/// A [SafeArea] with an enabled toggle
class SimpleSafeArea extends StatefulWidget {
/// Constructor for [SimpleSafeArea]
const SimpleSafeArea({
Key? key,
this.enabled = true,
required this.child,
}) : super(key: key);
/// Wrap [child] with [SafeArea]
final bool enabled;
/// Child widget to wrap
final Widget child;
@override
_SimpleSafeAreaState createState() => _SimpleSafeAreaState();
}
class _SimpleSafeAreaState extends State<SimpleSafeArea> {
@override
Widget build(BuildContext context) => SafeArea(
left: widget.enabled,
top: widget.enabled,
right: widget.enabled,
bottom: widget.enabled,
child: widget.child,
);
}
@@ -0,0 +1,575 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:stream_chat_flutter/src/extension.dart';
import 'package:stream_chat_flutter/src/media_list_view.dart';
import 'package:stream_chat_flutter/src/video_service.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
import 'package:video_compress/video_compress.dart';
/// Callback for when a file has to be picked.
typedef FilePickerCallback = void Function(
DefaultAttachmentTypes fileType, {
bool camera,
});
/// Callback for building an icon for a custom attachment type.
typedef CustomAttachmentIconBuilder = Widget Function(
BuildContext context,
bool active,
);
/// A widget that allows to pick an attachment.
class StreamAttachmentPicker extends StatefulWidget {
/// Default constructor for [StreamAttachmentPicker] which creates the Stream
/// attachment picker widget.
const StreamAttachmentPicker({
Key? key,
required this.messageInputController,
required this.onFilePicked,
this.isOpen = false,
this.pickerSize = 360.0,
this.attachmentLimit = 10,
this.onAttachmentLimitExceeded,
this.maxAttachmentSize = 20971520,
this.compressedVideoQuality = VideoQuality.DefaultQuality,
this.compressedVideoFrameRate = 30,
this.onError,
this.allowedAttachmentTypes = const [
DefaultAttachmentTypes.image,
DefaultAttachmentTypes.file,
DefaultAttachmentTypes.video,
],
this.customAttachmentTypes = const [],
}) : super(key: key);
/// True if the picker is open.
final bool isOpen;
/// The picker size in height.
final double pickerSize;
/// The [MessageInputController] linked to this picker.
final MessageInputController messageInputController;
/// The limit of attachments that can be picked.
final int attachmentLimit;
/// The callback for when the attachment limit is exceeded.
final AttachmentLimitExceedListener? onAttachmentLimitExceeded;
/// Callback for when an error occurs in the attachment picker.
final ValueChanged<String>? onError;
/// Callback for when file is picked.
final FilePickerCallback onFilePicked;
/// Video quality to use when compressing the videos.
final VideoQuality compressedVideoQuality;
/// Frame rate to use when compressing the videos.
final int compressedVideoFrameRate;
/// Max attachment size in bytes:
/// - Defaults to 20 MB
/// - Do not set it if you're using our default CDN
final int maxAttachmentSize;
/// The list of attachment types that can be picked.
final List<DefaultAttachmentTypes> allowedAttachmentTypes;
/// The list of custom attachment types that can be picked.
final List<CustomAttachmentType> customAttachmentTypes;
/// Used to create a new copy of [StreamAttachmentPicker] with modified
/// properties.
StreamAttachmentPicker copyWith({
Key? key,
MessageInputController? messageInputController,
FilePickerCallback? onFilePicked,
bool? isOpen,
double? pickerSize,
int? attachmentLimit,
AttachmentLimitExceedListener? onAttachmentLimitExceeded,
int? maxAttachmentSize,
VideoQuality? compressedVideoQuality,
int? compressedVideoFrameRate,
ValueChanged<bool>? onChangeInputState,
ValueChanged<String>? onError,
List<DefaultAttachmentTypes>? allowedAttachmentTypes,
List<CustomAttachmentType>? customAttachmentTypes = const [],
}) =>
StreamAttachmentPicker(
key: key ?? this.key,
messageInputController:
messageInputController ?? this.messageInputController,
onFilePicked: onFilePicked ?? this.onFilePicked,
isOpen: isOpen ?? this.isOpen,
pickerSize: pickerSize ?? this.pickerSize,
attachmentLimit: attachmentLimit ?? this.attachmentLimit,
onAttachmentLimitExceeded:
onAttachmentLimitExceeded ?? this.onAttachmentLimitExceeded,
maxAttachmentSize: maxAttachmentSize ?? this.maxAttachmentSize,
compressedVideoQuality:
compressedVideoQuality ?? this.compressedVideoQuality,
compressedVideoFrameRate:
compressedVideoFrameRate ?? this.compressedVideoFrameRate,
onError: onError ?? this.onError,
allowedAttachmentTypes:
allowedAttachmentTypes ?? this.allowedAttachmentTypes,
customAttachmentTypes:
customAttachmentTypes ?? this.customAttachmentTypes,
);
@override
State<StreamAttachmentPicker> createState() => _StreamAttachmentPickerState();
}
class _StreamAttachmentPickerState extends State<StreamAttachmentPicker> {
int _filePickerIndex = 0;
@override
Widget build(BuildContext context) {
final _streamChatTheme = StreamChatTheme.of(context);
final messageInputController = widget.messageInputController;
final _attachmentContainsImage =
messageInputController.attachments.any((it) => it.type == 'image');
final _attachmentContainsFile =
messageInputController.attachments.any((it) => it.type == 'file');
final _attachmentContainsVideo =
messageInputController.attachments.any((it) => it.type == 'video');
final attachmentLimitCrossed =
messageInputController.attachments.length >= widget.attachmentLimit;
Color _getIconColor(int index) {
final streamChatThemeData = _streamChatTheme;
switch (index) {
case 0:
return _filePickerIndex == 0 || _attachmentContainsImage
? streamChatThemeData.colorTheme.accentPrimary
: (_attachmentContainsImage
? streamChatThemeData.colorTheme.accentPrimary
: streamChatThemeData.colorTheme.textHighEmphasis.withOpacity(
messageInputController.attachments.isEmpty ? 0.5 : 0.2,
));
case 1:
return _attachmentContainsFile
? streamChatThemeData.colorTheme.accentPrimary
: (messageInputController.attachments.isEmpty
? streamChatThemeData.colorTheme.textHighEmphasis
.withOpacity(0.5)
: streamChatThemeData.colorTheme.textHighEmphasis
.withOpacity(0.2));
case 2:
return widget.messageInputController.attachments.isNotEmpty &&
(!_attachmentContainsImage || attachmentLimitCrossed)
? streamChatThemeData.colorTheme.textHighEmphasis.withOpacity(0.2)
: _attachmentContainsFile &&
messageInputController.attachments.isNotEmpty
? streamChatThemeData.colorTheme.textHighEmphasis
.withOpacity(0.2)
: streamChatThemeData.colorTheme.textHighEmphasis
.withOpacity(0.5);
case 3:
return widget.messageInputController.attachments.isNotEmpty &&
(!_attachmentContainsVideo || attachmentLimitCrossed)
? streamChatThemeData.colorTheme.textHighEmphasis.withOpacity(0.2)
: _attachmentContainsFile &&
messageInputController.attachments.isNotEmpty
? streamChatThemeData.colorTheme.textHighEmphasis
.withOpacity(0.2)
: streamChatThemeData.colorTheme.textHighEmphasis
.withOpacity(0.5);
default:
return Colors.black;
}
}
return AnimatedContainer(
duration:
widget.isOpen ? const Duration(milliseconds: 300) : const Duration(),
curve: Curves.easeOut,
height: widget.isOpen ? widget.pickerSize : 0,
child: SingleChildScrollView(
child: SizedBox(
height: widget.pickerSize,
child: Material(
color: _streamChatTheme.colorTheme.inputBg,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
if (widget.allowedAttachmentTypes
.contains(DefaultAttachmentTypes.image))
IconButton(
icon: StreamSvgIcon.pictures(
color: _getIconColor(0),
),
onPressed:
messageInputController.attachments.isNotEmpty &&
!_attachmentContainsImage
? null
: () {
setState(() {
_filePickerIndex = 0;
});
},
),
if (widget.allowedAttachmentTypes
.contains(DefaultAttachmentTypes.file))
IconButton(
iconSize: 32,
icon: StreamSvgIcon.files(
color: _getIconColor(1),
),
onPressed: messageInputController
.attachments.isNotEmpty &&
!_attachmentContainsFile
? null
: () {
widget
.onFilePicked(DefaultAttachmentTypes.file);
},
),
if (widget.allowedAttachmentTypes
.contains(DefaultAttachmentTypes.image))
IconButton(
icon: StreamSvgIcon.camera(
color: _getIconColor(2),
),
onPressed: attachmentLimitCrossed ||
(messageInputController
.attachments.isNotEmpty &&
!_attachmentContainsVideo)
? null
: () {
widget.onFilePicked(
DefaultAttachmentTypes.image,
camera: true,
);
},
),
if (widget.allowedAttachmentTypes
.contains(DefaultAttachmentTypes.video))
IconButton(
padding: const EdgeInsets.all(0),
icon: StreamSvgIcon.record(
color: _getIconColor(3),
),
onPressed: attachmentLimitCrossed ||
(messageInputController
.attachments.isNotEmpty &&
!_attachmentContainsVideo)
? null
: () {
widget.onFilePicked(
DefaultAttachmentTypes.video,
camera: true,
);
},
),
for (int i = 0;
i < widget.customAttachmentTypes.length;
i++)
IconButton(
onPressed: () {
if (messageInputController.attachments.isNotEmpty) {
if (!messageInputController.attachments.any((e) =>
e.type ==
widget.customAttachmentTypes[i].type)) {
return;
}
}
setState(() {
_filePickerIndex = i + 1;
});
},
icon: widget.customAttachmentTypes[i]
.iconBuilder(context, _filePickerIndex == i + 1),
),
],
),
DecoratedBox(
decoration: BoxDecoration(
color: _streamChatTheme.colorTheme.barsBg,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
),
),
child: Center(
child: Padding(
padding: const EdgeInsets.all(8),
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: _streamChatTheme.colorTheme.inputBg,
borderRadius: BorderRadius.circular(4),
),
),
),
),
),
if (widget.isOpen &&
(widget.allowedAttachmentTypes
.contains(DefaultAttachmentTypes.image) ||
(widget.allowedAttachmentTypes
.contains(DefaultAttachmentTypes.file))))
Expanded(
child: DecoratedBox(
decoration: BoxDecoration(
color: _streamChatTheme.colorTheme.barsBg,
borderRadius: BorderRadius.circular(8),
),
child: _PickerWidget(
filePickerIndex: _filePickerIndex,
streamChatTheme: _streamChatTheme,
containsFile: _attachmentContainsFile,
selectedMedias: messageInputController.attachments
.map((e) => e.id)
.toList(),
onAddMoreFilesClick: widget.onFilePicked,
onMediaSelected: (media) {
if (messageInputController.attachments
.any((e) => e.id == media.id)) {
messageInputController
.removeAttachmentById(media.id);
} else {
_addAssetAttachment(media);
}
},
allowedAttachmentTypes: widget.allowedAttachmentTypes,
customAttachmentTypes: widget.customAttachmentTypes,
),
),
),
],
),
),
),
),
);
}
void _addAssetAttachment(AssetEntity medium) async {
final mediaFile = await medium.originFile.timeout(
const Duration(seconds: 5),
onTimeout: () => medium.originFile,
);
if (mediaFile == null) return;
var file = AttachmentFile(
path: mediaFile.path,
size: await mediaFile.length(),
bytes: mediaFile.readAsBytesSync(),
);
if (file.size! > widget.maxAttachmentSize) {
if (medium.type == AssetType.video && file.path != null) {
final mediaInfo = await (VideoService.compressVideo(
file.path!,
frameRate: widget.compressedVideoFrameRate,
quality: widget.compressedVideoQuality,
) as FutureOr<MediaInfo>);
if (mediaInfo.filesize! > widget.maxAttachmentSize) {
widget.onError?.call(
context.translations.fileTooLargeAfterCompressionError(
widget.maxAttachmentSize / (1024 * 1024),
),
);
return;
}
file = AttachmentFile(
name: file.name,
size: mediaInfo.filesize,
bytes: await mediaInfo.file?.readAsBytes(),
path: mediaInfo.path,
);
} else {
widget.onError?.call(context.translations.fileTooLargeError(
widget.maxAttachmentSize / (1024 * 1024),
));
return;
}
}
setState(() {
final attachment = Attachment(
id: medium.id,
file: file,
type: medium.type == AssetType.image ? 'image' : 'video',
);
_addAttachments([attachment]);
});
}
/// Adds an attachment to the [messageInputController.attachments] map
void _addAttachments(Iterable<Attachment> attachments) {
final limit = widget.attachmentLimit;
final length =
widget.messageInputController.attachments.length + attachments.length;
if (length > limit) {
final onAttachmentLimitExceed = widget.onAttachmentLimitExceeded;
if (onAttachmentLimitExceed != null) {
return onAttachmentLimitExceed(
widget.attachmentLimit,
context.translations.attachmentLimitExceedError(limit),
);
}
return widget.onError?.call(
context.translations.attachmentLimitExceedError(limit),
);
}
for (final attachment in attachments) {
widget.messageInputController.addAttachment(attachment);
}
}
}
class _PickerWidget extends StatefulWidget {
const _PickerWidget({
Key? key,
required this.filePickerIndex,
required this.containsFile,
required this.selectedMedias,
required this.onAddMoreFilesClick,
required this.onMediaSelected,
required this.streamChatTheme,
required this.allowedAttachmentTypes,
required this.customAttachmentTypes,
}) : super(key: key);
final int filePickerIndex;
final bool containsFile;
final List<String> selectedMedias;
final void Function(DefaultAttachmentTypes) onAddMoreFilesClick;
final void Function(AssetEntity) onMediaSelected;
final StreamChatThemeData streamChatTheme;
final List<DefaultAttachmentTypes> allowedAttachmentTypes;
final List<CustomAttachmentType> customAttachmentTypes;
@override
_PickerWidgetState createState() => _PickerWidgetState();
}
class _PickerWidgetState extends State<_PickerWidget> {
Future<bool>? requestPermission;
@override
void initState() {
super.initState();
requestPermission = PhotoManager.requestPermission();
}
@override
Widget build(BuildContext context) {
if (widget.filePickerIndex != 0) {
return widget.customAttachmentTypes[widget.filePickerIndex - 1]
.pickerBuilder(context);
}
return FutureBuilder<bool>(
future: requestPermission,
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const Offstage();
}
if (snapshot.data!) {
if (widget.containsFile ||
!widget.allowedAttachmentTypes
.contains(DefaultAttachmentTypes.image)) {
return GestureDetector(
onTap: () {
widget.onAddMoreFilesClick(DefaultAttachmentTypes.file);
},
child: Container(
constraints: const BoxConstraints.expand(),
color: widget.streamChatTheme.colorTheme.inputBg,
alignment: Alignment.center,
child: Text(
context.translations.addMoreFilesLabel,
style: TextStyle(
color: widget.streamChatTheme.colorTheme.accentPrimary,
fontWeight: FontWeight.bold,
),
),
),
);
}
return MediaListView(
selectedIds: widget.selectedMedias,
onSelect: widget.onMediaSelected,
);
}
return InkWell(
onTap: () async {
PhotoManager.openSetting();
},
child: Container(
color: widget.streamChatTheme.colorTheme.inputBg,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SvgPicture.asset(
'svgs/icon_picture_empty_state.svg',
package: 'stream_chat_flutter',
height: 140,
color: widget.streamChatTheme.colorTheme.disabled,
),
Text(
context.translations.enablePhotoAndVideoAccessMessage,
style: widget.streamChatTheme.textTheme.body.copyWith(
color: widget.streamChatTheme.colorTheme.textLowEmphasis,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 6),
Center(
child: Text(
context.translations.allowGalleryAccessMessage,
style: widget.streamChatTheme.textTheme.bodyBold.copyWith(
color: widget.streamChatTheme.colorTheme.accentPrimary,
),
),
),
],
),
),
);
},
);
}
}
/// Class which holds data for a custom attachment type in the attachment picker
class CustomAttachmentType {
/// Default constructor for creating a custom attachment for the attachment
/// picker.
CustomAttachmentType({
required this.type,
required this.iconBuilder,
required this.pickerBuilder,
});
/// Type name.
String type;
/// Builds the icon in the attachment picker top row.
CustomAttachmentIconBuilder iconBuilder;
/// Builds content in the attachment builder when icon is selected.
WidgetBuilder pickerBuilder;
}
@@ -0,0 +1,115 @@
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
/// A widget that displays a sending button.
class StreamMessageSendButton extends StatelessWidget {
/// Returns a [StreamMessageSendButton] with the given [timeOut], [isIdle],
/// [isCommandEnabled], [isEditEnabled], [idleSendButton], [activeSendButton],
/// [onSendMessage].
const StreamMessageSendButton({
Key? key,
this.timeOut = 0,
this.isIdle = true,
this.isCommandEnabled = false,
this.isEditEnabled = false,
this.idleSendButton,
this.activeSendButton,
required this.onSendMessage,
}) : super(key: key);
/// Time out related to slow mode.
final int timeOut;
/// If true the button will be disabled.
final bool isIdle;
/// True if a command is being sent.
final bool isCommandEnabled;
/// True if in editing mode.
final bool isEditEnabled;
/// The widget to display when the button is disabled.
final Widget? idleSendButton;
/// The widget to display when the button is enabled.
final Widget? activeSendButton;
/// The callback to call when the button is pressed.
final VoidCallback onSendMessage;
@override
Widget build(BuildContext context) {
final _streamChatTheme = StreamChatTheme.of(context);
late Widget sendButton;
if (timeOut > 0) {
sendButton = CountdownButton(count: timeOut);
} else if (isIdle) {
sendButton = idleSendButton ?? _buildIdleSendButton(context);
} else {
sendButton = activeSendButton != null
? InkWell(
onTap: onSendMessage,
child: activeSendButton,
)
: _buildSendButton(context);
}
return AnimatedSwitcher(
duration: _streamChatTheme.messageInputTheme.sendAnimationDuration!,
child: sendButton,
);
}
Widget _buildIdleSendButton(BuildContext context) {
final _messageInputTheme = MessageInputTheme.of(context);
return Padding(
padding: const EdgeInsets.all(8),
child: StreamSvgIcon(
assetName: _getIdleSendIcon(),
color: _messageInputTheme.sendButtonIdleColor,
),
);
}
Widget _buildSendButton(BuildContext context) {
final _messageInputTheme = MessageInputTheme.of(context);
return Padding(
padding: const EdgeInsets.all(8),
child: IconButton(
onPressed: onSendMessage,
padding: const EdgeInsets.all(0),
splashRadius: 24,
constraints: const BoxConstraints.tightFor(
height: 24,
width: 24,
),
icon: StreamSvgIcon(
assetName: _getSendIcon(),
color: _messageInputTheme.sendButtonColor,
),
),
);
}
String _getIdleSendIcon() {
if (isCommandEnabled) {
return 'Icon_search.svg';
} else {
return 'Icon_circle_right.svg';
}
}
String _getSendIcon() {
if (isEditEnabled) {
return 'Icon_circle_up.svg';
} else if (isCommandEnabled) {
return 'Icon_search.svg';
} else {
return 'Icon_circle_up.svg';
}
}
}
@@ -0,0 +1,766 @@
// ignore_for_file: prefer-trailing-comma, cascade_invocations, lines_longer_than_80_chars
import 'dart:ui' as ui show BoxHeightStyle, BoxWidthStyle;
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
export 'package:flutter/services.dart'
show
TextInputType,
TextInputAction,
TextCapitalization,
SmartQuotesType,
SmartDashesType;
/// A widget the wraps the [TextField] and adds some StreamChat specifics.
class StreamMessageTextField extends StatefulWidget {
/// Creates a Material Design text field.
///
/// If [decoration] is non-null (which is the default), the text field
/// requires one of its ancestors to be a [Material] widget.
///
/// To remove the decoration entirely (including the extra padding introduced
/// by the decoration to save space for the labels), set the [decoration] to
/// null.
///
/// The [maxLines] property can be set to null to remove the restriction on
/// the number of lines. By default, it is one, meaning this is a single-line
/// text field. [maxLines] must not be zero.
///
/// The [maxLength] property is set to null by default, which means the
/// number of characters allowed in the text field is not restricted. If
/// [maxLength] is set a character counter will be displayed below the
/// field showing how many characters have been entered. If the value is
/// set to a positive integer it will also display the maximum allowed
/// number of characters to be entered. If the value is set to
/// [TextField.noMaxLength] then only the current length is displayed.
///
/// After [maxLength] characters have been input, additional input
/// is ignored, unless [maxLengthEnforcement] is set to
/// [MaxLengthEnforcement.none].
/// The text field enforces the length with a
/// [LengthLimitingTextInputFormatter],
/// which is evaluated after the supplied [inputFormatters], if any.
/// The [maxLength] value must be either null or greater than zero.
///
/// The text cursor is not shown if [showCursor] is false or if [showCursor]
/// is null (the default) and [readOnly] is true.
///
/// The [selectionHeightStyle] and [selectionWidthStyle] properties allow
/// changing the shape of the selection highlighting. These properties default
/// to [ui.BoxHeightStyle.tight] and [ui.BoxWidthStyle.tight] respectively and
/// must not be null.
///
/// The [textAlign], [autofocus], [obscureText], [readOnly], [autocorrect],
/// [scrollPadding], [maxLines], [maxLength],
/// [selectionHeightStyle], [selectionWidthStyle], [enableSuggestions], and
/// [enableIMEPersonalizedLearning] arguments must not be null.
///
/// See also:
///
/// * [maxLength], which discusses the precise meaning of "number of
/// characters" and how it may differ from the intuitive meaning.
const StreamMessageTextField({
Key? key,
this.controller,
this.focusNode,
this.decoration = const InputDecoration(),
TextInputType? keyboardType,
this.textInputAction,
this.textCapitalization = TextCapitalization.none,
this.style,
this.strutStyle,
this.textAlign = TextAlign.start,
this.textAlignVertical,
this.textDirection,
this.readOnly = false,
ToolbarOptions? toolbarOptions,
this.showCursor,
this.autofocus = false,
this.obscuringCharacter = '',
this.obscureText = false,
this.autocorrect = true,
SmartDashesType? smartDashesType,
SmartQuotesType? smartQuotesType,
this.enableSuggestions = true,
this.maxLines = 1,
this.minLines,
this.expands = false,
this.maxLength,
@Deprecated(
'Use maxLengthEnforcement parameter which provides more specific '
'behavior related to the maxLength limit. '
'This feature was deprecated after v1.25.0-5.0.pre.',
)
this.maxLengthEnforced = true,
this.maxLengthEnforcement,
this.onChanged,
this.onEditingComplete,
this.onSubmitted,
this.onAppPrivateCommand,
this.inputFormatters,
this.enabled,
this.cursorWidth = 2.0,
this.cursorHeight,
this.cursorRadius,
this.cursorColor,
this.selectionHeightStyle = ui.BoxHeightStyle.tight,
this.selectionWidthStyle = ui.BoxWidthStyle.tight,
this.keyboardAppearance,
this.scrollPadding = const EdgeInsets.all(20),
this.dragStartBehavior = DragStartBehavior.start,
this.enableInteractiveSelection = true,
this.selectionControls,
this.onTap,
this.mouseCursor,
this.buildCounter,
this.scrollController,
this.scrollPhysics,
this.autofillHints,
this.restorationId,
this.enableIMEPersonalizedLearning = true,
}) : assert(obscuringCharacter.length == 1,
'`obscuringCharacter.length` must be 1'),
smartDashesType = smartDashesType ??
(obscureText ? SmartDashesType.disabled : SmartDashesType.enabled),
smartQuotesType = smartQuotesType ??
(obscureText ? SmartQuotesType.disabled : SmartQuotesType.enabled),
assert(
maxLengthEnforced || maxLengthEnforcement == null,
'maxLengthEnforced is deprecated, use only maxLengthEnforcement',
),
assert(maxLines == null || maxLines > 0,
'`maxLines` needs to be left as null or bigger than 0'),
assert(minLines == null || minLines > 0,
'`minLines` needs to be left as null or bigger than 0'),
assert(
(maxLines == null) || (minLines == null) || (maxLines >= minLines),
"minLines can't be greater than maxLines",
),
assert(
!expands || (maxLines == null && minLines == null),
'minLines and maxLines must be null when expands is true.',
),
assert(!obscureText || maxLines == 1,
'Obscured fields cannot be multiline.'),
assert(
maxLength == null ||
maxLength == TextField.noMaxLength ||
maxLength > 0,
'`maxLength` needs to be null or a positive integer'),
// Assert the following instead of setting it directly to avoid
// surprising the user by silently changing the value they set.
assert(
!identical(textInputAction, TextInputAction.newline) ||
maxLines == 1 ||
!identical(keyboardType, TextInputType.text),
'''Use keyboardType TextInputType.multiline when using TextInputAction.newline on a multiline TextField.''',
),
keyboardType = keyboardType ??
(maxLines == 1 ? TextInputType.text : TextInputType.multiline),
toolbarOptions = toolbarOptions ??
(obscureText
? const ToolbarOptions(
selectAll: true,
paste: true,
)
: const ToolbarOptions(
copy: true,
cut: true,
selectAll: true,
paste: true,
)),
super(key: key);
/// Controls the message being edited.
///
/// If null, this widget will create its own [MessageInputController].
final MessageInputController? controller;
/// Defines the keyboard focus for this widget.
///
/// The [focusNode] is a long-lived object that's typically managed by a
/// [StatefulWidget] parent. See [FocusNode] for more information.
///
/// To give the keyboard focus to this widget, provide a [focusNode] and then
/// use the current [FocusScope] to request the focus:
///
/// ```dart
/// FocusScope.of(context).requestFocus(myFocusNode);
/// ```
///
/// This happens automatically when the widget is tapped.
///
/// To be notified when the widget gains or loses the focus, add a listener
/// to the [focusNode]:
///
/// ```dart
/// focusNode.addListener(() { print(myFocusNode.hasFocus); });
/// ```
///
/// If null, this widget will create its own [FocusNode].
///
/// ## Keyboard
///
/// Requesting the focus will typically cause the keyboard to be shown
/// if it's not showing already.
///
/// On Android, the user can hide the keyboard - without changing the focus -
/// with the system back button. They can restore the keyboard's visibility
/// by tapping on a text field. The user might hide the keyboard and
/// switch to a physical keyboard, or they might just need to get it
/// out of the way for a moment, to expose something it's
/// obscuring. In this case requesting the focus again will not
/// cause the focus to change, and will not make the keyboard visible.
///
/// This widget builds an [EditableText] and will ensure that the keyboard is
/// showing when it is tapped by calling
/// [EditableTextState.requestKeyboard()].
final FocusNode? focusNode;
/// The decoration to show around the text field.
///
/// By default, draws a horizontal line under the text field but can be
/// configured to show an icon, label, hint text, and error text.
///
/// Specify null to remove the decoration entirely (including the
/// extra padding introduced by the decoration to save space for the labels).
final InputDecoration? decoration;
/// {@macro flutter.widgets.editableText.keyboardType}
final TextInputType keyboardType;
/// The type of action button to use for the keyboard.
///
/// Defaults to [TextInputAction.newline] if [keyboardType] is
/// [TextInputType.multiline] and [TextInputAction.done] otherwise.
final TextInputAction? textInputAction;
/// {@macro flutter.widgets.editableText.textCapitalization}
final TextCapitalization textCapitalization;
/// The style to use for the text being edited.
///
/// This text style is also used as the base style for the [decoration].
///
/// If null, defaults to the `subtitle1` text style from the current [Theme].
final TextStyle? style;
/// {@macro flutter.widgets.editableText.strutStyle}
final StrutStyle? strutStyle;
/// {@macro flutter.widgets.editableText.textAlign}
final TextAlign textAlign;
/// {@macro flutter.material.InputDecorator.textAlignVertical}
final TextAlignVertical? textAlignVertical;
/// {@macro flutter.widgets.editableText.textDirection}
final TextDirection? textDirection;
/// {@macro flutter.widgets.editableText.autofocus}
final bool autofocus;
/// {@macro flutter.widgets.editableText.obscuringCharacter}
final String obscuringCharacter;
/// {@macro flutter.widgets.editableText.obscureText}
final bool obscureText;
/// {@macro flutter.widgets.editableText.autocorrect}
final bool autocorrect;
/// {@macro flutter.services.TextInputConfiguration.smartDashesType}
final SmartDashesType smartDashesType;
/// {@macro flutter.services.TextInputConfiguration.smartQuotesType}
final SmartQuotesType smartQuotesType;
/// {@macro flutter.services.TextInputConfiguration.enableSuggestions}
final bool enableSuggestions;
/// {@macro flutter.widgets.editableText.maxLines}
/// * [expands], which determines whether the field should fill the height of
/// its parent.
final int? maxLines;
/// {@macro flutter.widgets.editableText.minLines}
/// * [expands], which determines whether the field should fill the height of
/// its parent.
final int? minLines;
/// {@macro flutter.widgets.editableText.expands}
final bool expands;
/// {@macro flutter.widgets.editableText.readOnly}
final bool readOnly;
/// Configuration of toolbar options.
///
/// If not set, select all and paste will default to be enabled. Copy and cut
/// will be disabled if [obscureText] is true. If [readOnly] is true,
/// paste and cut will be disabled regardless.
final ToolbarOptions toolbarOptions;
/// {@macro flutter.widgets.editableText.showCursor}
final bool? showCursor;
/// If [maxLength] is set to this value, only the "current input length"
/// part of the character counter is shown.
static const int noMaxLength = -1;
/// The maximum number of characters (Unicode scalar values) to allow in the
/// text field.
///
/// If set, a character counter will be displayed below the
/// field showing how many characters have been entered. If set to a number
/// greater than 0, it will also display the maximum number allowed. If set
/// to [TextField.noMaxLength] then only the current character count is
/// displayed.
///
/// After [maxLength] characters have been input, additional input
/// is ignored, unless [maxLengthEnforcement] is set to
/// [MaxLengthEnforcement.none].
///
/// The text field enforces the length with a
/// [LengthLimitingTextInputFormatter], which is evaluated after the supplied
/// [inputFormatters], if any.
///
/// This value must be either null, [TextField.noMaxLength], or greater than
/// 0.
///
/// If null (the default) then there is no limit to the number of characters
/// that can be entered. If set to [TextField.noMaxLength], then no limit will
/// be enforced, but the number of characters entered will still be displayed.
///
/// Whitespace characters (e.g. newline, space, tab) are included in the
/// character count.
///
/// {@macro flutter.services.lengthLimitingTextInputFormatter.maxLength}
final int? maxLength;
/// If [maxLength] is set, [maxLengthEnforced] indicates whether or not to
/// enforce the limit, or merely provide a character counter and warning when
/// [maxLength] is exceeded.
///
/// If true, prevents the field from allowing more than [maxLength]
/// characters.
@Deprecated(
'Use maxLengthEnforcement parameter which provides more specific '
'behavior related to the maxLength limit. '
'This feature was deprecated after v1.25.0-5.0.pre.',
)
final bool maxLengthEnforced;
/// Determines how the [maxLength] limit should be enforced.
///
/// {@macro flutter.services.textFormatter.effectiveMaxLengthEnforcement}
///
/// {@macro flutter.services.textFormatter.maxLengthEnforcement}
final MaxLengthEnforcement? maxLengthEnforcement;
/// {@macro flutter.widgets.editableText.onChanged}
///
/// See also:
///
/// * [inputFormatters], which are called before [onChanged]
/// runs and can validate and change ("format") the input value.
/// * [onEditingComplete], [onSubmitted]:
/// which are more specialized input change notifications.
final ValueChanged<String>? onChanged;
/// {@macro flutter.widgets.editableText.onEditingComplete}
final VoidCallback? onEditingComplete;
/// {@macro flutter.widgets.editableText.onSubmitted}
///
/// See also:
///
/// * [TextInputAction.next] and [TextInputAction.previous], which
/// automatically shift the focus to the next/previous focusable item when
/// the user is done editing.
final ValueChanged<String>? onSubmitted;
/// {@macro flutter.widgets.editableText.onAppPrivateCommand}
final AppPrivateCommandCallback? onAppPrivateCommand;
/// {@macro flutter.widgets.editableText.inputFormatters}
final List<TextInputFormatter>? inputFormatters;
/// If false the text field is "disabled": it ignores taps and its
/// [decoration] is rendered in grey.
///
/// If non-null this property overrides the [decoration]'s
/// [InputDecoration.enabled] property.
final bool? enabled;
/// {@macro flutter.widgets.editableText.cursorWidth}
final double cursorWidth;
/// {@macro flutter.widgets.editableText.cursorHeight}
final double? cursorHeight;
/// {@macro flutter.widgets.editableText.cursorRadius}
final Radius? cursorRadius;
/// The color of the cursor.
///
/// The cursor indicates the current location of text insertion point in
/// the field.
///
/// If this is null it will default to the ambient
/// [TextSelectionThemeData.cursorColor]. If that is null, and the
/// [ThemeData.platform] is [TargetPlatform.iOS] or [TargetPlatform.macOS]
/// it will use [CupertinoThemeData.primaryColor]. Otherwise it will use
/// the value of [ColorScheme.primary] of [ThemeData.colorScheme].
final Color? cursorColor;
/// Controls how tall the selection highlight boxes are computed to be.
///
/// See [ui.BoxHeightStyle] for details on available styles.
final ui.BoxHeightStyle selectionHeightStyle;
/// Controls how wide the selection highlight boxes are computed to be.
///
/// See [ui.BoxWidthStyle] for details on available styles.
final ui.BoxWidthStyle selectionWidthStyle;
/// The appearance of the keyboard.
///
/// This setting is only honored on iOS devices.
///
/// If unset, defaults to the brightness of
/// [ThemeData.primaryColorBrightness].
final Brightness? keyboardAppearance;
/// {@macro flutter.widgets.editableText.scrollPadding}
final EdgeInsets scrollPadding;
/// {@macro flutter.widgets.editableText.enableInteractiveSelection}
final bool enableInteractiveSelection;
/// {@macro flutter.widgets.editableText.selectionControls}
final TextSelectionControls? selectionControls;
/// {@macro flutter.widgets.scrollable.dragStartBehavior}
final DragStartBehavior dragStartBehavior;
/// {@macro flutter.widgets.editableText.selectionEnabled}
bool get selectionEnabled => enableInteractiveSelection;
/// {@template flutter.material.textfield.onTap}
/// Called for each distinct tap except for every second tap of a double tap.
///
/// The text field builds a [GestureDetector] to handle input events like tap,
/// to trigger focus requests, to move the caret, adjust the selection, etc.
/// Handling some of those events by wrapping the text field with a competing
/// GestureDetector is problematic.
///
/// To unconditionally handle taps, without interfering with the text field's
/// internal gesture detector, provide this callback.
///
/// If the text field is created with [enabled] false, taps will not be
/// recognized.
///
/// To be notified when the text field gains or loses the focus, provide a
/// [focusNode] and add a listener to that.
///
/// To listen to arbitrary pointer events without competing with the
/// text field's internal gesture detector, use a [Listener].
/// {@endtemplate}
final GestureTapCallback? onTap;
/// The cursor for a mouse pointer when it enters or is hovering over the
/// widget.
///
/// If [mouseCursor] is a [MaterialStateProperty<MouseCursor>],
/// [MaterialStateProperty.resolve] is used for the following
/// [MaterialState]s:
///
/// * [MaterialState.error].
/// * [MaterialState.hovered].
/// * [MaterialState.focused].
/// * [MaterialState.disabled].
///
/// If this property is null, [MaterialStateMouseCursor.textable] will be
/// used.
///
/// The [mouseCursor] is the only property of [TextField] that controls the
/// appearance of the mouse pointer. All other properties related to "cursor"
/// stand for the text cursor, which is usually a blinking vertical line at
/// the editing position.
final MouseCursor? mouseCursor;
/// Callback that generates a custom [InputDecoration.counter] widget.
///
/// See [InputCounterWidgetBuilder] for an explanation of the passed in
/// arguments. The returned widget will be placed below the line in place of
/// the default widget built when [InputDecoration.counterText] is specified.
///
/// The returned widget will be wrapped in a [Semantics] widget for
/// accessibility, but it also needs to be accessible itself. For example,
/// if returning a Text widget, set the [Text.semanticsLabel] property.
///
/// {@tool snippet}
/// ```dart
/// Widget counter(
/// BuildContext context,
/// {
/// required int currentLength,
/// required int? maxLength,
/// required bool isFocused,
/// }
/// ) {
/// return Text(
/// '$currentLength of $maxLength characters',
/// semanticsLabel: 'character count',
/// );
/// }
/// ```
/// {@end-tool}
///
/// If buildCounter returns null, then no counter and no Semantics widget will
/// be created at all.
final InputCounterWidgetBuilder? buildCounter;
/// {@macro flutter.widgets.editableText.scrollPhysics}
final ScrollPhysics? scrollPhysics;
/// {@macro flutter.widgets.editableText.scrollController}
final ScrollController? scrollController;
/// {@macro flutter.widgets.editableText.autofillHints}
/// {@macro flutter.services.AutofillConfiguration.autofillHints}
final Iterable<String>? autofillHints;
/// {@template flutter.material.textfield.restorationId}
/// Restoration ID to save and restore the state of the text field.
///
/// If non-null, the text field will persist and restore its current scroll
/// offset and - if no [controller] has been provided - the content of the
/// text field. If a [controller] has been provided, it is the responsibility
/// of the owner of that controller to persist and restore it, e.g. by using
/// a [RestorableTextEditingController].
///
/// The state of this widget is persisted in a [RestorationBucket] claimed
/// from the surrounding [RestorationScope] using the provided restoration ID.
///
/// See also:
///
/// * [RestorationManager], which explains how state restoration works in
/// Flutter.
/// {@endtemplate}
final String? restorationId;
/// {@macro flutter.services.TextInputConfiguration.enableIMEPersonalizedLearning}
final bool enableIMEPersonalizedLearning;
@override
_StreamMessageTextFieldState createState() => _StreamMessageTextFieldState();
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<FocusNode>('focusNode', focusNode,
defaultValue: null));
properties
.add(DiagnosticsProperty<bool>('enabled', enabled, defaultValue: null));
properties.add(DiagnosticsProperty<InputDecoration>(
'decoration', decoration,
defaultValue: const InputDecoration()));
properties.add(DiagnosticsProperty<TextInputType>(
'keyboardType', keyboardType,
defaultValue: TextInputType.text));
properties.add(
DiagnosticsProperty<TextStyle>('style', style, defaultValue: null));
properties.add(
DiagnosticsProperty<bool>('autofocus', autofocus, defaultValue: false));
properties.add(DiagnosticsProperty<String>(
'obscuringCharacter', obscuringCharacter,
defaultValue: ''));
properties.add(DiagnosticsProperty<bool>('obscureText', obscureText,
defaultValue: false));
properties.add(DiagnosticsProperty<bool>('autocorrect', autocorrect,
defaultValue: true));
properties.add(EnumProperty<SmartDashesType>(
'smartDashesType', smartDashesType,
defaultValue:
obscureText ? SmartDashesType.disabled : SmartDashesType.enabled));
properties.add(EnumProperty<SmartQuotesType>(
'smartQuotesType', smartQuotesType,
defaultValue:
obscureText ? SmartQuotesType.disabled : SmartQuotesType.enabled));
properties.add(DiagnosticsProperty<bool>(
'enableSuggestions', enableSuggestions,
defaultValue: true));
properties.add(IntProperty('maxLines', maxLines, defaultValue: 1));
properties.add(IntProperty('minLines', minLines, defaultValue: null));
properties.add(
DiagnosticsProperty<bool>('expands', expands, defaultValue: false));
properties.add(IntProperty('maxLength', maxLength, defaultValue: null));
properties.add(EnumProperty<MaxLengthEnforcement>(
'maxLengthEnforcement', maxLengthEnforcement,
defaultValue: null));
properties.add(EnumProperty<TextInputAction>(
'textInputAction', textInputAction,
defaultValue: null));
properties.add(EnumProperty<TextCapitalization>(
'textCapitalization', textCapitalization,
defaultValue: TextCapitalization.none));
properties.add(EnumProperty<TextAlign>('textAlign', textAlign,
defaultValue: TextAlign.start));
properties.add(DiagnosticsProperty<TextAlignVertical>(
'textAlignVertical', textAlignVertical,
defaultValue: null));
properties.add(EnumProperty<TextDirection>('textDirection', textDirection,
defaultValue: null));
properties
.add(DoubleProperty('cursorWidth', cursorWidth, defaultValue: 2.0));
properties
.add(DoubleProperty('cursorHeight', cursorHeight, defaultValue: null));
properties.add(DiagnosticsProperty<Radius>('cursorRadius', cursorRadius,
defaultValue: null));
properties
.add(ColorProperty('cursorColor', cursorColor, defaultValue: null));
properties.add(DiagnosticsProperty<Brightness>(
'keyboardAppearance', keyboardAppearance,
defaultValue: null));
properties.add(DiagnosticsProperty<EdgeInsetsGeometry>(
'scrollPadding', scrollPadding,
defaultValue: const EdgeInsets.all(20)));
properties.add(FlagProperty('selectionEnabled',
value: selectionEnabled,
defaultValue: true,
ifFalse: 'selection disabled'));
properties.add(DiagnosticsProperty<TextSelectionControls>(
'selectionControls', selectionControls,
defaultValue: null));
properties.add(DiagnosticsProperty<ScrollController>(
'scrollController', scrollController,
defaultValue: null));
properties.add(DiagnosticsProperty<ScrollPhysics>(
'scrollPhysics', scrollPhysics,
defaultValue: null));
properties.add(DiagnosticsProperty<bool>(
'enableIMEPersonalizedLearning', enableIMEPersonalizedLearning,
defaultValue: true));
}
}
class _StreamMessageTextFieldState extends State<StreamMessageTextField>
with RestorationMixin<StreamMessageTextField> {
RestorableMessageInputController? _controller;
MessageInputController get _effectiveController =>
widget.controller ?? _controller!.value;
@override
void initState() {
super.initState();
if (widget.controller == null) {
_createLocalController();
}
}
void _createLocalController([Message? message]) {
assert(_controller == null, '');
_controller = RestorableMessageInputController(message: message);
}
@override
void didUpdateWidget(covariant StreamMessageTextField oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.controller == null && oldWidget.controller != null) {
_createLocalController(oldWidget.controller!.value);
} else if (widget.controller != null && oldWidget.controller == null) {
unregisterFromRestoration(_controller!);
_controller!.dispose();
_controller = null;
}
}
@override
void restoreState(RestorationBucket? oldBucket, bool initialRestore) {
if (_controller != null) {
_registerController();
}
}
@override
String? get restorationId => widget.restorationId;
void _registerController() {
assert(_controller != null, '');
registerForRestoration(_controller!, restorationId ?? 'controller');
}
@override
Widget build(BuildContext context) => TextField(
controller: _effectiveController.textEditingController,
onChanged: (newText) {
_effectiveController.text = newText;
},
focusNode: widget.focusNode,
decoration: widget.decoration,
keyboardType: widget.keyboardType,
textInputAction: widget.textInputAction,
textCapitalization: widget.textCapitalization,
style: widget.style,
strutStyle: widget.strutStyle,
textAlign: widget.textAlign,
textAlignVertical: widget.textAlignVertical,
textDirection: widget.textDirection,
readOnly: widget.readOnly,
toolbarOptions: widget.toolbarOptions,
showCursor: widget.showCursor,
autofocus: widget.autofocus,
obscuringCharacter: widget.obscuringCharacter,
obscureText: widget.obscureText,
autocorrect: widget.autocorrect,
smartDashesType: widget.smartDashesType,
smartQuotesType: widget.smartQuotesType,
enableSuggestions: widget.enableSuggestions,
maxLines: widget.maxLines,
minLines: widget.minLines,
expands: widget.expands,
maxLength: widget.maxLength,
maxLengthEnforcement: widget.maxLengthEnforcement,
onEditingComplete: widget.onEditingComplete,
onSubmitted: widget.onSubmitted,
onAppPrivateCommand: widget.onAppPrivateCommand,
inputFormatters: widget.inputFormatters,
enabled: widget.enabled,
cursorWidth: widget.cursorWidth,
cursorHeight: widget.cursorHeight,
cursorRadius: widget.cursorRadius,
cursorColor: widget.cursorColor,
selectionHeightStyle: widget.selectionHeightStyle,
selectionWidthStyle: widget.selectionWidthStyle,
keyboardAppearance: widget.keyboardAppearance,
scrollPadding: widget.scrollPadding,
dragStartBehavior: widget.dragStartBehavior,
enableInteractiveSelection: widget.enableInteractiveSelection,
selectionControls: widget.selectionControls,
onTap: widget.onTap,
mouseCursor: widget.mouseCursor,
buildCounter: widget.buildCounter,
scrollController: widget.scrollController,
scrollPhysics: widget.scrollPhysics,
autofillHints: widget.autofillHints,
restorationId: widget.restorationId,
enableIMEPersonalizedLearning: widget.enableIMEPersonalizedLearning,
);
@override
void dispose() {
_controller?.dispose();
super.dispose();
}
}
File diff suppressed because it is too large Load Diff
@@ -196,7 +196,6 @@ class MessageListView extends StatefulWidget {
this.messageFilter,
this.onMessageTap,
this.onSystemMessageTap,
this.pinPermissions = const [],
this.showFloatingDateDivider = true,
this.threadSeparatorBuilder,
this.messageListController,
@@ -314,9 +313,6 @@ class MessageListView extends StatefulWidget {
/// Called when system message is tapped
final OnMessageTap? onSystemMessageTap;
/// A List of user types that have permission to pin messages
final List<String> pinPermissions;
/// Builder used to build the thread separator in case it's a thread view
final WidgetBuilder? threadSeparatorBuilder;
@@ -345,6 +341,7 @@ class _MessageListViewState extends State<MessageListView> {
int? _messageListLength;
StreamChannelState? streamChannel;
late StreamChatThemeData _streamTheme;
late List<String> _userPermissions;
int get _initialIndex {
final initialScrollIndex = widget.initialScrollIndex;
@@ -1023,7 +1020,7 @@ class _MessageListViewState extends State<MessageListView> {
FocusScope.of(context).unfocus();
},
showPinButton: currentUserMember != null &&
widget.pinPermissions.contains(currentUserMember.role),
_userPermissions.contains(PermissionType.pinMessage),
);
if (widget.parentMessageBuilder != null) {
@@ -1142,7 +1139,10 @@ class _MessageListViewState extends State<MessageListView> {
},
showEditMessage: isMyMessage,
showDeleteMessage: isMyMessage,
showThreadReplyMessage: !isThreadMessage,
showThreadReplyMessage: !isThreadMessage &&
streamChannel?.channel.ownCapabilities
.contains(PermissionType.sendReply) ==
true,
showFlagButton: !isMyMessage,
borderSide: borderSide,
onThreadTap: _onThreadTap,
@@ -1211,7 +1211,7 @@ class _MessageListViewState extends State<MessageListView> {
FocusScope.of(context).unfocus();
},
showPinButton: currentUserMember != null &&
widget.pinPermissions.contains(currentUserMember.role),
_userPermissions.contains(PermissionType.pinMessage),
);
if (widget.messageBuilder != null) {
@@ -1291,6 +1291,7 @@ class _MessageListViewState extends State<MessageListView> {
void didChangeDependencies() {
final newStreamChannel = StreamChannel.of(context);
_streamTheme = StreamChatTheme.of(context);
_userPermissions = newStreamChannel.channel.ownCapabilities;
if (newStreamChannel != streamChannel) {
streamChannel = newStreamChannel;
@@ -13,7 +13,7 @@ class MessageReactionsModal extends StatelessWidget {
required this.message,
required this.messageWidget,
required this.messageTheme,
this.showReactions = true,
this.showReactions,
this.reverse = false,
this.onUserAvatarTap,
}) : super(key: key);
@@ -31,7 +31,7 @@ class MessageReactionsModal extends StatelessWidget {
final bool reverse;
/// Flag to show reactions on message
final bool showReactions;
final bool? showReactions;
/// Callback when user avatar is tapped
final void Function(User)? onUserAvatarTap;
@@ -40,6 +40,10 @@ class MessageReactionsModal extends StatelessWidget {
Widget build(BuildContext context) {
final size = MediaQuery.of(context).size;
final user = StreamChat.of(context).currentUser;
final _userPermissions = StreamChannel.of(context).channel.ownCapabilities;
final hasReactionPermission =
_userPermissions.contains(PermissionType.sendReaction);
final roughMaxSize = size.width * 2 / 3;
var messageTextLength = message.text!.length;
@@ -71,7 +75,7 @@ class MessageReactionsModal extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
if (showReactions &&
if ((showReactions ?? hasReactionPermission) &&
(message.status == MessageSendingStatus.sent))
Align(
alignment: Alignment(
@@ -1087,7 +1087,8 @@ class _MessageWidgetState extends State<MessageWidget>
showSendingIndicator: false,
padding: const EdgeInsets.all(0),
showReactionPickerIndicator: widget.showReactions &&
(widget.message.status == MessageSendingStatus.sent),
(widget.message.status == MessageSendingStatus.sent) &&
channel.ownCapabilities.contains(PermissionType.sendReaction),
showPinHighlight: false,
showUserAvatar:
widget.message.user!.id == channel.client.state.currentUser!.id
@@ -1098,7 +1099,6 @@ class _MessageWidgetState extends State<MessageWidget>
Clipboard.setData(ClipboardData(text: message.text)),
messageTheme: widget.messageTheme,
reverse: widget.reverse,
showDeleteMessage: widget.showDeleteMessage || isDeleteFailed,
message: widget.message,
editMessageInputBuilder: widget.editMessageInputBuilder,
onReplyTap: widget.onReplyTap,
@@ -1108,11 +1108,6 @@ class _MessageWidgetState extends State<MessageWidget>
showCopyMessage: widget.showCopyMessage &&
!isFailedState &&
widget.message.text?.trim().isNotEmpty == true,
showEditMessage: widget.showEditMessage &&
!isDeleteFailed &&
!widget.message.attachments
.any((element) => element.type == 'giphy'),
showReactions: widget.showReactions,
showReplyMessage: widget.showReplyMessage &&
!isFailedState &&
widget.onReplyTap != null,
@@ -1120,7 +1115,6 @@ class _MessageWidgetState extends State<MessageWidget>
!isFailedState &&
widget.onThreadTap != null,
showFlagButton: widget.showFlagButton,
showPinButton: widget.showPinButton,
customActions: widget.customActions,
),
),
@@ -1150,7 +1144,8 @@ class _MessageWidgetState extends State<MessageWidget>
showSendingIndicator: false,
padding: const EdgeInsets.all(0),
showReactionPickerIndicator: widget.showReactions &&
(widget.message.status == MessageSendingStatus.sent),
(widget.message.status == MessageSendingStatus.sent) &&
channel.ownCapabilities.contains(PermissionType.sendReaction),
showPinHighlight: false,
showUserAvatar:
widget.message.user!.id == channel.client.state.currentUser!.id
@@ -1161,7 +1156,8 @@ class _MessageWidgetState extends State<MessageWidget>
messageTheme: widget.messageTheme,
reverse: widget.reverse,
message: widget.message,
showReactions: widget.showReactions,
showReactions: widget.showReactions &&
channel.ownCapabilities.contains(PermissionType.sendReaction),
),
),
);
@@ -1249,6 +1245,13 @@ class _MessageWidgetState extends State<MessageWidget>
final channel = StreamChannel.of(context).channel;
if (!channel.ownCapabilities.contains(PermissionType.readEvents)) {
return SendingIndicator(
message: message,
size: style!.fontSize,
);
}
return BetterStreamBuilder<List<Read>>(
stream: channel.state?.readStream,
initialData: channel.state?.read,
@@ -252,6 +252,7 @@ class StreamChatThemeData {
sendButtonIdleColor: colorTheme.disabled,
inputBackgroundColor: colorTheme.barsBg,
inputTextStyle: textTheme.body,
linkHighlightColor: colorTheme.accentPrimary,
idleBorderGradient: LinearGradient(
colors: [
colorTheme.disabled,
@@ -65,6 +65,10 @@ class MessageInputThemeData with Diagnosticable {
this.idleBorderGradient,
this.borderRadius,
this.expandButtonColor,
this.linkHighlightColor,
this.enableSafeArea,
this.elevation,
this.shadow,
});
/// Duration of the [MessageInput] send button animation
@@ -73,6 +77,9 @@ class MessageInputThemeData with Diagnosticable {
/// Background color of [MessageInput] send button
final Color? sendButtonColor;
/// Color of a link
final Color? linkHighlightColor;
/// Background color of [MessageInput] action buttons
final Color? actionButtonColor;
@@ -103,6 +110,15 @@ class MessageInputThemeData with Diagnosticable {
/// Border radius of [MessageInput]
final BorderRadius? borderRadius;
/// Wrap [MessageInput] with a [SafeArea widget]
final bool? enableSafeArea;
/// Elevation of the [MessageInput]
final double? elevation;
/// Shadow for the [MessageInput] widget
final BoxShadow? shadow;
/// Returns a new [MessageInputThemeData] replacing some of its properties
MessageInputThemeData copyWith({
Duration? sendAnimationDuration,
@@ -110,6 +126,7 @@ class MessageInputThemeData with Diagnosticable {
Color? actionButtonColor,
Color? sendButtonColor,
Color? actionButtonIdleColor,
Color? linkHighlightColor,
Color? sendButtonIdleColor,
Color? expandButtonColor,
TextStyle? inputTextStyle,
@@ -117,6 +134,9 @@ class MessageInputThemeData with Diagnosticable {
Gradient? activeBorderGradient,
Gradient? idleBorderGradient,
BorderRadius? borderRadius,
bool? enableSafeArea,
double? elevation,
BoxShadow? shadow,
}) =>
MessageInputThemeData(
sendAnimationDuration:
@@ -133,6 +153,10 @@ class MessageInputThemeData with Diagnosticable {
activeBorderGradient: activeBorderGradient ?? this.activeBorderGradient,
idleBorderGradient: idleBorderGradient ?? this.idleBorderGradient,
borderRadius: borderRadius ?? this.borderRadius,
linkHighlightColor: linkHighlightColor ?? this.linkHighlightColor,
enableSafeArea: enableSafeArea ?? this.enableSafeArea,
elevation: elevation ?? this.elevation,
shadow: shadow ?? this.shadow,
);
/// Linearly interpolate from one [MessageInputThemeData] to another.
@@ -161,6 +185,11 @@ class MessageInputThemeData with Diagnosticable {
Color.lerp(a.sendButtonIdleColor, b.sendButtonIdleColor, t),
sendAnimationDuration: a.sendAnimationDuration,
inputDecoration: a.inputDecoration,
linkHighlightColor:
Color.lerp(a.linkHighlightColor, b.linkHighlightColor, t),
enableSafeArea: a.enableSafeArea,
elevation: Tween(begin: a.elevation, end: b.elevation).transform(t),
shadow: BoxShadow.lerp(a.shadow, b.shadow, t),
);
/// Merges [this] [MessageInputThemeData] with the [other]
@@ -181,6 +210,10 @@ class MessageInputThemeData with Diagnosticable {
idleBorderGradient: other.idleBorderGradient,
borderRadius: other.borderRadius,
expandButtonColor: other.expandButtonColor,
linkHighlightColor: other.linkHighlightColor,
enableSafeArea: other.enableSafeArea,
elevation: other.elevation,
shadow: other.shadow,
);
}
@@ -200,7 +233,11 @@ class MessageInputThemeData with Diagnosticable {
inputDecoration == other.inputDecoration &&
idleBorderGradient == other.idleBorderGradient &&
activeBorderGradient == other.activeBorderGradient &&
borderRadius == other.borderRadius;
borderRadius == other.borderRadius &&
linkHighlightColor == other.linkHighlightColor &&
enableSafeArea == other.enableSafeArea &&
elevation == other.elevation &&
shadow == other.shadow;
@override
int get hashCode =>
@@ -215,7 +252,11 @@ class MessageInputThemeData with Diagnosticable {
inputDecoration.hashCode ^
idleBorderGradient.hashCode ^
activeBorderGradient.hashCode ^
borderRadius.hashCode;
borderRadius.hashCode ^
linkHighlightColor.hashCode ^
elevation.hashCode ^
shadow.hashCode ^
enableSafeArea.hashCode;
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
@@ -232,6 +273,10 @@ class MessageInputThemeData with Diagnosticable {
..add(DiagnosticsProperty('activeBorderGradient', activeBorderGradient))
..add(DiagnosticsProperty('idleBorderGradient', idleBorderGradient))
..add(DiagnosticsProperty('borderRadius', borderRadius))
..add(ColorProperty('expandButtonColor', expandButtonColor));
..add(ColorProperty('expandButtonColor', expandButtonColor))
..add(ColorProperty('linkHighlightColor', linkHighlightColor))
..add(DiagnosticsProperty('elevation', elevation))
..add(DiagnosticsProperty('shadow', shadow))
..add(DiagnosticsProperty('enableSafeArea', enableSafeArea));
}
}
@@ -1,7 +1,6 @@
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:jiffy/jiffy.dart';
import 'package:stream_chat/stream_chat.dart' show Channel;
import 'package:stream_chat_flutter/src/extension.dart';
import 'package:stream_chat_flutter/src/sending_indicator.dart';
import 'package:stream_chat_flutter/src/stream_svg_icon.dart';
@@ -2,7 +2,6 @@ import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/src/group_avatar.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/packages/stream_chat_flutter/screenshots/channel_image.png)
/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/packages/stream_chat_flutter/screenshots/channel_image_paint.png)
@@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:stream_chat_flutter/src/extension.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
@@ -23,7 +23,13 @@ export 'src/localization/stream_chat_localizations.dart';
export 'src/localization/translations.dart' show DefaultTranslations;
export 'src/mention_tile.dart';
export 'src/message_action.dart';
export 'src/message_input.dart';
export 'src/message_input/countdown_button.dart';
export 'src/message_input/message_input.dart';
export 'src/message_input/message_input_controller.dart';
export 'src/message_input/message_text_field_controller.dart';
export 'src/message_input/stream_attachment_picker.dart';
export 'src/message_input/stream_message_send_button.dart';
export 'src/message_input/stream_message_text_field.dart';
export 'src/message_list_view.dart';
export 'src/message_search_item.dart';
export 'src/message_search_list_view.dart';
@@ -18,6 +18,7 @@ void main() {
(WidgetTester tester) async {
final client = MockClient();
final clientState = MockClientState();
final channel = MockChannel();
when(() => client.state).thenReturn(clientState);
when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id'));
@@ -31,18 +32,25 @@ void main() {
streamChatThemeData: streamTheme,
client: client,
child: SizedBox(
child: MessageActionsModal(
message: Message(
text: 'test',
user: User(
id: 'user-id',
child: StreamChannel(
channel: channel,
child: MessageActionsModal(
message: Message(
text: 'test',
user: User(
id: 'user-id',
),
status: MessageSendingStatus.sent,
),
messageWidget: const Text(
'test',
key: Key('MessageWidget'),
),
messageTheme: streamTheme.ownMessageTheme,
showThreadReplyMessage: true,
showEditMessage: true,
showDeleteMessage: true,
),
messageWidget: const Text(
'test',
key: Key('MessageWidget'),
),
messageTheme: streamTheme.ownMessageTheme,
),
),
),
@@ -64,6 +72,7 @@ void main() {
(WidgetTester tester) async {
final client = MockClient();
final clientState = MockClientState();
final channel = MockChannel();
when(() => client.state).thenReturn(clientState);
when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id'));
@@ -77,22 +86,23 @@ void main() {
streamChatThemeData: streamTheme,
client: client,
child: SizedBox(
child: MessageActionsModal(
showEditMessage: false,
showCopyMessage: false,
showDeleteMessage: false,
showReplyMessage: false,
showThreadReplyMessage: false,
message: Message(
text: 'test',
user: User(
id: 'user-id',
child: StreamChannel(
channel: channel,
child: MessageActionsModal(
showCopyMessage: false,
showReplyMessage: false,
showThreadReplyMessage: false,
message: Message(
text: 'test',
user: User(
id: 'user-id',
),
),
messageTheme: streamTheme.ownMessageTheme,
messageWidget: const Text(
'test',
key: Key('MessageWidget'),
),
),
messageTheme: streamTheme.ownMessageTheme,
messageWidget: const Text(
'test',
key: Key('MessageWidget'),
),
),
),
@@ -115,6 +125,7 @@ void main() {
(WidgetTester tester) async {
final client = MockClient();
final clientState = MockClientState();
final channel = MockChannel();
when(() => client.state).thenReturn(clientState);
when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id'));
@@ -130,24 +141,27 @@ void main() {
streamChatThemeData: streamTheme,
client: client,
child: SizedBox(
child: MessageActionsModal(
messageWidget: const Text('test'),
message: Message(
text: 'test',
user: User(
id: 'user-id',
child: StreamChannel(
channel: channel,
child: MessageActionsModal(
messageWidget: const Text('test'),
message: Message(
text: 'test',
user: User(
id: 'user-id',
),
),
messageTheme: streamTheme.ownMessageTheme,
customActions: [
MessageAction(
leading: const Icon(Icons.check),
title: const Text('title'),
onTap: (m) {
tapped = true;
},
),
],
),
messageTheme: streamTheme.ownMessageTheme,
customActions: [
MessageAction(
leading: const Icon(Icons.check),
title: const Text('title'),
onTap: (m) {
tapped = true;
},
),
],
),
),
),
@@ -170,6 +184,7 @@ void main() {
(WidgetTester tester) async {
final client = MockClient();
final clientState = MockClientState();
final channel = MockChannel();
when(() => client.state).thenReturn(clientState);
when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id'));
@@ -186,18 +201,22 @@ void main() {
streamChatThemeData: streamTheme,
client: client,
child: SizedBox(
child: MessageActionsModal(
messageWidget: const Text('test'),
onReplyTap: (m) {
tapped = true;
},
message: Message(
text: 'test',
user: User(
id: 'user-id',
child: StreamChannel(
channel: channel,
child: MessageActionsModal(
messageWidget: const Text('test'),
onReplyTap: (m) {
tapped = true;
},
message: Message(
text: 'test',
user: User(
id: 'user-id',
),
status: MessageSendingStatus.sent,
),
messageTheme: streamTheme.ownMessageTheme,
),
messageTheme: streamTheme.ownMessageTheme,
),
),
),
@@ -216,6 +235,7 @@ void main() {
(WidgetTester tester) async {
final client = MockClient();
final clientState = MockClientState();
final channel = MockChannel();
when(() => client.state).thenReturn(clientState);
when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id'));
@@ -232,18 +252,23 @@ void main() {
streamChatThemeData: streamTheme,
client: client,
child: SizedBox(
child: MessageActionsModal(
messageWidget: const Text('test'),
onThreadReplyTap: (m) {
tapped = true;
},
message: Message(
text: 'test',
user: User(
id: 'user-id',
child: StreamChannel(
channel: channel,
child: MessageActionsModal(
messageWidget: const Text('test'),
onThreadReplyTap: (m) {
tapped = true;
},
message: Message(
text: 'test',
user: User(
id: 'user-id',
),
status: MessageSendingStatus.sent,
),
messageTheme: streamTheme.ownMessageTheme,
showThreadReplyMessage: true,
),
messageTheme: streamTheme.ownMessageTheme,
),
),
),
@@ -293,6 +318,7 @@ void main() {
),
),
messageTheme: streamTheme.ownMessageTheme,
showEditMessage: true,
),
),
),
@@ -343,6 +369,7 @@ void main() {
),
),
messageTheme: streamTheme.ownMessageTheme,
showEditMessage: true,
),
),
),
@@ -543,6 +570,7 @@ void main() {
),
),
messageTheme: streamTheme.ownMessageTheme,
showFlagButton: true,
),
),
),
@@ -599,6 +627,7 @@ void main() {
),
),
messageTheme: streamTheme.ownMessageTheme,
showFlagButton: true,
),
),
),
@@ -655,6 +684,7 @@ void main() {
),
),
messageTheme: streamTheme.ownMessageTheme,
showFlagButton: true,
),
),
),
@@ -709,6 +739,7 @@ void main() {
),
),
messageTheme: streamTheme.ownMessageTheme,
showDeleteMessage: true,
),
),
),
@@ -765,6 +796,7 @@ void main() {
),
),
messageTheme: streamTheme.ownMessageTheme,
showDeleteMessage: true,
),
),
),
@@ -0,0 +1,14 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
void main() {
testWidgets(
'should instantiate a new MessageInputController with empty message',
(tester) async {
final controller = MessageInputController()..text = 'test';
expect(controller.text, 'test');
expect(controller.message.text, 'test');
},
);
}
@@ -13,6 +13,7 @@ void main() {
(WidgetTester tester) async {
final client = MockClient();
final clientState = MockClientState();
final channel = MockChannel();
final themeData = ThemeData();
when(() => client.state).thenReturn(clientState);
@@ -33,13 +34,16 @@ void main() {
home: StreamChat(
client: client,
streamChatThemeData: streamTheme,
child: MessageReactionsModal(
messageWidget: const Text(
'test',
key: Key('MessageWidget'),
child: StreamChannel(
channel: channel,
child: MessageReactionsModal(
messageWidget: const Text(
'test',
key: Key('MessageWidget'),
),
message: message,
messageTheme: streamTheme.ownMessageTheme,
),
message: message,
messageTheme: streamTheme.ownMessageTheme,
),
),
),
@@ -58,6 +62,7 @@ void main() {
(WidgetTester tester) async {
final client = MockClient();
final clientState = MockClientState();
final channel = MockChannel();
final themeData = ThemeData();
when(() => client.state).thenReturn(clientState);
@@ -89,16 +94,19 @@ void main() {
home: StreamChat(
client: client,
streamChatThemeData: streamTheme,
child: MessageReactionsModal(
messageWidget: const Text(
'test',
key: Key('MessageWidget'),
child: StreamChannel(
channel: channel,
child: MessageReactionsModal(
messageWidget: const Text(
'test',
key: Key('MessageWidget'),
),
message: message,
messageTheme: streamTheme.ownMessageTheme,
reverse: true,
showReactions: false,
onUserAvatarTap: onUserAvatarTap,
),
message: message,
messageTheme: streamTheme.ownMessageTheme,
reverse: true,
showReactions: false,
onUserAvatarTap: onUserAvatarTap,
),
),
),
@@ -21,6 +21,9 @@ class MockChannel extends Mock implements Channel {
Future<void> keyStroke([String? parentId]) async {
return;
}
@override
List<String> get ownCapabilities => ['send-message'];
}
class MockChannelState extends Mock implements ChannelClientState {
@@ -1,5 +1,9 @@
## Upcoming
✅ Added
- Added `MessageInputController` to hold `Message` related data.
🐞 Fixed
- Do not move a channel to top if the new message is from a thread.
@@ -6,7 +6,7 @@ repository: https://github.com/GetStream/stream-chat-flutter
issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues
environment:
sdk: '>=2.12.0 <3.0.0'
sdk: '>=2.14.0 <3.0.0'
flutter: ">=1.17.0"
dependencies:
@@ -84,6 +84,10 @@ class NnStreamChatLocalizations extends GlobalStreamChatLocalizations {
return 'Pinned by ${pinnedBy.name}';
}
@override
String get sendMessagePermissionError =>
'You don\'t have permission to send messages';
@override
String get emptyMessagesText => 'There are no messages currently';
@@ -392,6 +396,13 @@ class NnStreamChatLocalizations extends GlobalStreamChatLocalizations {
@override
String get slowModeOnLabel => 'Slow mode ON';
@override
String get linkDisabledDetails =>
'Sending links is not allowed in this conversation.';
@override
String get linkDisabledError => 'Links are disabled';
}
void main() async {
@@ -60,6 +60,10 @@ class StreamChatLocalizationsEn extends GlobalStreamChatLocalizations {
return 'Pinned by ${pinnedBy.name}';
}
@override
String get sendMessagePermissionError =>
'You don\'t have permission to send messages';
@override
String get emptyMessagesText => 'There are no messages currently';
@@ -368,4 +372,11 @@ class StreamChatLocalizationsEn extends GlobalStreamChatLocalizations {
@override
String get slowModeOnLabel => 'Slow mode ON';
@override
String get linkDisabledDetails =>
'Sending links is not allowed in this conversation.';
@override
String get linkDisabledError => 'Links are disabled';
}
@@ -61,6 +61,10 @@ class StreamChatLocalizationsEs extends GlobalStreamChatLocalizations {
return 'Fijado por ${pinnedBy.name}';
}
@override
String get sendMessagePermissionError =>
'No tienes permiso para enviar mensajes';
@override
String get emptyMessagesText => 'Actualmente no hay mensajes';
@@ -374,4 +378,11 @@ No es posible añadir más de $limit archivos adjuntos
@override
String get slowModeOnLabel => 'Modo lento activado';
@override
String get linkDisabledDetails =>
'No se permite enviar enlaces en esta conversación.';
@override
String get linkDisabledError => 'Los enlaces están deshabilitados';
}
@@ -61,6 +61,10 @@ class StreamChatLocalizationsFr extends GlobalStreamChatLocalizations {
return 'Épinglé par ${pinnedBy.name}';
}
@override
String get sendMessagePermissionError =>
'Vous n\'êtes pas autorisé à envoyer des messages';
@override
String get emptyMessagesText => "Il n'y a pas de messages actuellement";
@@ -373,4 +377,11 @@ Limite de pièces jointes dépassée : il n'est pas possible d'ajouter plus de $
@override
String get slowModeOnLabel => 'Mode lent activé';
@override
String get linkDisabledDetails =>
'L\'envoi de liens n\'est pas autorisé dans cette conversation.';
@override
String get linkDisabledError => 'Les liens sont désactivés';
}
@@ -60,6 +60,9 @@ class StreamChatLocalizationsHi extends GlobalStreamChatLocalizations {
return '${pinnedBy.name} द्वारा पिन किया गया';
}
@override
String get sendMessagePermissionError => 'आपको संदेश भेजने की अनुमति नहीं है';
@override
String get emptyMessagesText => 'वर्तमान में कोई संदेश नहीं है';
@@ -368,4 +371,11 @@ class StreamChatLocalizationsHi extends GlobalStreamChatLocalizations {
@override
String get slowModeOnLabel => 'स्लो मोड चालू';
@override
String get linkDisabledDetails =>
'इस बातचीत में लिंक भेजने की अनुमति नहीं है.';
@override
String get linkDisabledError => 'लिंक भेजना प्रतिबंधित';
}
@@ -60,6 +60,10 @@ class StreamChatLocalizationsIt extends GlobalStreamChatLocalizations {
return 'Messo in evidenza da ${pinnedBy.name}';
}
@override
String get sendMessagePermissionError =>
'Non hai l\'autorizzazione per inviare messaggi';
@override
String get emptyMessagesText => 'Non c\'é nessun messaggio al momento';
@@ -370,4 +374,11 @@ Attenzione: il limite massimo di $limit file è stato superato.
@override
String get slowModeOnLabel => 'Slowmode attiva';
@override
String get linkDisabledDetails =>
'Non è permesso condividere link in questa convesazione.';
@override
String get linkDisabledError => 'I links sono disattivati';
}
@@ -60,6 +60,9 @@ class StreamChatLocalizationsJa extends GlobalStreamChatLocalizations {
return '${pinnedBy.name}のピン';
}
@override
String get sendMessagePermissionError => 'メッセージを送信する権限がありません';
@override
String get emptyMessagesText => '現在、メッセージはありません。';
@@ -354,4 +357,10 @@ class StreamChatLocalizationsJa extends GlobalStreamChatLocalizations {
String attachmentLimitExceedError(int limit) => '''
$limit個のファイル以上を添付することはできません
''';
@override
String get linkDisabledDetails => 'この会話では、リンクの送信は許可されていません。';
@override
String get linkDisabledError => 'リンクが無効になっています';
}
@@ -60,6 +60,9 @@ class StreamChatLocalizationsKo extends GlobalStreamChatLocalizations {
return '${pinnedBy.name}의 핀';
}
@override
String get sendMessagePermissionError => '메시지를 보낼 수 있는 권한이 없습니다';
@override
String get emptyMessagesText => '현재 메시지가 없습니다';
@@ -354,4 +357,10 @@ class StreamChatLocalizationsKo extends GlobalStreamChatLocalizations {
@override
String attachmentLimitExceedError(int limit) =>
'첨부 파일 제한 초과: $limit 이상의 첨부 파일을 추가할 수 없습니다';
@override
String get linkDisabledDetails => '이 대화에서는 링크를 보낼 수 없습니다.';
@override
String get linkDisabledError => '링크가 비활성화되었습니다.';
}
@@ -157,7 +157,6 @@ void main() {
(prev, curr) =>
prev?..update(curr.type, (value) => value + 1, ifAbsent: () => 1),
),
status: MessageSendingStatus.sending,
updatedAt: DateTime.now(),
extraData: const {'extra_test_data': 'extraData'},
user: user,
@@ -147,7 +147,6 @@ void main() {
(prev, curr) =>
prev?..update(curr.type, (value) => value + 1, ifAbsent: () => 1),
),
status: MessageSendingStatus.sending,
updatedAt: DateTime.now(),
extraData: const {'extra_test_data': 'extraData'},
user: user,