Merge branch 'message-input-controller' into feat/capabilities
This commit is contained in:
@@ -1,18 +1,13 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:logging/logging.dart';
|
|
||||||
import 'package:rxdart/rxdart.dart';
|
import 'package:rxdart/rxdart.dart';
|
||||||
import 'package:stream_chat/src/client/channel.dart';
|
|
||||||
import 'package:stream_chat/src/client/retry_policy.dart';
|
import 'package:stream_chat/src/client/retry_policy.dart';
|
||||||
import 'package:stream_chat/src/core/error/error.dart';
|
|
||||||
import 'package:stream_chat/src/core/models/message.dart';
|
|
||||||
import 'package:stream_chat/src/event_type.dart';
|
|
||||||
import 'package:stream_chat/stream_chat.dart';
|
import 'package:stream_chat/stream_chat.dart';
|
||||||
|
|
||||||
/// The retry queue associated to a channel
|
/// The retry queue associated to a channel.
|
||||||
class RetryQueue {
|
class RetryQueue {
|
||||||
/// Instantiate a new RetryQueue object
|
/// Instantiate a new RetryQueue object.
|
||||||
RetryQueue({
|
RetryQueue({
|
||||||
required this.channel,
|
required this.channel,
|
||||||
this.logger,
|
this.logger,
|
||||||
@@ -22,13 +17,13 @@ class RetryQueue {
|
|||||||
_listenFailedEvents();
|
_listenFailedEvents();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The channel of this queue
|
/// The channel of this queue.
|
||||||
final Channel channel;
|
final Channel channel;
|
||||||
|
|
||||||
/// The client associated with this [channel]
|
/// The client associated with this [channel].
|
||||||
final StreamChatClient client;
|
final StreamChatClient client;
|
||||||
|
|
||||||
/// The logger associated to this queue
|
/// The logger associated to this queue.
|
||||||
final Logger? logger;
|
final Logger? logger;
|
||||||
|
|
||||||
late final RetryPolicy _retryPolicy;
|
late final RetryPolicy _retryPolicy;
|
||||||
@@ -68,7 +63,7 @@ class RetryQueue {
|
|||||||
}).addTo(_compositeSubscription);
|
}).addTo(_compositeSubscription);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add a list of messages
|
/// Add a list of messages.
|
||||||
void add(List<Message> messages) {
|
void add(List<Message> messages) {
|
||||||
if (messages.isEmpty) return;
|
if (messages.isEmpty) return;
|
||||||
if (!_messageQueue.containsAllMessage(messages)) {
|
if (!_messageQueue.containsAllMessage(messages)) {
|
||||||
@@ -118,6 +113,7 @@ class RetryQueue {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e is! StreamChatNetworkError || !e.isRetriable) {
|
if (e is! StreamChatNetworkError || !e.isRetriable) {
|
||||||
_messageQueue.removeMessage(message);
|
_messageQueue.removeMessage(message);
|
||||||
|
_sendFailedEvent(message);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
// retry logic
|
// retry logic
|
||||||
@@ -179,10 +175,10 @@ class RetryQueue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether our [_messageQueue] has messages or not
|
/// Whether our [_messageQueue] has messages or not.
|
||||||
bool get hasMessages => _messageQueue.isNotEmpty;
|
bool get hasMessages => _messageQueue.isNotEmpty;
|
||||||
|
|
||||||
/// Call this method to dispose this object
|
/// Call this method to dispose this object.
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_messageQueue.clear();
|
_messageQueue.clear();
|
||||||
_compositeSubscription.dispose();
|
_compositeSubscription.dispose();
|
||||||
|
|||||||
@@ -8,13 +8,13 @@ import 'package:uuid/uuid.dart';
|
|||||||
|
|
||||||
part 'message.g.dart';
|
part 'message.g.dart';
|
||||||
|
|
||||||
class _PinExpires {
|
class _NullConst {
|
||||||
const _PinExpires();
|
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 {
|
enum MessageSendingStatus {
|
||||||
/// Message is being sent
|
/// Message is being sent
|
||||||
sending,
|
sending,
|
||||||
@@ -40,10 +40,10 @@ enum MessageSendingStatus {
|
|||||||
sent,
|
sent,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The class that contains the information about a message
|
/// The class that contains the information about a message.
|
||||||
@JsonSerializable()
|
@JsonSerializable()
|
||||||
class Message extends Equatable {
|
class Message extends Equatable {
|
||||||
/// Constructor used for json serialization
|
/// Constructor used for json serialization.
|
||||||
Message({
|
Message({
|
||||||
String? id,
|
String? id,
|
||||||
this.text,
|
this.text,
|
||||||
@@ -58,44 +58,47 @@ class Message extends Equatable {
|
|||||||
this.ownReactions,
|
this.ownReactions,
|
||||||
this.parentId,
|
this.parentId,
|
||||||
this.quotedMessage,
|
this.quotedMessage,
|
||||||
this.quotedMessageId,
|
String? quotedMessageId,
|
||||||
this.replyCount = 0,
|
this.replyCount = 0,
|
||||||
this.threadParticipants,
|
this.threadParticipants,
|
||||||
this.showInChannel,
|
this.showInChannel,
|
||||||
this.command,
|
this.command,
|
||||||
DateTime? createdAt,
|
DateTime? createdAt,
|
||||||
DateTime? updatedAt,
|
DateTime? updatedAt,
|
||||||
|
this.deletedAt,
|
||||||
this.user,
|
this.user,
|
||||||
this.pinned = false,
|
this.pinned = false,
|
||||||
this.pinnedAt,
|
this.pinnedAt,
|
||||||
DateTime? pinExpires,
|
DateTime? pinExpires,
|
||||||
this.pinnedBy,
|
this.pinnedBy,
|
||||||
this.extraData = const {},
|
this.extraData = const {},
|
||||||
this.deletedAt,
|
this.status = MessageSendingStatus.sending,
|
||||||
this.status = MessageSendingStatus.sent,
|
|
||||||
this.i18n,
|
this.i18n,
|
||||||
}) : id = id ?? const Uuid().v4(),
|
}) : id = id ?? const Uuid().v4(),
|
||||||
pinExpires = pinExpires?.toUtc(),
|
pinExpires = pinExpires?.toUtc(),
|
||||||
createdAt = createdAt ?? DateTime.now(),
|
_createdAt = createdAt,
|
||||||
updatedAt = updatedAt ?? DateTime.now();
|
_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(
|
factory Message.fromJson(Map<String, dynamic> json) => _$MessageFromJson(
|
||||||
Serializer.moveToExtraDataFromRoot(json, topLevelFields),
|
Serializer.moveToExtraDataFromRoot(json, topLevelFields),
|
||||||
|
).copyWith(
|
||||||
|
status: MessageSendingStatus.sent,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// The message ID. This is either created by Stream or set client side when
|
/// The message ID. This is either created by Stream or set client side when
|
||||||
/// the message is added.
|
/// the message is added.
|
||||||
final String id;
|
final String id;
|
||||||
|
|
||||||
/// The text of this message
|
/// The text of this message.
|
||||||
final String? text;
|
final String? text;
|
||||||
|
|
||||||
/// The status of a sending message
|
/// The status of a sending message.
|
||||||
@JsonKey(ignore: true)
|
@JsonKey(ignore: true)
|
||||||
final MessageSendingStatus status;
|
final MessageSendingStatus status;
|
||||||
|
|
||||||
/// The message type
|
/// The message type.
|
||||||
@JsonKey(
|
@JsonKey(
|
||||||
includeIfNull: false,
|
includeIfNull: false,
|
||||||
toJson: Serializer.readOnly,
|
toJson: Serializer.readOnly,
|
||||||
@@ -107,15 +110,15 @@ class Message extends Equatable {
|
|||||||
@JsonKey(includeIfNull: false)
|
@JsonKey(includeIfNull: false)
|
||||||
final List<Attachment> attachments;
|
final List<Attachment> attachments;
|
||||||
|
|
||||||
/// The list of user mentioned in the message
|
/// The list of user mentioned in the message.
|
||||||
@JsonKey(toJson: User.toIds)
|
@JsonKey(toJson: User.toIds)
|
||||||
final List<User> mentionedUsers;
|
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)
|
@JsonKey(includeIfNull: false, toJson: Serializer.readOnly)
|
||||||
final Map<String, int>? reactionCounts;
|
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)
|
@JsonKey(includeIfNull: false, toJson: Serializer.readOnly)
|
||||||
final Map<String, int>? reactionScores;
|
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.
|
/// The ID of the parent message, if the message is a thread reply.
|
||||||
final String? parentId;
|
final String? parentId;
|
||||||
|
|
||||||
/// A quoted reply message
|
/// A quoted reply message.
|
||||||
@JsonKey(toJson: Serializer.readOnly)
|
@JsonKey(toJson: Serializer.readOnly)
|
||||||
final Message? quotedMessage;
|
final Message? quotedMessage;
|
||||||
|
|
||||||
|
final String? _quotedMessageId;
|
||||||
|
|
||||||
/// The ID of the quoted message, if the message is a quoted reply.
|
/// 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.
|
/// Reserved field indicating the number of replies for this message.
|
||||||
@JsonKey(includeIfNull: false, toJson: Serializer.readOnly)
|
@JsonKey(includeIfNull: false, toJson: Serializer.readOnly)
|
||||||
@@ -148,10 +153,10 @@ class Message extends Equatable {
|
|||||||
/// Check if this message needs to show in the channel.
|
/// Check if this message needs to show in the channel.
|
||||||
final bool? showInChannel;
|
final bool? showInChannel;
|
||||||
|
|
||||||
/// If true the message is silent
|
/// If true the message is silent.
|
||||||
final bool silent;
|
final bool silent;
|
||||||
|
|
||||||
/// If true the message is shadowed
|
/// If true the message is shadowed.
|
||||||
@JsonKey(
|
@JsonKey(
|
||||||
includeIfNull: false,
|
includeIfNull: false,
|
||||||
toJson: Serializer.readOnly,
|
toJson: Serializer.readOnly,
|
||||||
@@ -162,56 +167,61 @@ class Message extends Equatable {
|
|||||||
@JsonKey(includeIfNull: false, toJson: Serializer.readOnly)
|
@JsonKey(includeIfNull: false, toJson: Serializer.readOnly)
|
||||||
final String? command;
|
final String? command;
|
||||||
|
|
||||||
/// Reserved field indicating when the message was created.
|
final DateTime? _createdAt;
|
||||||
@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';
|
|
||||||
|
|
||||||
/// Reserved field indicating when the message was deleted.
|
/// Reserved field indicating when the message was deleted.
|
||||||
@JsonKey(includeIfNull: false, toJson: Serializer.readOnly)
|
@JsonKey(includeIfNull: false, toJson: Serializer.readOnly)
|
||||||
final DateTime? deletedAt;
|
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.
|
/// A Map of translations.
|
||||||
@JsonKey(includeIfNull: false)
|
@JsonKey(includeIfNull: false)
|
||||||
final Map<String, String>? i18n;
|
final Map<String, String>? i18n;
|
||||||
|
|
||||||
/// Known top level fields.
|
/// Known top level fields.
|
||||||
|
///
|
||||||
/// Useful for [Serializer] methods.
|
/// Useful for [Serializer] methods.
|
||||||
static const topLevelFields = [
|
static const topLevelFields = [
|
||||||
'id',
|
'id',
|
||||||
@@ -244,7 +254,7 @@ class Message extends Equatable {
|
|||||||
'i18n',
|
'i18n',
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Serialize to json
|
/// Serialize to json.
|
||||||
Map<String, dynamic> toJson() => Serializer.moveFromExtraDataToRoot(
|
Map<String, dynamic> toJson() => Serializer.moveFromExtraDataToRoot(
|
||||||
_$MessageToJson(this),
|
_$MessageToJson(this),
|
||||||
);
|
);
|
||||||
@@ -256,18 +266,18 @@ class Message extends Equatable {
|
|||||||
String? type,
|
String? type,
|
||||||
List<Attachment>? attachments,
|
List<Attachment>? attachments,
|
||||||
List<User>? mentionedUsers,
|
List<User>? mentionedUsers,
|
||||||
|
bool? silent,
|
||||||
|
bool? shadowed,
|
||||||
Map<String, int>? reactionCounts,
|
Map<String, int>? reactionCounts,
|
||||||
Map<String, int>? reactionScores,
|
Map<String, int>? reactionScores,
|
||||||
List<Reaction>? latestReactions,
|
List<Reaction>? latestReactions,
|
||||||
List<Reaction>? ownReactions,
|
List<Reaction>? ownReactions,
|
||||||
String? parentId,
|
String? parentId,
|
||||||
Message? quotedMessage,
|
Object? quotedMessage = _nullConst,
|
||||||
String? quotedMessageId,
|
Object? quotedMessageId = _nullConst,
|
||||||
int? replyCount,
|
int? replyCount,
|
||||||
List<User>? threadParticipants,
|
List<User>? threadParticipants,
|
||||||
bool? showInChannel,
|
bool? showInChannel,
|
||||||
bool? shadowed,
|
|
||||||
bool? silent,
|
|
||||||
String? command,
|
String? command,
|
||||||
DateTime? createdAt,
|
DateTime? createdAt,
|
||||||
DateTime? updatedAt,
|
DateTime? updatedAt,
|
||||||
@@ -275,7 +285,7 @@ class Message extends Equatable {
|
|||||||
User? user,
|
User? user,
|
||||||
bool? pinned,
|
bool? pinned,
|
||||||
DateTime? pinnedAt,
|
DateTime? pinnedAt,
|
||||||
Object? pinExpires = _pinExpires,
|
Object? pinExpires = _nullConst,
|
||||||
User? pinnedBy,
|
User? pinnedBy,
|
||||||
Map<String, Object?>? extraData,
|
Map<String, Object?>? extraData,
|
||||||
MessageSendingStatus? status,
|
MessageSendingStatus? status,
|
||||||
@@ -284,41 +294,68 @@ class Message extends Equatable {
|
|||||||
assert(() {
|
assert(() {
|
||||||
if (pinExpires is! DateTime &&
|
if (pinExpires is! DateTime &&
|
||||||
pinExpires != null &&
|
pinExpires != null &&
|
||||||
pinExpires is! _PinExpires) {
|
pinExpires is! _NullConst) {
|
||||||
throw ArgumentError('`pinExpires` can only be set as DateTime or null');
|
throw ArgumentError('`pinExpires` can only be set as DateTime or null');
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}(), 'Validate type for pinExpires');
|
}(), '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(
|
return Message(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
text: text ?? this.text,
|
text: text ?? this.text,
|
||||||
type: type ?? this.type,
|
type: type ?? this.type,
|
||||||
attachments: attachments ?? this.attachments,
|
attachments: attachments ?? this.attachments,
|
||||||
mentionedUsers: mentionedUsers ?? this.mentionedUsers,
|
mentionedUsers: mentionedUsers ?? this.mentionedUsers,
|
||||||
|
silent: silent ?? this.silent,
|
||||||
|
shadowed: shadowed ?? this.shadowed,
|
||||||
reactionCounts: reactionCounts ?? this.reactionCounts,
|
reactionCounts: reactionCounts ?? this.reactionCounts,
|
||||||
reactionScores: reactionScores ?? this.reactionScores,
|
reactionScores: reactionScores ?? this.reactionScores,
|
||||||
latestReactions: latestReactions ?? this.latestReactions,
|
latestReactions: latestReactions ?? this.latestReactions,
|
||||||
ownReactions: ownReactions ?? this.ownReactions,
|
ownReactions: ownReactions ?? this.ownReactions,
|
||||||
parentId: parentId ?? this.parentId,
|
parentId: parentId ?? this.parentId,
|
||||||
quotedMessage: quotedMessage ?? this.quotedMessage,
|
quotedMessage: quotedMessage == _nullConst
|
||||||
quotedMessageId: quotedMessageId ?? this.quotedMessageId,
|
? this.quotedMessage
|
||||||
|
: quotedMessage as Message?,
|
||||||
|
quotedMessageId: quotedMessageId == _nullConst
|
||||||
|
? _quotedMessageId
|
||||||
|
: quotedMessageId as String?,
|
||||||
replyCount: replyCount ?? this.replyCount,
|
replyCount: replyCount ?? this.replyCount,
|
||||||
threadParticipants: threadParticipants ?? this.threadParticipants,
|
threadParticipants: threadParticipants ?? this.threadParticipants,
|
||||||
showInChannel: showInChannel ?? this.showInChannel,
|
showInChannel: showInChannel ?? this.showInChannel,
|
||||||
command: command ?? this.command,
|
command: command ?? this.command,
|
||||||
createdAt: createdAt ?? this.createdAt,
|
createdAt: createdAt ?? _createdAt,
|
||||||
silent: silent ?? this.silent,
|
updatedAt: updatedAt ?? _updatedAt,
|
||||||
extraData: extraData ?? this.extraData,
|
|
||||||
user: user ?? this.user,
|
|
||||||
shadowed: shadowed ?? this.shadowed,
|
|
||||||
updatedAt: updatedAt ?? this.updatedAt,
|
|
||||||
deletedAt: deletedAt ?? this.deletedAt,
|
deletedAt: deletedAt ?? this.deletedAt,
|
||||||
status: status ?? this.status,
|
user: user ?? this.user,
|
||||||
pinned: pinned ?? this.pinned,
|
pinned: pinned ?? this.pinned,
|
||||||
pinnedAt: pinnedAt ?? this.pinnedAt,
|
pinnedAt: pinnedAt ?? this.pinnedAt,
|
||||||
pinnedBy: pinnedBy ?? this.pinnedBy,
|
|
||||||
pinExpires:
|
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,
|
i18n: i18n ?? this.i18n,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -331,6 +368,8 @@ class Message extends Equatable {
|
|||||||
type: other.type,
|
type: other.type,
|
||||||
attachments: other.attachments,
|
attachments: other.attachments,
|
||||||
mentionedUsers: other.mentionedUsers,
|
mentionedUsers: other.mentionedUsers,
|
||||||
|
silent: other.silent,
|
||||||
|
shadowed: other.shadowed,
|
||||||
reactionCounts: other.reactionCounts,
|
reactionCounts: other.reactionCounts,
|
||||||
reactionScores: other.reactionScores,
|
reactionScores: other.reactionScores,
|
||||||
latestReactions: other.latestReactions,
|
latestReactions: other.latestReactions,
|
||||||
@@ -343,17 +382,15 @@ class Message extends Equatable {
|
|||||||
showInChannel: other.showInChannel,
|
showInChannel: other.showInChannel,
|
||||||
command: other.command,
|
command: other.command,
|
||||||
createdAt: other.createdAt,
|
createdAt: other.createdAt,
|
||||||
silent: other.silent,
|
|
||||||
extraData: other.extraData,
|
|
||||||
user: other.user,
|
|
||||||
shadowed: other.shadowed,
|
|
||||||
updatedAt: other.updatedAt,
|
updatedAt: other.updatedAt,
|
||||||
deletedAt: other.deletedAt,
|
deletedAt: other.deletedAt,
|
||||||
status: other.status,
|
user: other.user,
|
||||||
pinned: other.pinned,
|
pinned: other.pinned,
|
||||||
pinnedAt: other.pinnedAt,
|
pinnedAt: other.pinnedAt,
|
||||||
pinExpires: other.pinExpires,
|
pinExpires: other.pinExpires,
|
||||||
pinnedBy: other.pinnedBy,
|
pinnedBy: other.pinnedBy,
|
||||||
|
extraData: other.extraData,
|
||||||
|
status: other.status,
|
||||||
i18n: other.i18n,
|
i18n: other.i18n,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -377,8 +414,8 @@ class Message extends Equatable {
|
|||||||
shadowed,
|
shadowed,
|
||||||
silent,
|
silent,
|
||||||
command,
|
command,
|
||||||
createdAt,
|
_createdAt,
|
||||||
updatedAt,
|
_updatedAt,
|
||||||
deletedAt,
|
deletedAt,
|
||||||
user,
|
user,
|
||||||
pinned,
|
pinned,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export 'package:dio/src/options.dart';
|
|||||||
export 'package:dio/src/options.dart' show ProgressCallback;
|
export 'package:dio/src/options.dart' show ProgressCallback;
|
||||||
export 'package:logging/logging.dart' show Logger, Level;
|
export 'package:logging/logging.dart' show Logger, Level;
|
||||||
export 'package:rate_limiter/rate_limiter.dart';
|
export 'package:rate_limiter/rate_limiter.dart';
|
||||||
|
export 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
export './src/core/api/attachment_file_uploader.dart'
|
export './src/core/api/attachment_file_uploader.dart'
|
||||||
show AttachmentFileUploader;
|
show AttachmentFileUploader;
|
||||||
|
|||||||
@@ -244,9 +244,13 @@ void main() {
|
|||||||
|
|
||||||
group('`.sendMessage`', () {
|
group('`.sendMessage`', () {
|
||||||
test('should work fine', () async {
|
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(
|
when(() => client.sendMessage(
|
||||||
any(that: isSameMessageAs(message)),
|
any(that: isSameMessageAs(message)),
|
||||||
@@ -329,6 +333,7 @@ void main() {
|
|||||||
.map((it) =>
|
.map((it) =>
|
||||||
it.copyWith(uploadState: const UploadState.success()))
|
it.copyWith(uploadState: const UploadState.success()))
|
||||||
.toList(growable: false),
|
.toList(growable: false),
|
||||||
|
status: MessageSendingStatus.sent,
|
||||||
));
|
));
|
||||||
|
|
||||||
expectLater(
|
expectLater(
|
||||||
@@ -455,7 +460,10 @@ void main() {
|
|||||||
|
|
||||||
group('`.updateMessage`', () {
|
group('`.updateMessage`', () {
|
||||||
test('should work fine', () async {
|
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()
|
final updateMessageResponse = UpdateMessageResponse()
|
||||||
..message = message;
|
..message = message;
|
||||||
@@ -530,6 +538,7 @@ void main() {
|
|||||||
any(that: isSameMessageAs(message)),
|
any(that: isSameMessageAs(message)),
|
||||||
)).thenAnswer((_) async => UpdateMessageResponse()
|
)).thenAnswer((_) async => UpdateMessageResponse()
|
||||||
..message = message.copyWith(
|
..message = message.copyWith(
|
||||||
|
status: MessageSendingStatus.sent,
|
||||||
attachments: attachments
|
attachments: attachments
|
||||||
.map((it) =>
|
.map((it) =>
|
||||||
it.copyWith(uploadState: const UploadState.success()))
|
it.copyWith(uploadState: const UploadState.success()))
|
||||||
@@ -678,7 +687,7 @@ void main() {
|
|||||||
[
|
[
|
||||||
isSameMessageAs(
|
isSameMessageAs(
|
||||||
updateMessageResponse.message.copyWith(
|
updateMessageResponse.message.copyWith(
|
||||||
status: MessageSendingStatus.sent,
|
status: MessageSendingStatus.sending,
|
||||||
),
|
),
|
||||||
matchText: true,
|
matchText: true,
|
||||||
matchSendingStatus: true,
|
matchSendingStatus: true,
|
||||||
@@ -707,7 +716,10 @@ void main() {
|
|||||||
group('`.deleteMessage`', () {
|
group('`.deleteMessage`', () {
|
||||||
test('should work fine', () async {
|
test('should work fine', () async {
|
||||||
const messageId = 'test-message-id';
|
const messageId = 'test-message-id';
|
||||||
final message = Message(id: messageId);
|
final message = Message(
|
||||||
|
id: messageId,
|
||||||
|
status: MessageSendingStatus.sent,
|
||||||
|
);
|
||||||
|
|
||||||
when(() => client.deleteMessage(messageId))
|
when(() => client.deleteMessage(messageId))
|
||||||
.thenAnswer((_) async => EmptyResponse());
|
.thenAnswer((_) async => EmptyResponse());
|
||||||
@@ -744,7 +756,6 @@ void main() {
|
|||||||
const messageId = 'test-message-id';
|
const messageId = 'test-message-id';
|
||||||
final message = Message(
|
final message = Message(
|
||||||
id: messageId,
|
id: messageId,
|
||||||
status: MessageSendingStatus.sending,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
expectLater(
|
expectLater(
|
||||||
@@ -1077,7 +1088,10 @@ void main() {
|
|||||||
group('`.sendReaction`', () {
|
group('`.sendReaction`', () {
|
||||||
test('should work fine', () async {
|
test('should work fine', () async {
|
||||||
const type = 'test-reaction-type';
|
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);
|
final reaction = Reaction(type: type, messageId: message.id);
|
||||||
|
|
||||||
@@ -1120,7 +1134,10 @@ void main() {
|
|||||||
'should restore previous message if `client.sendReaction` throws',
|
'should restore previous message if `client.sendReaction` throws',
|
||||||
() async {
|
() async {
|
||||||
const type = 'test-reaction-type';
|
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);
|
final reaction = Reaction(type: type, messageId: message.id);
|
||||||
|
|
||||||
@@ -1181,6 +1198,7 @@ void main() {
|
|||||||
latestReactions: [prevReaction],
|
latestReactions: [prevReaction],
|
||||||
reactionScores: const {prevType: 1},
|
reactionScores: const {prevType: 1},
|
||||||
reactionCounts: const {prevType: 1},
|
reactionCounts: const {prevType: 1},
|
||||||
|
status: MessageSendingStatus.sent,
|
||||||
);
|
);
|
||||||
|
|
||||||
const type = 'test-reaction-type-2';
|
const type = 'test-reaction-type-2';
|
||||||
@@ -1212,7 +1230,7 @@ void main() {
|
|||||||
emitsInOrder([
|
emitsInOrder([
|
||||||
[
|
[
|
||||||
isSameMessageAs(
|
isSameMessageAs(
|
||||||
newMessage.copyWith(status: MessageSendingStatus.sent),
|
newMessage,
|
||||||
matchReactions: true,
|
matchReactions: true,
|
||||||
matchSendingStatus: true,
|
matchSendingStatus: true,
|
||||||
),
|
),
|
||||||
@@ -1255,6 +1273,7 @@ void main() {
|
|||||||
latestReactions: [reaction],
|
latestReactions: [reaction],
|
||||||
reactionScores: const {type: 1},
|
reactionScores: const {type: 1},
|
||||||
reactionCounts: const {type: 1},
|
reactionCounts: const {type: 1},
|
||||||
|
status: MessageSendingStatus.sent,
|
||||||
);
|
);
|
||||||
|
|
||||||
when(() => client.deleteReaction(messageId, type))
|
when(() => client.deleteReaction(messageId, type))
|
||||||
@@ -1302,6 +1321,7 @@ void main() {
|
|||||||
latestReactions: [reaction],
|
latestReactions: [reaction],
|
||||||
reactionScores: const {type: 1},
|
reactionScores: const {type: 1},
|
||||||
reactionCounts: const {type: 1},
|
reactionCounts: const {type: 1},
|
||||||
|
status: MessageSendingStatus.sent,
|
||||||
);
|
);
|
||||||
|
|
||||||
when(() => client.deleteReaction(messageId, type))
|
when(() => client.deleteReaction(messageId, type))
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
🛑️ Breaking Changes
|
🛑️ Breaking Changes
|
||||||
|
|
||||||
- `pinPermissions` is no longer needed in `MessageListView`.
|
- `pinPermissions` is no longer needed in `MessageListView`.
|
||||||
|
- `MessageInput` now works with a `MessageInputController` instead of a `TextEditingController`
|
||||||
|
|
||||||
🐞 Fixed
|
🐞 Fixed
|
||||||
|
|
||||||
|
|||||||
@@ -126,7 +126,9 @@ class ThreadPage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
MessageInput(
|
MessageInput(
|
||||||
parentMessage: parent,
|
messageInputController: MessageInputController(
|
||||||
|
message: Message(parentId: parent!.id),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -166,7 +166,9 @@ class ThreadPage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
MessageInput(
|
MessageInput(
|
||||||
parentMessage: parent,
|
messageInputController: MessageInputController(
|
||||||
|
message: Message(parentId: parent!.id),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -27,9 +27,12 @@ dependencies:
|
|||||||
cupertino_icons: ^1.0.3
|
cupertino_icons: ^1.0.3
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
stream_chat_flutter: ^2.2.1
|
stream_chat_flutter:
|
||||||
stream_chat_localizations: ^1.1.0
|
path: ../
|
||||||
stream_chat_persistence: ^2.2.0
|
stream_chat_localizations:
|
||||||
|
path: ../../stream_chat_localizations
|
||||||
|
stream_chat_persistence:
|
||||||
|
path: ../../stream_chat_persistence
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import 'package:jiffy/jiffy.dart';
|
import 'package:jiffy/jiffy.dart';
|
||||||
import 'package:stream_chat_flutter/src/connection_status_builder.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_list_view.dart';
|
||||||
import 'package:stream_chat_flutter/src/message_search_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'
|
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'
|
||||||
|
|||||||
@@ -634,7 +634,9 @@ class _MessageActionsModalState extends State<MessageActionsModal> {
|
|||||||
widget.editMessageInputBuilder!(context, widget.message)
|
widget.editMessageInputBuilder!(context, widget.message)
|
||||||
else
|
else
|
||||||
MessageInput(
|
MessageInput(
|
||||||
editMessage: widget.message,
|
messageInputController: MessageInputController(
|
||||||
|
message: widget.message,
|
||||||
|
),
|
||||||
preMessageSending: (m) {
|
preMessageSending: (m) {
|
||||||
FocusScope.of(context).unfocus();
|
FocusScope.of(context).unfocus();
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
+348
-731
File diff suppressed because it is too large
Load Diff
@@ -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_core/stream_chat_flutter_core.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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import 'package:flutter/foundation.dart';
|
|||||||
import 'package:flutter/material.dart' hide TextTheme;
|
import 'package:flutter/material.dart' hide TextTheme;
|
||||||
import 'package:stream_chat_flutter/src/channel_preview.dart';
|
import 'package:stream_chat_flutter/src/channel_preview.dart';
|
||||||
import 'package:stream_chat_flutter/src/gradient_avatar.dart';
|
import 'package:stream_chat_flutter/src/gradient_avatar.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/reaction_icon.dart';
|
import 'package:stream_chat_flutter/src/reaction_icon.dart';
|
||||||
import 'package:stream_chat_flutter/src/theme/themes.dart';
|
import 'package:stream_chat_flutter/src/theme/themes.dart';
|
||||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||||
|
|||||||
@@ -23,7 +23,11 @@ export 'src/localization/stream_chat_localizations.dart';
|
|||||||
export 'src/localization/translations.dart' show DefaultTranslations;
|
export 'src/localization/translations.dart' show DefaultTranslations;
|
||||||
export 'src/mention_tile.dart';
|
export 'src/mention_tile.dart';
|
||||||
export 'src/message_action.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/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_list_view.dart';
|
||||||
export 'src/message_search_item.dart';
|
export 'src/message_search_item.dart';
|
||||||
export 'src/message_search_list_view.dart';
|
export 'src/message_search_list_view.dart';
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import 'dart:async';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:mocktail/mocktail.dart';
|
import 'package:mocktail/mocktail.dart';
|
||||||
import 'package:stream_chat_flutter/src/attachment_actions_modal.dart';
|
|
||||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||||
|
|
||||||
import 'mocks.dart';
|
import 'mocks.dart';
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:flutter_test/flutter_test.dart';
|
|||||||
import 'package:mocktail/mocktail.dart';
|
import 'package:mocktail/mocktail.dart';
|
||||||
import 'package:stream_chat_flutter/src/message_actions_modal.dart';
|
import 'package:stream_chat_flutter/src/message_actions_modal.dart';
|
||||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||||
|
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
|
||||||
|
|
||||||
import 'mocks.dart';
|
import 'mocks.dart';
|
||||||
|
|
||||||
@@ -37,6 +38,7 @@ void main() {
|
|||||||
user: User(
|
user: User(
|
||||||
id: 'user-id',
|
id: 'user-id',
|
||||||
),
|
),
|
||||||
|
status: MessageSendingStatus.sent,
|
||||||
),
|
),
|
||||||
messageWidget: const Text(
|
messageWidget: const Text(
|
||||||
'test',
|
'test',
|
||||||
@@ -194,6 +196,7 @@ void main() {
|
|||||||
user: User(
|
user: User(
|
||||||
id: 'user-id',
|
id: 'user-id',
|
||||||
),
|
),
|
||||||
|
status: MessageSendingStatus.sent,
|
||||||
),
|
),
|
||||||
messageTheme: streamTheme.ownMessageTheme,
|
messageTheme: streamTheme.ownMessageTheme,
|
||||||
),
|
),
|
||||||
@@ -240,6 +243,7 @@ void main() {
|
|||||||
user: User(
|
user: User(
|
||||||
id: 'user-id',
|
id: 'user-id',
|
||||||
),
|
),
|
||||||
|
status: MessageSendingStatus.sent,
|
||||||
),
|
),
|
||||||
messageTheme: streamTheme.ownMessageTheme,
|
messageTheme: streamTheme.ownMessageTheme,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
# Upcoming
|
||||||
|
|
||||||
|
✅ Added
|
||||||
|
|
||||||
|
- Added `MessageInputController` to hold `Message` related data.
|
||||||
|
|
||||||
## 3.3.1
|
## 3.3.1
|
||||||
|
|
||||||
- Updated `stream_chat` dependency to [`3.3.1`](https://pub.dev/packages/stream_chat/changelog).
|
- Updated `stream_chat` dependency to [`3.3.1`](https://pub.dev/packages/stream_chat/changelog).
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:rxdart/rxdart.dart';
|
import 'package:rxdart/rxdart.dart';
|
||||||
import 'package:stream_chat/stream_chat.dart';
|
import 'package:stream_chat/stream_chat.dart';
|
||||||
|
|||||||
@@ -0,0 +1,273 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:stream_chat/stream_chat.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,
|
||||||
|
}) =>
|
||||||
|
MessageInputController._(
|
||||||
|
initialMessage: message ?? Message(),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Creates a controller for an editable text field from an initial [text].
|
||||||
|
factory MessageInputController.fromText(String? text) =>
|
||||||
|
MessageInputController._(
|
||||||
|
initialMessage: Message(text: text),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Creates a controller for an editable text field from initial
|
||||||
|
/// [attachments].
|
||||||
|
factory MessageInputController.fromAttachments(
|
||||||
|
List<Attachment> attachments,
|
||||||
|
) =>
|
||||||
|
MessageInputController._(
|
||||||
|
initialMessage: Message(attachments: attachments),
|
||||||
|
);
|
||||||
|
|
||||||
|
MessageInputController._({
|
||||||
|
required Message initialMessage,
|
||||||
|
}) : _textEditingController =
|
||||||
|
TextEditingController.fromValue(TextEditingValue(
|
||||||
|
text: initialMessage.text ?? '',
|
||||||
|
composing: TextRange.collapsed(initialMessage.text?.length ?? 0),
|
||||||
|
)),
|
||||||
|
_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.
|
||||||
|
TextEditingController get textEditingController => _textEditingController;
|
||||||
|
final TextEditingController _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 = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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) {
|
||||||
|
_initialMessage = _initialMessage.copyWith(
|
||||||
|
id: const Uuid().v4(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -2,7 +2,6 @@ import 'dart:async';
|
|||||||
|
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/cupertino.dart';
|
import 'package:flutter/cupertino.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:stream_chat/stream_chat.dart';
|
import 'package:stream_chat/stream_chat.dart';
|
||||||
import 'package:stream_chat_flutter_core/src/better_stream_builder.dart';
|
import 'package:stream_chat_flutter_core/src/better_stream_builder.dart';
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export 'src/better_stream_builder.dart';
|
|||||||
export 'src/channel_list_core.dart' hide ChannelListCoreState;
|
export 'src/channel_list_core.dart' hide ChannelListCoreState;
|
||||||
export 'src/channels_bloc.dart';
|
export 'src/channels_bloc.dart';
|
||||||
export 'src/lazy_load_scroll_view.dart';
|
export 'src/lazy_load_scroll_view.dart';
|
||||||
|
export 'src/message_input_controller.dart';
|
||||||
export 'src/message_list_core.dart' hide MessageListCoreState;
|
export 'src/message_list_core.dart' hide MessageListCoreState;
|
||||||
export 'src/message_search_bloc.dart';
|
export 'src/message_search_bloc.dart';
|
||||||
export 'src/message_search_list_core.dart' hide MessageSearchListCoreState;
|
export 'src/message_search_list_core.dart' hide MessageSearchListCoreState;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ repository: https://github.com/GetStream/stream-chat-flutter
|
|||||||
issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues
|
issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=2.12.0 <3.0.0'
|
sdk: '>=2.14.0 <3.0.0'
|
||||||
flutter: ">=1.17.0"
|
flutter: ">=1.17.0"
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.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');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -157,7 +157,6 @@ void main() {
|
|||||||
(prev, curr) =>
|
(prev, curr) =>
|
||||||
prev?..update(curr.type, (value) => value + 1, ifAbsent: () => 1),
|
prev?..update(curr.type, (value) => value + 1, ifAbsent: () => 1),
|
||||||
),
|
),
|
||||||
status: MessageSendingStatus.sending,
|
|
||||||
updatedAt: DateTime.now(),
|
updatedAt: DateTime.now(),
|
||||||
extraData: const {'extra_test_data': 'extraData'},
|
extraData: const {'extra_test_data': 'extraData'},
|
||||||
user: user,
|
user: user,
|
||||||
|
|||||||
@@ -147,7 +147,6 @@ void main() {
|
|||||||
(prev, curr) =>
|
(prev, curr) =>
|
||||||
prev?..update(curr.type, (value) => value + 1, ifAbsent: () => 1),
|
prev?..update(curr.type, (value) => value + 1, ifAbsent: () => 1),
|
||||||
),
|
),
|
||||||
status: MessageSendingStatus.sending,
|
|
||||||
updatedAt: DateTime.now(),
|
updatedAt: DateTime.now(),
|
||||||
extraData: const {'extra_test_data': 'extraData'},
|
extraData: const {'extra_test_data': 'extraData'},
|
||||||
user: user,
|
user: user,
|
||||||
|
|||||||
Reference in New Issue
Block a user