Merge branch 'master' into feature/partial-update

This commit is contained in:
Deven Joshi
2021-02-18 18:56:04 +05:30
committed by GitHub
62 changed files with 2776 additions and 1340 deletions
+6
View File
@@ -1,3 +1,9 @@
## 1.1.0-beta
- Fixed minor bugs
- Add support for custom attachment upload [docs here](https://getstream.io/chat/docs/flutter-dart/file_uploads/?language=dart)
- Add support for asynchronous attachment upload
## 1.0.3-beta
- Fixed issue with disconnecting after connecting without awaiting the connection result
@@ -3,6 +3,7 @@ include: package:pedantic/analysis_options.yaml
analyzer:
exclude:
- lib/**/*.g.dart
- lib/**/*.freezed.dart
- example/*
- test/*
+276 -31
View File
@@ -2,15 +2,16 @@ import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:pedantic/pedantic.dart' show unawaited;
import 'package:dio/dio.dart';
import 'package:logging/logging.dart';
import 'package:rxdart/rxdart.dart';
import 'package:stream_chat/src/api/retry_queue.dart';
import 'package:stream_chat/src/event_type.dart';
import 'package:stream_chat/src/models/attachment_file.dart';
import 'package:stream_chat/src/models/channel_state.dart';
import 'package:stream_chat/src/models/user.dart';
import 'package:stream_chat/stream_chat.dart';
import 'package:uuid/uuid.dart';
import '../client.dart';
import '../models/event.dart';
@@ -171,19 +172,150 @@ class Channel {
/// Call [watch] to initialize the client or instantiate it using [Channel.fromState]
Future<bool> get initialized => _initializedCompleter.future;
/// Send a message to this channel
final _cancelableAttachmentUploadRequest = <String, CancelToken>{};
final _messageAttachmentsUploadCompleter = <String, Completer>{};
/// Cancels [attachmentId] upload request. Throws exception if the request hasn't
/// even started yet, Already completed or Already cancelled.
///
/// Optionally, provide a [reason] for the cancellation.
void cancelAttachmentUpload(
String attachmentId, {
String reason,
}) {
final cancelToken = _cancelableAttachmentUploadRequest[attachmentId];
if (cancelToken == null) {
throw Exception(
"Upload request for this Attachment hasn't started yet or else Already completed",
);
}
if (cancelToken.isCancelled) throw Exception('Already cancelled');
cancelToken.cancel(reason);
}
/// Retries the failed [attachmentId] upload request.
Future<void> retryAttachmentUpload(String messageId, String attachmentId) {
return _uploadAttachments(messageId, [attachmentId]);
}
Future<void> _uploadAttachments(
String messageId,
Iterable<String> attachmentIds,
) {
var message = state.messages.firstWhere(
(it) => it.id == messageId,
orElse: () => null,
);
if (message == null) {
throw Exception('Error, Message not found');
}
final attachments = message.attachments.where((it) {
if (it.uploadState.isSuccess) return false;
return attachmentIds.contains(it.id);
});
if (attachments.isEmpty) {
client.logger.info('No attachments available to upload');
if (message.attachments.every((it) => it.uploadState.isSuccess)) {
_messageAttachmentsUploadCompleter.remove(messageId)?.complete(message);
}
return Future.value();
}
client.logger.info('Found ${attachments.length} attachments');
return Future.wait(attachments.map((it) {
client.logger.info('Uploading ${it.id} attachment...');
void updateAttachment(Attachment attachment) {
final index = message.attachments.indexWhere((it) {
return it.id == attachment.id;
});
if (index != -1) {
message.attachments[index] = attachment;
state?.addMessage(message);
}
}
void onSendProgress(int sent, int total) {
updateAttachment(it.copyWith(
uploadState: UploadState.inProgress(uploaded: sent, total: total),
));
}
final isImage = it.type == 'image';
final cancelToken = CancelToken();
Future<String> future;
if (isImage) {
future = sendImage(
it.file,
onSendProgress: onSendProgress,
cancelToken: cancelToken,
).then((it) => it.file);
} else {
future = sendFile(
it.file,
onSendProgress: onSendProgress,
cancelToken: cancelToken,
).then((it) => it.file);
}
_cancelableAttachmentUploadRequest[it.id] = cancelToken;
return future.then((url) {
client.logger.info('Attachment ${it.id} uploaded successfully...');
if (isImage) {
updateAttachment(
it.copyWith(imageUrl: url, uploadState: UploadState.success()),
);
} else {
updateAttachment(
it.copyWith(assetUrl: url, uploadState: UploadState.success()),
);
}
}).catchError((e, stk) {
updateAttachment(
it.copyWith(uploadState: UploadState.failed(error: e.toString())),
);
}).whenComplete(() {
_cancelableAttachmentUploadRequest.remove(it.id);
});
})).whenComplete(() {
if (message.attachments.every((it) => it.uploadState.isSuccess)) {
_messageAttachmentsUploadCompleter.remove(messageId)?.complete(message);
}
});
}
/// Send a [message] to this channel.
/// Waits for a [_messageAttachmentsUploadCompleter] to complete
/// before actually sending the message.
Future<SendMessageResponse> sendMessage(Message message) async {
final messageId = message.id ?? Uuid().v4();
// Cancelling previous completer in case it's called again in the process
// Eg. Updating the message while the previous call is in progress.
_messageAttachmentsUploadCompleter
.remove(message.id)
?.completeError('Message Cancelled');
final quotedMessage = state?.messages?.firstWhere(
(m) => m.id == message?.quotedMessageId,
orElse: () => null,
);
final newMessage = message.copyWith(
message = message.copyWith(
createdAt: message.createdAt ?? DateTime.now(),
user: _client.state.user,
id: messageId,
quotedMessage: quotedMessage,
status: MessageSendingStatus.sending,
attachments: message.attachments?.map(
(it) {
if (it.uploadState.isSuccess) return it;
return it.copyWith(
uploadState: UploadState.inProgress(
uploaded: 0,
total: it.file?.size ?? it.extraData['file_size'],
),
);
},
)?.toList(),
);
if (message.parentId != null && message.id == null) {
@@ -195,49 +327,156 @@ class Channel {
));
}
state?.addMessage(newMessage);
state?.addMessage(message);
try {
final response = await _client.post(
'$_channelURL/message',
data: {
'message': message
.copyWith(
id: messageId,
)
.toJson()
},
);
if (message.attachments?.isNotEmpty == true) {
final attachmentsUploadCompleter = Completer<Message>();
_messageAttachmentsUploadCompleter[message.id] =
attachmentsUploadCompleter;
final res = _client.decode(response.data, SendMessageResponse.fromJson);
unawaited(_uploadAttachments(
message.id,
message.attachments.map((it) => it.id),
));
state?.addMessage(res.message);
message = await attachmentsUploadCompleter.future;
}
return res;
final response = await _client.sendMessage(message, id, type);
state?.addMessage(response.message);
return response;
} catch (error) {
if (error is DioError && error.type != DioErrorType.RESPONSE) {
state?.retryQueue?.add([newMessage]);
state?.retryQueue?.add([message]);
}
rethrow;
}
}
/// Updates the [message] in this channel.
/// Waits for a [_messageAttachmentsUploadCompleter] to complete
/// before actually updating the message.
Future<UpdateMessageResponse> updateMessage(Message message) async {
// Cancelling previous completer in case it's called again in the process
// Eg. Updating the message while the previous call is in progress.
_messageAttachmentsUploadCompleter
.remove(message.id)
?.completeError('Message Cancelled');
message = message.copyWith(
status: MessageSendingStatus.updating,
updatedAt: message.updatedAt ?? DateTime.now(),
attachments: message.attachments?.map(
(it) {
if (it.uploadState.isSuccess) return it;
return it.copyWith(
uploadState: UploadState.inProgress(
uploaded: 0,
total: it.file?.size ?? it.extraData['file_size'],
),
);
},
)?.toList(),
);
state?.addMessage(message);
try {
if (message.attachments?.isNotEmpty == true) {
final attachmentsUploadCompleter = Completer<Message>();
_messageAttachmentsUploadCompleter[message.id] =
attachmentsUploadCompleter;
unawaited(_uploadAttachments(
message.id,
message.attachments.map((it) => it.id),
));
message = await attachmentsUploadCompleter.future;
}
final response = await _client.updateMessage(message);
state?.addMessage(response?.message?.copyWith(
ownReactions: message.ownReactions,
));
return response;
} catch (error) {
if (error is DioError && error.type != DioErrorType.RESPONSE) {
state?.retryQueue?.add([message]);
}
rethrow;
}
}
/// Deletes the [message] from the channel.
Future<EmptyResponse> deleteMessage(Message message) async {
// Directly deleting the local messages which are not yet sent to server
if (message.status == MessageSendingStatus.sending ||
message.status == MessageSendingStatus.failed) {
state.addMessage(message.copyWith(
type: 'deleted',
status: MessageSendingStatus.sent,
));
// Removing the attachments upload completer to stop the `sendMessage`
// waiting for attachments to complete.
_messageAttachmentsUploadCompleter
.remove(message.id)
?.completeError(Exception('Message deleted'));
return EmptyResponse();
}
try {
message = message.copyWith(
type: 'deleted',
status: MessageSendingStatus.deleting,
deletedAt: message.deletedAt ?? DateTime.now(),
);
state?.addMessage(message);
final response = await _client.deleteMessage(message);
state?.addMessage(message.copyWith(status: MessageSendingStatus.sent));
return response;
} catch (error) {
if (error is DioError && error.type != DioErrorType.RESPONSE) {
state?.retryQueue?.add([message]);
}
rethrow;
}
}
/// Send a file to this channel
Future<SendFileResponse> sendFile(MultipartFile file) async {
final response = await _client.post(
'$_channelURL/file',
data: FormData.fromMap({'file': file}),
Future<SendFileResponse> sendFile(
AttachmentFile file, {
ProgressCallback onSendProgress,
CancelToken cancelToken,
}) {
return _client.sendFile(
file,
id,
type,
onSendProgress: onSendProgress,
cancelToken: cancelToken,
);
return _client.decode(response.data, SendFileResponse.fromJson);
}
/// Send an image to this channel
Future<SendImageResponse> sendImage(MultipartFile file) async {
final response = await _client.post(
'$_channelURL/image',
data: FormData.fromMap({'file': file}),
Future<SendImageResponse> sendImage(
AttachmentFile file, {
ProgressCallback onSendProgress,
CancelToken cancelToken,
}) {
return _client.sendImage(
file,
id,
type,
onSendProgress: onSendProgress,
cancelToken: cancelToken,
);
return _client.decode(response.data, SendImageResponse.fromJson);
}
/// Delete a file from this channel
@@ -974,7 +1213,13 @@ class ChannelClientState {
?.getChannelThreads(_channel.cid)
?.then((threads) {
_threads = threads;
retryFailedMessages();
})?.then((_) {
_channel._client.chatPersistenceClient
?.getChannelStateByCid(_channel.cid)
?.then((state) {
updateChannelState(state);
retryFailedMessages();
});
});
}
@@ -125,21 +125,13 @@ class RetryQueue {
Future<void> _sendMessage(Message message) async {
if (message.status == MessageSendingStatus.failed_update ||
message.status == MessageSendingStatus.updating) {
await channel.client.updateMessage(
message,
channel.cid,
);
await channel.updateMessage(message);
} else if (message.status == MessageSendingStatus.failed ||
message.status == MessageSendingStatus.sending) {
await channel.sendMessage(
message,
);
await channel.sendMessage(message);
} else if (message.status == MessageSendingStatus.failed_delete ||
message.status == MessageSendingStatus.deleting) {
await channel.client.deleteMessage(
message,
channel.cid,
);
await channel.deleteMessage(message);
}
}
@@ -0,0 +1,92 @@
import 'package:dio/dio.dart';
import 'package:stream_chat/src/api/responses.dart';
import 'package:stream_chat/src/models/attachment_file.dart';
import 'client.dart';
import 'extensions/string_extension.dart';
/// Class responsible for uploading images and files to a given channel
abstract class AttachmentFileUploader {
/// Uploads a [image] to the given channel.
/// Returns [SendImageResponse] once sent successfully.
///
/// Optionally, access upload progress using [onSendProgress]
/// and cancel the request using [cancelToken]
Future<SendImageResponse> sendImage(
AttachmentFile image,
String channelId,
String channelType, {
ProgressCallback onSendProgress,
CancelToken cancelToken,
});
/// Uploads a [file] to the given channel.
/// Returns [SendFileResponse] once sent successfully.
///
/// Optionally, access upload progress using [onSendProgress]
/// and cancel the request using [cancelToken]
Future<SendFileResponse> sendFile(
AttachmentFile file,
String channelId,
String channelType, {
ProgressCallback onSendProgress,
CancelToken cancelToken,
});
}
/// Stream's default implementation of [AttachmentFileUploader]
class StreamAttachmentFileUploader implements AttachmentFileUploader {
final StreamChatClient _client;
/// Creates a new [StreamAttachmentFileUploader] instance.
const StreamAttachmentFileUploader(this._client);
@override
Future<SendImageResponse> sendImage(
AttachmentFile file,
String channelId,
String channelType, {
ProgressCallback onSendProgress,
CancelToken cancelToken,
}) async {
final filename = file.path?.split('/')?.last;
final mimeType = filename.mimeType;
final response = await _client.post(
'/channels/$channelType/$channelId/image',
data: FormData.fromMap({
'file': await MultipartFile.fromFile(
file.path,
filename: filename,
contentType: mimeType,
),
}),
onSendProgress: onSendProgress,
cancelToken: cancelToken,
);
return _client.decode(response.data, SendImageResponse.fromJson);
}
@override
Future<SendFileResponse> sendFile(
AttachmentFile file,
String channelId,
String channelType, {
ProgressCallback onSendProgress,
CancelToken cancelToken,
}) async {
final filename = file.path?.split('/')?.last;
final mimeType = filename.mimeType;
final response = await _client.post(
'/channels/$channelType/$channelId/file',
data: FormData.fromMap({
'file': await MultipartFile.fromFile(
file.path,
filename: filename,
contentType: mimeType,
),
}),
onSendProgress: onSendProgress,
cancelToken: cancelToken,
);
return _client.decode(response.data, SendFileResponse.fromJson);
}
}
+68 -68
View File
@@ -8,10 +8,12 @@ import 'package:pedantic/pedantic.dart' show unawaited;
import 'package:rxdart/rxdart.dart';
import 'package:stream_chat/src/api/retry_policy.dart';
import 'package:stream_chat/src/event_type.dart';
import 'package:stream_chat/src/models/attachment_file.dart';
import 'package:stream_chat/src/models/own_user.dart';
import 'package:stream_chat/version.dart';
import 'package:uuid/uuid.dart';
import 'attachment_file_uploader.dart';
import 'api/channel.dart';
import 'api/connection_status.dart';
import 'api/requests.dart';
@@ -78,6 +80,7 @@ class StreamChatClient {
Duration receiveTimeout = const Duration(seconds: 6),
Dio httpClient,
RetryPolicy retryPolicy,
this.attachmentFileUploader,
}) {
_retryPolicy ??= RetryPolicy(
retryTimeout: (StreamChatClient client, int attempt, ApiError error) =>
@@ -86,6 +89,8 @@ class StreamChatClient {
attempt < 5,
);
attachmentFileUploader ??= StreamAttachmentFileUploader(this);
state = ClientState(this);
_setupLogger();
@@ -97,6 +102,9 @@ class StreamChatClient {
/// Chat persistence client
ChatPersistenceClient chatPersistenceClient;
/// Attachment uploader
AttachmentFileUploader attachmentFileUploader;
/// Whether the chat persistence is available or not
bool get persistenceEnabled => chatPersistenceClient != null;
@@ -794,9 +802,16 @@ class StreamChatClient {
Future<Response<String>> post(
String path, {
dynamic data,
ProgressCallback onSendProgress,
CancelToken cancelToken,
}) async {
try {
final response = await httpClient.post<String>(path, data: data);
final response = await httpClient.post<String>(
path,
data: data,
onSendProgress: onSendProgress,
cancelToken: cancelToken,
);
return response;
} on DioError catch (error) {
throw _parseError(error);
@@ -1027,6 +1042,40 @@ class StreamChatClient {
response.data, SearchMessagesResponse.fromJson);
}
/// Send a [file] to the [channelId] of type [channelType]
Future<SendFileResponse> sendFile(
AttachmentFile file,
String channelId,
String channelType, {
ProgressCallback onSendProgress,
CancelToken cancelToken,
}) {
return attachmentFileUploader.sendFile(
file,
channelId,
channelType,
onSendProgress: onSendProgress,
cancelToken: cancelToken,
);
}
/// Send a [image] to the [channelId] of type [channelType]
Future<SendImageResponse> sendImage(
AttachmentFile image,
String channelId,
String channelType, {
ProgressCallback onSendProgress,
CancelToken cancelToken,
}) {
return attachmentFileUploader.sendImage(
image,
channelId,
channelType,
onSendProgress: onSendProgress,
cancelToken: cancelToken,
);
}
/// Add a device for Push Notifications.
Future<EmptyResponse> addDevice(String id, PushProvider pushProvider) async {
final response = await post('/devices', data: {
@@ -1198,78 +1247,29 @@ class StreamChatClient {
return decode(response.data, EmptyResponse.fromJson);
}
/// Update the given message
Future<UpdateMessageResponse> updateMessage(
Message message, [
String cid,
]) async {
message = message.copyWith(
status: MessageSendingStatus.updating,
updatedAt: message.updatedAt ?? DateTime.now(),
/// Sends the message to the given channel
Future<SendMessageResponse> sendMessage(
Message message, String channelId, String channelType) async {
final response = await post(
'/channels/$channelType/$channelId/message',
data: {'message': message.toJson()},
);
return decode(response.data, SendMessageResponse.fromJson);
}
final channel = state?.channels != null ? state?.channels[cid] : null;
channel?.state?.addMessage(message);
return post('/messages/${message.id}', data: {'message': message})
.then((res) {
final updateMessageResponse = decode(
res?.data,
UpdateMessageResponse.fromJson,
);
channel?.state?.addMessage(updateMessageResponse?.message?.copyWith(
ownReactions: message.ownReactions,
));
return updateMessageResponse;
}).catchError((error) {
if (error is DioError &&
error.type != DioErrorType.RESPONSE &&
state?.channels != null) {
channel?.state?.retryQueue?.add([message]);
}
throw error;
});
/// Update the given message
Future<UpdateMessageResponse> updateMessage(Message message) async {
final response = await post(
'/messages/${message.id}',
data: {'message': message},
);
return decode(response.data, UpdateMessageResponse.fromJson);
}
/// Deletes the given message
Future<EmptyResponse> deleteMessage(Message message, [String cid]) async {
if (message.status == MessageSendingStatus.failed) {
state.channels[cid].state.addMessage(message.copyWith(
type: 'deleted',
status: MessageSendingStatus.sent,
));
return EmptyResponse();
}
try {
message = message.copyWith(
type: 'deleted',
status: MessageSendingStatus.deleting,
deletedAt: message.deletedAt ?? DateTime.now(),
);
if (state?.channels != null) {
state.channels[cid]?.state?.addMessage(message);
}
final response = await delete('/messages/${message.id}');
if (state?.channels != null) {
state.channels[cid]?.state
?.addMessage(message.copyWith(status: MessageSendingStatus.sent));
}
return decode(response.data, EmptyResponse.fromJson);
} catch (error) {
if (error is DioError &&
error.type != DioErrorType.RESPONSE &&
state?.channels != null) {
state.channels[cid]?.state?.retryQueue?.add([message]);
}
rethrow;
}
Future<EmptyResponse> deleteMessage(Message message) async {
final response = await delete('/messages/${message.id}');
return decode(response.data, EmptyResponse.fromJson);
}
/// Get a message by id
@@ -0,0 +1,15 @@
import 'package:http_parser/http_parser.dart' as http_parser;
import 'package:mime/mime.dart';
/// Useful extension functions for [String]
extension StringX on String {
/// Returns the mime type from the passed file name.
http_parser.MediaType get mimeType {
if (this == null) return null;
if (toLowerCase().endsWith('heic')) {
return http_parser.MediaType.parse('image/heic');
} else {
return http_parser.MediaType.parse(lookupMimeType(this));
}
}
}
@@ -1,6 +1,8 @@
// ignore_for_file: public_member_api_docs
import 'package:json_annotation/json_annotation.dart';
import 'package:stream_chat/src/models/attachment_file.dart';
import 'package:uuid/uuid.dart';
import 'action.dart';
import 'serialization.dart';
@@ -52,10 +54,21 @@ class Attachment {
final Uri localUri;
/// The file present inside this attachment.
final AttachmentFile file;
/// The current upload state of the attachment
final UploadState uploadState;
/// Map of custom channel extraData
@JsonKey(includeIfNull: false)
final Map<String, dynamic> extraData;
/// The attachment ID.
///
/// This is created locally for uniquely identifying a attachment.
final String id;
/// Known top level fields.
/// Useful for [Serialization] methods.
static const topLevelFields = [
@@ -79,11 +92,20 @@ class Attachment {
'actions',
];
/// Known db specific top level fields.
/// Useful for [Serialization] methods.
static const dbSpecificTopLevelFields = [
'id',
'upload_state',
'file',
];
/// Constructor used for json serialization
Attachment({
String id,
this.type,
this.titleLink,
this.title,
String title,
this.thumbUrl,
this.text,
this.pretext,
@@ -100,8 +122,11 @@ class Attachment {
this.assetUrl,
this.actions,
this.extraData,
this.localUri,
});
this.file,
this.uploadState,
}) : id = id ?? Uuid().v4(),
title = title ?? file?.name,
localUri = file?.path != null ? Uri.parse(file.path) : null;
/// Create a new instance from a json
factory Attachment.fromJson(Map<String, dynamic> json) {
@@ -111,9 +136,21 @@ class Attachment {
/// Serialize to json
Map<String, dynamic> toJson() => Serialization.moveFromExtraDataToRoot(
_$AttachmentToJson(this), topLevelFields);
_$AttachmentToJson(this), topLevelFields)
..removeWhere((key, value) => dbSpecificTopLevelFields.contains(key));
/// Create a new instance from a db data
factory Attachment.fromData(Map<String, dynamic> json) {
return _$AttachmentFromJson(Serialization.moveToExtraDataFromRoot(
json, topLevelFields + dbSpecificTopLevelFields));
}
/// Serialize to db data
Map<String, dynamic> toData() => Serialization.moveFromExtraDataToRoot(
_$AttachmentToJson(this), topLevelFields + dbSpecificTopLevelFields);
Attachment copyWith({
String id,
String type,
String titleLink,
String title,
@@ -132,10 +169,12 @@ class Attachment {
String authorIcon,
String assetUrl,
List<Action> actions,
Uri localUri,
AttachmentFile file,
UploadState uploadState,
Map<String, dynamic> extraData,
}) =>
Attachment(
id: id ?? this.id,
type: type ?? this.type,
titleLink: titleLink ?? this.titleLink,
title: title ?? this.title,
@@ -154,7 +193,8 @@ class Attachment {
authorIcon: authorIcon ?? this.authorIcon,
assetUrl: assetUrl ?? this.assetUrl,
actions: actions ?? this.actions,
localUri: localUri ?? this.localUri,
file: file ?? this.file,
uploadState: uploadState ?? this.uploadState,
extraData: extraData ?? this.extraData,
);
@@ -8,6 +8,7 @@ part of 'attachment.dart';
Attachment _$AttachmentFromJson(Map json) {
return Attachment(
id: json['id'] as String,
type: json['type'] as String,
titleLink: json['title_link'] as String,
title: json['title'] as String,
@@ -35,9 +36,16 @@ Attachment _$AttachmentFromJson(Map json) {
extraData: (json['extra_data'] as Map)?.map(
(k, e) => MapEntry(k as String, e),
),
localUri: json['local_uri'] == null
file: json['file'] == null
? null
: Uri.parse(json['local_uri'] as String),
: AttachmentFile.fromJson((json['file'] as Map)?.map(
(k, e) => MapEntry(k as String, e),
)),
uploadState: json['upload_state'] == null
? null
: UploadState.fromJson((json['upload_state'] as Map)?.map(
(k, e) => MapEntry(k as String, e),
)),
);
}
@@ -68,7 +76,9 @@ Map<String, dynamic> _$AttachmentToJson(Attachment instance) {
writeNotNull('author_icon', instance.authorIcon);
writeNotNull('asset_url', instance.assetUrl);
writeNotNull('actions', instance.actions?.map((e) => e?.toJson())?.toList());
writeNotNull('local_uri', instance.localUri?.toString());
writeNotNull('file', instance.file?.toJson());
writeNotNull('upload_state', instance.uploadState?.toJson());
writeNotNull('extra_data', instance.extraData);
writeNotNull('id', instance.id);
return val;
}
@@ -0,0 +1,82 @@
import 'dart:typed_data';
import 'package:meta/meta.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'attachment_file.freezed.dart';
part 'attachment_file.g.dart';
/// Union class to hold various [UploadState] of a attachment.
@freezed
abstract class UploadState with _$UploadState {
/// InProgress state of the union
const factory UploadState.inProgress({int uploaded, int total}) = InProgress;
/// Success state of the union
const factory UploadState.success() = Success;
/// Failed state of the union
const factory UploadState.failed({@required String error}) = Failed;
/// Creates a new instance from a json
factory UploadState.fromJson(Map<String, dynamic> json) =>
_$UploadStateFromJson(json);
}
/// Helper extension for UploadState
extension UploadStateX on UploadState {
/// Returns true if state is [InProgress]
bool get isInProgress => this is InProgress;
/// Returns true if state is [Success]
bool get isSuccess => this is Success;
/// Returns true if state is [Failed]
bool get isFailed => this is Failed;
}
Uint8List _fromString(String bytes) => Uint8List.fromList(bytes.codeUnits);
String _toString(Uint8List bytes) => String.fromCharCodes(bytes);
/// The class that contains the information about an attachment file
@JsonSerializable()
class AttachmentFile {
/// Creates a new [AttachmentFile] instance.
const AttachmentFile({
this.path,
this.name,
this.bytes,
this.size,
});
/// The absolute path for a cached copy of this file. It can be used to create a
/// file instance with a descriptor for the given path.
/// ```
/// final File myFile = File(platformFile.path);
/// ```
final String path;
/// File name including its extension.
final String name;
/// Byte data for this file. Particularly useful if you want to manipulate its data
/// or easily upload to somewhere else.
@JsonKey(toJson: _toString, fromJson: _fromString)
final Uint8List bytes;
/// The file size in bytes.
final int size;
/// File extension for this file.
String get extension => name?.split('.')?.last;
/// Create a new instance from a json
factory AttachmentFile.fromJson(Map<String, dynamic> json) {
return _$AttachmentFileFromJson(json);
}
/// Serialize to json
Map<String, dynamic> toJson() => _$AttachmentFileToJson(this);
}
@@ -0,0 +1,486 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies
part of 'attachment_file.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
T _$identity<T>(T value) => value;
UploadState _$UploadStateFromJson(Map<String, dynamic> json) {
switch (json['runtimeType'] as String) {
case 'inProgress':
return InProgress.fromJson(json);
case 'success':
return Success.fromJson(json);
case 'failed':
return Failed.fromJson(json);
default:
throw FallThroughError();
}
}
/// @nodoc
class _$UploadStateTearOff {
const _$UploadStateTearOff();
// ignore: unused_element
InProgress inProgress({int uploaded, int total}) {
return InProgress(
uploaded: uploaded,
total: total,
);
}
// ignore: unused_element
Success success() {
return const Success();
}
// ignore: unused_element
Failed failed({@required String error}) {
return Failed(
error: error,
);
}
// ignore: unused_element
UploadState fromJson(Map<String, Object> json) {
return UploadState.fromJson(json);
}
}
/// @nodoc
// ignore: unused_element
const $UploadState = _$UploadStateTearOff();
/// @nodoc
mixin _$UploadState {
@optionalTypeArgs
TResult when<TResult extends Object>({
@required TResult inProgress(int uploaded, int total),
@required TResult success(),
@required TResult failed(String error),
});
@optionalTypeArgs
TResult maybeWhen<TResult extends Object>({
TResult inProgress(int uploaded, int total),
TResult success(),
TResult failed(String error),
@required TResult orElse(),
});
@optionalTypeArgs
TResult map<TResult extends Object>({
@required TResult inProgress(InProgress value),
@required TResult success(Success value),
@required TResult failed(Failed value),
});
@optionalTypeArgs
TResult maybeMap<TResult extends Object>({
TResult inProgress(InProgress value),
TResult success(Success value),
TResult failed(Failed value),
@required TResult orElse(),
});
Map<String, dynamic> toJson();
}
/// @nodoc
abstract class $UploadStateCopyWith<$Res> {
factory $UploadStateCopyWith(
UploadState value, $Res Function(UploadState) then) =
_$UploadStateCopyWithImpl<$Res>;
}
/// @nodoc
class _$UploadStateCopyWithImpl<$Res> implements $UploadStateCopyWith<$Res> {
_$UploadStateCopyWithImpl(this._value, this._then);
final UploadState _value;
// ignore: unused_field
final $Res Function(UploadState) _then;
}
/// @nodoc
abstract class $InProgressCopyWith<$Res> {
factory $InProgressCopyWith(
InProgress value, $Res Function(InProgress) then) =
_$InProgressCopyWithImpl<$Res>;
$Res call({int uploaded, int total});
}
/// @nodoc
class _$InProgressCopyWithImpl<$Res> extends _$UploadStateCopyWithImpl<$Res>
implements $InProgressCopyWith<$Res> {
_$InProgressCopyWithImpl(InProgress _value, $Res Function(InProgress) _then)
: super(_value, (v) => _then(v as InProgress));
@override
InProgress get _value => super._value as InProgress;
@override
$Res call({
Object uploaded = freezed,
Object total = freezed,
}) {
return _then(InProgress(
uploaded: uploaded == freezed ? _value.uploaded : uploaded as int,
total: total == freezed ? _value.total : total as int,
));
}
}
@JsonSerializable()
/// @nodoc
class _$InProgress implements InProgress {
const _$InProgress({this.uploaded, this.total});
factory _$InProgress.fromJson(Map<String, dynamic> json) =>
_$_$InProgressFromJson(json);
@override
final int uploaded;
@override
final int total;
@override
String toString() {
return 'UploadState.inProgress(uploaded: $uploaded, total: $total)';
}
@override
bool operator ==(dynamic other) {
return identical(this, other) ||
(other is InProgress &&
(identical(other.uploaded, uploaded) ||
const DeepCollectionEquality()
.equals(other.uploaded, uploaded)) &&
(identical(other.total, total) ||
const DeepCollectionEquality().equals(other.total, total)));
}
@override
int get hashCode =>
runtimeType.hashCode ^
const DeepCollectionEquality().hash(uploaded) ^
const DeepCollectionEquality().hash(total);
@JsonKey(ignore: true)
@override
$InProgressCopyWith<InProgress> get copyWith =>
_$InProgressCopyWithImpl<InProgress>(this, _$identity);
@override
@optionalTypeArgs
TResult when<TResult extends Object>({
@required TResult inProgress(int uploaded, int total),
@required TResult success(),
@required TResult failed(String error),
}) {
assert(inProgress != null);
assert(success != null);
assert(failed != null);
return inProgress(uploaded, total);
}
@override
@optionalTypeArgs
TResult maybeWhen<TResult extends Object>({
TResult inProgress(int uploaded, int total),
TResult success(),
TResult failed(String error),
@required TResult orElse(),
}) {
assert(orElse != null);
if (inProgress != null) {
return inProgress(uploaded, total);
}
return orElse();
}
@override
@optionalTypeArgs
TResult map<TResult extends Object>({
@required TResult inProgress(InProgress value),
@required TResult success(Success value),
@required TResult failed(Failed value),
}) {
assert(inProgress != null);
assert(success != null);
assert(failed != null);
return inProgress(this);
}
@override
@optionalTypeArgs
TResult maybeMap<TResult extends Object>({
TResult inProgress(InProgress value),
TResult success(Success value),
TResult failed(Failed value),
@required TResult orElse(),
}) {
assert(orElse != null);
if (inProgress != null) {
return inProgress(this);
}
return orElse();
}
@override
Map<String, dynamic> toJson() {
return _$_$InProgressToJson(this)..['runtimeType'] = 'inProgress';
}
}
abstract class InProgress implements UploadState {
const factory InProgress({int uploaded, int total}) = _$InProgress;
factory InProgress.fromJson(Map<String, dynamic> json) =
_$InProgress.fromJson;
int get uploaded;
int get total;
@JsonKey(ignore: true)
$InProgressCopyWith<InProgress> get copyWith;
}
/// @nodoc
abstract class $SuccessCopyWith<$Res> {
factory $SuccessCopyWith(Success value, $Res Function(Success) then) =
_$SuccessCopyWithImpl<$Res>;
}
/// @nodoc
class _$SuccessCopyWithImpl<$Res> extends _$UploadStateCopyWithImpl<$Res>
implements $SuccessCopyWith<$Res> {
_$SuccessCopyWithImpl(Success _value, $Res Function(Success) _then)
: super(_value, (v) => _then(v as Success));
@override
Success get _value => super._value as Success;
}
@JsonSerializable()
/// @nodoc
class _$Success implements Success {
const _$Success();
factory _$Success.fromJson(Map<String, dynamic> json) =>
_$_$SuccessFromJson(json);
@override
String toString() {
return 'UploadState.success()';
}
@override
bool operator ==(dynamic other) {
return identical(this, other) || (other is Success);
}
@override
int get hashCode => runtimeType.hashCode;
@override
@optionalTypeArgs
TResult when<TResult extends Object>({
@required TResult inProgress(int uploaded, int total),
@required TResult success(),
@required TResult failed(String error),
}) {
assert(inProgress != null);
assert(success != null);
assert(failed != null);
return success();
}
@override
@optionalTypeArgs
TResult maybeWhen<TResult extends Object>({
TResult inProgress(int uploaded, int total),
TResult success(),
TResult failed(String error),
@required TResult orElse(),
}) {
assert(orElse != null);
if (success != null) {
return success();
}
return orElse();
}
@override
@optionalTypeArgs
TResult map<TResult extends Object>({
@required TResult inProgress(InProgress value),
@required TResult success(Success value),
@required TResult failed(Failed value),
}) {
assert(inProgress != null);
assert(success != null);
assert(failed != null);
return success(this);
}
@override
@optionalTypeArgs
TResult maybeMap<TResult extends Object>({
TResult inProgress(InProgress value),
TResult success(Success value),
TResult failed(Failed value),
@required TResult orElse(),
}) {
assert(orElse != null);
if (success != null) {
return success(this);
}
return orElse();
}
@override
Map<String, dynamic> toJson() {
return _$_$SuccessToJson(this)..['runtimeType'] = 'success';
}
}
abstract class Success implements UploadState {
const factory Success() = _$Success;
factory Success.fromJson(Map<String, dynamic> json) = _$Success.fromJson;
}
/// @nodoc
abstract class $FailedCopyWith<$Res> {
factory $FailedCopyWith(Failed value, $Res Function(Failed) then) =
_$FailedCopyWithImpl<$Res>;
$Res call({String error});
}
/// @nodoc
class _$FailedCopyWithImpl<$Res> extends _$UploadStateCopyWithImpl<$Res>
implements $FailedCopyWith<$Res> {
_$FailedCopyWithImpl(Failed _value, $Res Function(Failed) _then)
: super(_value, (v) => _then(v as Failed));
@override
Failed get _value => super._value as Failed;
@override
$Res call({
Object error = freezed,
}) {
return _then(Failed(
error: error == freezed ? _value.error : error as String,
));
}
}
@JsonSerializable()
/// @nodoc
class _$Failed implements Failed {
const _$Failed({@required this.error}) : assert(error != null);
factory _$Failed.fromJson(Map<String, dynamic> json) =>
_$_$FailedFromJson(json);
@override
final String error;
@override
String toString() {
return 'UploadState.failed(error: $error)';
}
@override
bool operator ==(dynamic other) {
return identical(this, other) ||
(other is Failed &&
(identical(other.error, error) ||
const DeepCollectionEquality().equals(other.error, error)));
}
@override
int get hashCode =>
runtimeType.hashCode ^ const DeepCollectionEquality().hash(error);
@JsonKey(ignore: true)
@override
$FailedCopyWith<Failed> get copyWith =>
_$FailedCopyWithImpl<Failed>(this, _$identity);
@override
@optionalTypeArgs
TResult when<TResult extends Object>({
@required TResult inProgress(int uploaded, int total),
@required TResult success(),
@required TResult failed(String error),
}) {
assert(inProgress != null);
assert(success != null);
assert(failed != null);
return failed(error);
}
@override
@optionalTypeArgs
TResult maybeWhen<TResult extends Object>({
TResult inProgress(int uploaded, int total),
TResult success(),
TResult failed(String error),
@required TResult orElse(),
}) {
assert(orElse != null);
if (failed != null) {
return failed(error);
}
return orElse();
}
@override
@optionalTypeArgs
TResult map<TResult extends Object>({
@required TResult inProgress(InProgress value),
@required TResult success(Success value),
@required TResult failed(Failed value),
}) {
assert(inProgress != null);
assert(success != null);
assert(failed != null);
return failed(this);
}
@override
@optionalTypeArgs
TResult maybeMap<TResult extends Object>({
TResult inProgress(InProgress value),
TResult success(Success value),
TResult failed(Failed value),
@required TResult orElse(),
}) {
assert(orElse != null);
if (failed != null) {
return failed(this);
}
return orElse();
}
@override
Map<String, dynamic> toJson() {
return _$_$FailedToJson(this)..['runtimeType'] = 'failed';
}
}
abstract class Failed implements UploadState {
const factory Failed({@required String error}) = _$Failed;
factory Failed.fromJson(Map<String, dynamic> json) = _$Failed.fromJson;
String get error;
@JsonKey(ignore: true)
$FailedCopyWith<Failed> get copyWith;
}
@@ -0,0 +1,54 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'attachment_file.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
AttachmentFile _$AttachmentFileFromJson(Map json) {
return AttachmentFile(
path: json['path'] as String,
name: json['name'] as String,
bytes: _fromString(json['bytes'] as String),
size: json['size'] as int,
);
}
Map<String, dynamic> _$AttachmentFileToJson(AttachmentFile instance) =>
<String, dynamic>{
'path': instance.path,
'name': instance.name,
'bytes': _toString(instance.bytes),
'size': instance.size,
};
_$InProgress _$_$InProgressFromJson(Map json) {
return _$InProgress(
uploaded: json['uploaded'] as int,
total: json['total'] as int,
);
}
Map<String, dynamic> _$_$InProgressToJson(_$InProgress instance) =>
<String, dynamic>{
'uploaded': instance.uploaded,
'total': instance.total,
};
_$Success _$_$SuccessFromJson(Map json) {
return _$Success();
}
Map<String, dynamic> _$_$SuccessToJson(_$Success instance) =>
<String, dynamic>{};
_$Failed _$_$FailedFromJson(Map json) {
return _$Failed(
error: json['error'] as String,
);
}
Map<String, dynamic> _$_$FailedToJson(_$Failed instance) => <String, dynamic>{
'error': instance.error,
};
@@ -1,4 +1,5 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:uuid/uuid.dart';
import 'attachment.dart';
import 'reaction.dart';
@@ -163,7 +164,7 @@ class Message {
/// Constructor used for json serialization
Message({
this.id,
String id,
this.text,
this.type,
this.attachments,
@@ -187,7 +188,7 @@ class Message {
this.extraData,
this.deletedAt,
this.status = MessageSendingStatus.sent,
});
}) : id = id ?? Uuid().v4();
/// Create a new instance from a json
factory Message.fromJson(Map<String, dynamic> json) => _$MessageFromJson(
@@ -2,6 +2,7 @@ library stream_chat;
export 'package:dio/src/dio_error.dart';
export 'package:dio/src/multipart_file.dart';
export 'package:dio/src/options.dart' show ProgressCallback;
export 'package:logging/logging.dart' show Logger, Level;
export './src/api/channel.dart';
@@ -9,10 +10,12 @@ export './src/api/connection_status.dart';
export './src/api/requests.dart';
export './src/api/requests.dart';
export './src/api/responses.dart';
export './src/attachment_file_uploader.dart' show AttachmentFileUploader;
export './src/client.dart';
export './src/event_type.dart';
export './src/models/action.dart';
export './src/models/attachment.dart';
export './src/models/attachment_file.dart';
export './src/models/channel_config.dart';
export './src/models/channel_model.dart';
export './src/models/channel_state.dart';
@@ -26,4 +29,5 @@ export './src/models/own_user.dart';
export './src/models/reaction.dart';
export './src/models/read.dart';
export './src/models/user.dart';
export './src/extensions/string_extension.dart';
export './src/db/chat_persistence_client.dart';
+1 -1
View File
@@ -2,4 +2,4 @@ import 'package:stream_chat/src/client.dart';
/// Current package version
/// Used in [StreamChatClient] to build the `x-stream-client` header
const PACKAGE_VERSION = '1.0.3-beta';
const PACKAGE_VERSION = '1.1.0-beta';
+5 -1
View File
@@ -1,7 +1,7 @@
name: stream_chat
homepage: https://getstream.io/
description: The official Dart client for Stream Chat, a service for building chat applications.
version: 1.0.3-beta
version: 1.1.0-beta
repository: https://github.com/GetStream/stream-chat-flutter
issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues
@@ -19,9 +19,13 @@ dependencies:
collection: ^1.14.13
pedantic: ^1.9.2
meta: ^1.2.4
mime: ^0.9.7
freezed_annotation: ^0.12.0
http_parser: ^3.1.4
dev_dependencies:
build_runner: ^1.10.0
json_serializable: ^3.3.0
test: ^1.15.7
mockito: ^4.1.1
freezed: ^0.12.7
@@ -10,8 +10,12 @@ import 'package:stream_chat/src/models/reaction.dart';
import 'package:stream_chat/src/models/own_user.dart';
import 'package:test/test.dart';
import 'package:stream_chat/stream_chat.dart';
class MockDio extends Mock implements DioForNative {}
class MockAttachmentUploader extends Mock implements AttachmentFileUploader {}
class MockHttpClientAdapter extends Mock implements HttpClientAdapter {}
void main() {
@@ -170,6 +174,11 @@ void main() {
test('sendFile', () async {
final mockDio = MockDio();
final mockUploader = MockAttachmentUploader();
final file = AttachmentFile(path: 'filePath/fileName.pdf');
final channelId = 'testId';
final channelType = 'messaging';
when(mockDio.options).thenReturn(BaseOptions());
when(mockDio.interceptors).thenReturn(Interceptors());
@@ -178,23 +187,25 @@ void main() {
'api-key',
httpClient: mockDio,
tokenProvider: (_) async => '',
attachmentFileUploader: mockUploader,
);
final channelClient = client.channel('messaging', id: 'testid');
final file = MultipartFile.fromString('file');
final channelClient = client.channel(channelType, id: channelId);
when(mockDio.post<String>('/channels/messaging/testid/file',
data: argThat(isA<FormData>(), named: 'data')))
.thenAnswer((_) async => Response(data: '{}', statusCode: 200));
when(mockUploader.sendFile(file, channelId, channelType))
.thenAnswer((_) async => SendFileResponse());
await channelClient.sendFile(file);
verify(mockDio.post<String>('/channels/messaging/testid/file',
data: argThat(isA<FormData>(), named: 'data')))
.called(1);
verify(mockUploader.sendFile(file, channelId, channelType)).called(1);
});
test('sendImage', () async {
final mockDio = MockDio();
final mockUploader = MockAttachmentUploader();
final image = AttachmentFile(path: 'imagePath/imageName.jpeg');
final channelId = 'testId';
final channelType = 'messaging';
when(mockDio.options).thenReturn(BaseOptions());
when(mockDio.interceptors).thenReturn(Interceptors());
@@ -203,19 +214,16 @@ void main() {
'api-key',
httpClient: mockDio,
tokenProvider: (_) async => '',
attachmentFileUploader: mockUploader,
);
final channelClient = client.channel('messaging', id: 'testid');
final file = MultipartFile.fromString('file');
final channelClient = client.channel(channelType, id: channelId);
when(mockDio.post<String>('/channels/messaging/testid/image',
data: argThat(isA<FormData>(), named: 'data')))
.thenAnswer((_) async => Response(data: '{}', statusCode: 200));
when(mockUploader.sendImage(image, channelId, channelType))
.thenAnswer((_) async => SendImageResponse());
await channelClient.sendImage(file);
await channelClient.sendImage(image);
verify(mockDio.post<String>('/channels/messaging/testid/image',
data: argThat(isA<FormData>(), named: 'data')))
.called(1);
verify(mockUploader.sendImage(image, channelId, channelType)).called(1);
});
test('deleteFile', () async {
@@ -1,3 +1,10 @@
## 1.1.0-beta
- Update stream_chat_core dependency
- Expose common builders in ListView widgets
- Add support for asynchronous attachment upload while sending a message
- Fixed minor bugs
## 1.0.2-beta
- Update stream_chat_core dependency
@@ -8,6 +8,7 @@
/* Begin PBXBuildFile section */
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
1589B944F51366A883B3A7A5 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4AFDB673F1C9808CE4EC418F /* Pods_Runner.framework */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
@@ -31,7 +32,11 @@
/* Begin PBXFileReference section */
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
24CE22BB301621B9BF1A7A7C /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
4684439012E1DB1A82103E26 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
4AFDB673F1C9808CE4EC418F /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
59062C6EC2CCFE110AC70AB8 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
@@ -49,12 +54,24 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
1589B944F51366A883B3A7A5 /* Pods_Runner.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
38CA51382F78F25FC59B6C81 /* Pods */ = {
isa = PBXGroup;
children = (
24CE22BB301621B9BF1A7A7C /* Pods-Runner.debug.xcconfig */,
59062C6EC2CCFE110AC70AB8 /* Pods-Runner.release.xcconfig */,
4684439012E1DB1A82103E26 /* Pods-Runner.profile.xcconfig */,
);
name = Pods;
path = Pods;
sourceTree = "<group>";
};
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
@@ -72,6 +89,8 @@
9740EEB11CF90186004384FC /* Flutter */,
97C146F01CF9000F007C117D /* Runner */,
97C146EF1CF9000F007C117D /* Products */,
38CA51382F78F25FC59B6C81 /* Pods */,
E3E00C71A36D1ABA459667BF /* Frameworks */,
);
sourceTree = "<group>";
};
@@ -98,6 +117,14 @@
path = Runner;
sourceTree = "<group>";
};
E3E00C71A36D1ABA459667BF /* Frameworks */ = {
isa = PBXGroup;
children = (
4AFDB673F1C9808CE4EC418F /* Pods_Runner.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@@ -105,12 +132,14 @@
isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
41D6CB24535AC541800DBB07 /* [CP] Check Pods Manifest.lock */,
9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */,
97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
9DF0031578B883CBE79BDCC8 /* [CP] Embed Pods Frameworks */,
);
buildRules = (
);
@@ -183,6 +212,28 @@
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
};
41D6CB24535AC541800DBB07 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
@@ -197,6 +248,23 @@
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
};
9DF0031578B883CBE79BDCC8 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
@@ -4,4 +4,7 @@
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
<FileRef
location = "group:Pods/Pods.xcodeproj">
</FileRef>
</Workspace>
@@ -1,13 +1,21 @@
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
import 'package:stream_chat_persistence/stream_chat_persistence.dart';
final chatPersistentClient = StreamChatPersistenceClient(
logLevel: Level.INFO,
connectionMode: ConnectionMode.background,
);
void main() async {
WidgetsFlutterBinding.ensureInitialized();
/// Create a new instance of [StreamChatClient] passing the apikey obtained from your
/// project dashboard.
final client = StreamChatClient(
's2dxdhpxd94g',
logLevel: Level.INFO,
);
)..chatPersistenceClient = chatPersistentClient;
/// Set the current user and connect the websocket. In a production scenario, this should be done using
/// a backend to generate a user token using our server SDK.
@@ -20,8 +28,7 @@ void main() async {
final channel = client.channel('messaging', id: 'godevs');
// ignore: unawaited_futures
channel.watch();
await channel.watch();
runApp(MyApp(client, channel));
}
@@ -49,6 +56,9 @@ class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData.light(),
darkTheme: ThemeData.dark(),
themeMode: ThemeMode.system,
builder: (context, widget) {
return StreamChat(
child: widget,
@@ -0,0 +1,6 @@
export 'file_attachment.dart';
export 'giphy_attachment.dart';
export 'image_attachment.dart';
export 'video_attachment.dart';
export 'attachment_widget.dart'
show AttachmentError, AttachmentSource, AttachmentSourceX;
@@ -1,8 +1,8 @@
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
import 'stream_chat_theme.dart';
import 'utils.dart';
import '../stream_chat_theme.dart';
import '../utils.dart';
class AttachmentTitle extends StatelessWidget {
const AttachmentTitle({
@@ -0,0 +1,201 @@
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/src/upload_progress_indicator.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
typedef InProgressBuilder = Widget Function(BuildContext, int, int);
typedef FailedBuilder = Widget Function(BuildContext, String);
class AttachmentUploadStateBuilder extends StatelessWidget {
final Message message;
final Attachment attachment;
final FailedBuilder failedBuilder;
final WidgetBuilder successBuilder;
final InProgressBuilder inProgressBuilder;
const AttachmentUploadStateBuilder({
Key key,
@required this.message,
@required this.attachment,
this.failedBuilder,
this.successBuilder,
this.inProgressBuilder,
}) : assert(message != null),
assert(attachment != null),
super(key: key);
@override
Widget build(BuildContext context) {
if (attachment.uploadState == null) return Offstage();
final messageId = message.id;
final attachmentId = attachment.id;
var inProgress = inProgressBuilder;
inProgress ??= (context, int sent, int total) {
return _InProgressState(
sent: sent,
total: total,
attachmentId: attachmentId,
);
};
var failed = failedBuilder;
failed ??= (context, error) {
return _FailedState(
error: error,
messageId: messageId,
attachmentId: attachmentId,
);
};
var success = successBuilder;
success ??= (context) => _SuccessState();
return attachment.uploadState.when(
inProgress: (sent, total) => inProgress(context, sent, total),
success: () => success(context),
failed: (error) => failed(context, error),
);
}
}
class _IconButton extends StatelessWidget {
final Widget icon;
final double iconSize;
final VoidCallback onPressed;
final Color fillColor;
const _IconButton({
Key key,
this.icon,
this.iconSize = 24.0,
this.onPressed,
this.fillColor,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container(
height: iconSize,
width: iconSize,
child: RawMaterialButton(
elevation: 0,
highlightElevation: 0,
focusElevation: 0,
disabledElevation: 0,
hoverElevation: 0,
onPressed: onPressed,
fillColor:
fillColor ?? StreamChatTheme.of(context).colorTheme.overlayDark,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: icon,
),
);
}
}
class _InProgressState extends StatelessWidget {
final int sent;
final int total;
final String attachmentId;
const _InProgressState({
Key key,
@required this.sent,
@required this.total,
@required this.attachmentId,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final channel = StreamChannel.of(context).channel;
return Column(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Align(
alignment: Alignment.topRight,
child: _IconButton(
icon: StreamSvgIcon.close(
color: StreamChatTheme.of(context).colorTheme.white,
),
onPressed: () => channel.cancelAttachmentUpload(attachmentId),
),
),
Align(
alignment: Alignment.topRight,
child: UploadProgressIndicator(
uploaded: sent,
total: total,
),
)
],
);
}
}
class _FailedState extends StatelessWidget {
final String error;
final String messageId;
final String attachmentId;
const _FailedState({
Key key,
this.error,
@required this.messageId,
@required this.attachmentId,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final channel = StreamChannel.of(context).channel;
final theme = StreamChatTheme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_IconButton(
icon: StreamSvgIcon.retry(
color: theme.colorTheme.white,
),
onPressed: () {
return channel.retryAttachmentUpload(messageId, attachmentId);
},
),
Center(
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: theme.colorTheme.overlayDark.withOpacity(0.6),
),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 12),
child: Text(
'UPLOAD ERROR',
style: theme.textTheme.footnote.copyWith(
color: theme.colorTheme.white,
),
),
),
),
)
],
);
}
}
class _SuccessState extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Align(
alignment: Alignment.topRight,
child: CircleAvatar(
backgroundColor: StreamChatTheme.of(context).colorTheme.overlayDark,
maxRadius: 12.0,
child: StreamSvgIcon.check(
color: StreamChatTheme.of(context).colorTheme.white,
),
),
);
}
}
@@ -0,0 +1,78 @@
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
import '../stream_chat_theme.dart';
enum AttachmentSource {
local,
network,
}
extension AttachmentSourceX on AttachmentSource {
/// The [when] method is the equivalent to pattern matching.
/// Its prototype depends on the AttachmentSource defined.
// ignore: missing_return
T when<T>({
@required T Function() local,
@required T Function() network,
}) {
assert(() {
if (local == null || network == null) {
throw 'check for all possible cases';
}
return true;
}());
switch (this) {
case AttachmentSource.local:
return local();
case AttachmentSource.network:
return network();
}
}
}
abstract class AttachmentWidget extends StatelessWidget {
final Size size;
final Message message;
final Attachment attachment;
final AttachmentSource _source;
AttachmentSource get source => _source ?? attachment.file != null
? AttachmentSource.local
: AttachmentSource.network;
const AttachmentWidget({
Key key,
@required this.message,
@required this.attachment,
this.size,
AttachmentSource source,
}) : _source = source,
super(key: key);
}
class AttachmentError extends StatelessWidget {
final Size size;
const AttachmentError({
Key key,
this.size,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Center(
child: Container(
width: size?.width,
height: size?.height,
color: StreamChatTheme.of(context).colorTheme.accentRed.withOpacity(.1),
child: Center(
child: Icon(
Icons.error_outline,
color: StreamChatTheme.of(context).colorTheme.black,
),
),
),
);
}
}
@@ -0,0 +1,250 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/src/stream_chat_theme.dart';
import 'package:stream_chat_flutter/src/stream_svg_icon.dart';
import 'package:stream_chat_flutter/src/utils.dart';
import 'package:stream_chat_flutter/src/video_thumbnail_image.dart';
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
import '../upload_progress_indicator.dart';
import 'attachment_widget.dart';
class FileAttachment extends AttachmentWidget {
final Widget title;
final Widget trailing;
const FileAttachment({
Key key,
@required Message message,
@required Attachment attachment,
Size size,
this.title,
this.trailing,
}) : super(key: key, message: message, attachment: attachment, size: size);
bool get isVideoAttachment => attachment.title?.mimeType?.type == 'video';
bool get isImageAttachment => attachment.title?.mimeType?.type == 'image';
@override
Widget build(BuildContext context) {
return Material(
child: Container(
width: size?.width ?? 100,
height: 56.0,
decoration: BoxDecoration(
color: StreamChatTheme.of(context).colorTheme.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: StreamChatTheme.of(context).colorTheme.greyWhisper,
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
child: _getFileTypeImage(context),
height: 40.0,
width: 33.33,
margin: EdgeInsets.all(8.0),
),
SizedBox(width: 8.0),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
attachment?.title ?? 'File',
style: StreamChatTheme.of(context).textTheme.bodyBold,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
SizedBox(height: 3.0),
_buildSubtitle(context),
],
),
),
SizedBox(width: 8.0),
_buildTrailing(context),
],
),
),
);
}
ShapeBorder _getDefaultShape(BuildContext context) {
return RoundedRectangleBorder(
side: BorderSide(width: 0.0, color: Colors.transparent),
borderRadius: BorderRadius.circular(8),
);
}
Widget _getFileTypeImage(BuildContext context) {
if (isImageAttachment) {
return Material(
clipBehavior: Clip.antiAlias,
type: MaterialType.transparency,
shape: _getDefaultShape(context),
child: source.when(
local: () => Image.memory(
attachment.file.bytes,
fit: BoxFit.cover,
errorBuilder: (_, obj, trace) {
return getFileTypeImage(attachment.extraData['other']);
},
),
network: () => CachedNetworkImage(
imageUrl: attachment.imageUrl ??
attachment.assetUrl ??
attachment.thumbUrl,
fit: BoxFit.cover,
errorWidget: (_, obj, trace) {
return getFileTypeImage(attachment.extraData['other']);
},
progressIndicatorBuilder: (context, _, progress) {
return Center(
child: Container(
width: 20.0,
height: 20.0,
child: const CircularProgressIndicator(),
),
);
},
),
),
);
}
if (isVideoAttachment) {
return Material(
clipBehavior: Clip.antiAlias,
type: MaterialType.transparency,
shape: _getDefaultShape(context),
child: source.when(
local: () => VideoThumbnailImage(
video: attachment.file.path,
placeholderBuilder: (_) {
return Center(
child: Container(
width: 20.0,
height: 20.0,
child: const CircularProgressIndicator(),
),
);
},
),
network: () => VideoThumbnailImage(
video: attachment.assetUrl,
placeholderBuilder: (_) {
return Center(
child: Container(
width: 20.0,
height: 20.0,
child: const CircularProgressIndicator(),
),
);
},
),
),
);
}
return getFileTypeImage(attachment.extraData['mime_type']);
}
Widget _buildButton({
Widget icon,
double iconSize = 24.0,
VoidCallback onPressed,
Color fillColor,
}) {
return Container(
height: iconSize,
width: iconSize,
child: RawMaterialButton(
elevation: 0,
highlightElevation: 0,
focusElevation: 0,
disabledElevation: 0,
hoverElevation: 0,
onPressed: onPressed,
fillColor: fillColor,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: icon,
),
);
}
Widget _buildTrailing(BuildContext context) {
final theme = StreamChatTheme.of(context);
final channel = StreamChannel.of(context).channel;
final attachmentId = attachment.id;
var trailingWidget = trailing;
trailingWidget ??= attachment.uploadState?.when(
inProgress: (_, __) => Padding(
padding: const EdgeInsets.all(8.0),
child: _buildButton(
icon: StreamSvgIcon.close(color: theme.colorTheme.white),
fillColor: theme.colorTheme.overlayDark,
onPressed: () => channel.cancelAttachmentUpload(attachmentId),
),
),
success: () => Padding(
padding: const EdgeInsets.all(8.0),
child: CircleAvatar(
backgroundColor: theme.colorTheme.accentBlue,
maxRadius: 12.0,
child: StreamSvgIcon.check(color: theme.colorTheme.white),
),
),
failed: (_) => Padding(
padding: const EdgeInsets.all(8.0),
child: _buildButton(
icon: StreamSvgIcon.retry(color: theme.colorTheme.white),
fillColor: theme.colorTheme.overlayDark,
onPressed: () => channel.retryAttachmentUpload(
message?.id,
attachmentId,
),
),
),
) ??
IconButton(
icon: StreamSvgIcon.cloudDownload(color: theme.colorTheme.black),
padding: const EdgeInsets.all(8),
visualDensity: VisualDensity.compact,
splashRadius: 16,
onPressed: () {
launchURL(context, attachment.assetUrl);
},
);
return Material(
type: MaterialType.transparency,
child: trailingWidget,
);
}
Widget _buildSubtitle(BuildContext context) {
final theme = StreamChatTheme.of(context);
final size = attachment.file?.size ?? attachment.extraData['file_size'];
final textStyle = theme.textTheme.footnote.copyWith(
color: theme.colorTheme.grey,
);
return attachment.uploadState?.when(
inProgress: (sent, total) {
return UploadProgressIndicator(
uploaded: sent,
total: total,
showBackground: false,
padding: EdgeInsets.zero,
textStyle: textStyle,
progressIndicatorColor: theme.colorTheme.accentBlue,
);
},
success: () => Text('${fileSize(size, 2)}', style: textStyle),
failed: (_) => Text('UPLOAD ERROR', style: textStyle),
) ??
Text('${fileSize(size)}', style: textStyle);
}
}
@@ -1,48 +1,42 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/src/stream_svg_icon.dart';
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
import '../stream_chat_flutter.dart';
import 'attachment_error.dart';
import 'full_screen_media.dart';
import '../full_screen_media.dart';
import '../stream_chat_theme.dart';
import '../stream_svg_icon.dart';
import 'attachment_widget.dart';
class GiphyAttachment extends StatelessWidget {
final Attachment attachment;
class GiphyAttachment extends AttachmentWidget {
final MessageTheme messageTheme;
final Message message;
final Size size;
final ShowMessageCallback onShowMessage;
final ValueChanged<ReturnActionType> onReturnAction;
const GiphyAttachment({
Key key,
this.attachment,
@required Message message,
@required Attachment attachment,
Size size,
this.messageTheme,
this.message,
this.size,
this.onShowMessage,
this.onReturnAction,
}) : super(key: key);
}) : super(key: key, message: message, attachment: attachment, size: size);
@override
Widget build(BuildContext context) {
if (attachment.thumbUrl == null &&
attachment.imageUrl == null &&
attachment.assetUrl == null) {
return AttachmentError(
attachment: attachment,
);
final imageUrl =
attachment.thumbUrl ?? attachment.imageUrl ?? attachment.assetUrl;
if (imageUrl == null && source == AttachmentSource.network) {
return AttachmentError();
}
return attachment.actions != null
? _buildSendingAttachment(context)
: _buildSentAttachment(context);
if (attachment.actions != null) {
return _buildSendingAttachment(context, imageUrl);
}
return _buildSentAttachment(context, imageUrl);
}
Widget _buildSendingAttachment(context) {
Widget _buildSendingAttachment(BuildContext context, String imageUrl) {
final streamChannel = StreamChannel.of(context);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
@@ -67,9 +61,7 @@ class GiphyAttachment extends StatelessWidget {
Padding(
padding: const EdgeInsets.all(8.0),
child: GestureDetector(
onTap: () async {
_onImageTap(context);
},
onTap: () => _onImageTap(context),
child: ClipRRect(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(8),
@@ -87,13 +79,10 @@ class GiphyAttachment extends StatelessWidget {
),
);
},
imageUrl: attachment.thumbUrl ??
attachment.imageUrl ??
attachment.assetUrl,
errorWidget: (context, url, error) => AttachmentError(
attachment: attachment,
size: size,
),
imageUrl: imageUrl,
errorWidget: (context, url, error) {
return AttachmentError(size: size);
},
fit: BoxFit.cover,
),
),
@@ -305,44 +294,38 @@ class GiphyAttachment extends StatelessWidget {
}
void _onImageTap(BuildContext context) async {
var res = await Navigator.push(context, MaterialPageRoute(
builder: (_) {
final channel = StreamChannel.of(context).channel;
return StreamChannel(
channel: channel,
child: FullScreenMedia(
mediaAttachments: [
attachment,
],
userName: message.user.name,
sentAt: message.createdAt,
message: message,
onShowMessage: onShowMessage,
),
);
},
));
if (res != null) {
onReturnAction(res);
}
final res = await Navigator.push(
context,
MaterialPageRoute(
builder: (_) {
final channel = StreamChannel.of(context).channel;
return StreamChannel(
channel: channel,
child: FullScreenMedia(
mediaAttachments: [attachment],
userName: message.user.name,
sentAt: message.createdAt,
message: message,
onShowMessage: onShowMessage,
),
);
},
),
);
if (res != null) onReturnAction(res);
}
Widget _buildSentAttachment(context) {
Widget _buildSentAttachment(BuildContext context, String imageUrl) {
return Container(
child: GestureDetector(
onTap: () async {
var res =
final res =
await Navigator.push(context, MaterialPageRoute(builder: (_) {
var channel = StreamChannel.of(context).channel;
final channel = StreamChannel.of(context).channel;
return StreamChannel(
channel: channel,
child: FullScreenMedia(
mediaAttachments: [
attachment,
],
mediaAttachments: [attachment],
userName: message.user.name,
sentAt: message.createdAt,
message: message,
@@ -350,10 +333,7 @@ class GiphyAttachment extends StatelessWidget {
),
);
}));
if (res != null) {
onReturnAction(res);
}
if (res != null) onReturnAction(res);
},
child: Stack(
children: [
@@ -369,13 +349,10 @@ class GiphyAttachment extends StatelessWidget {
),
);
},
imageUrl: attachment.thumbUrl ??
attachment.imageUrl ??
attachment.assetUrl,
errorWidget: (context, url, error) => AttachmentError(
attachment: attachment,
size: size,
),
imageUrl: imageUrl,
errorWidget: (context, url, error) {
return AttachmentError(size: size);
},
fit: BoxFit.cover,
),
Positioned(
@@ -0,0 +1,139 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/src/attachment/attachment_upload_state_builder.dart';
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
import 'attachment_title.dart';
import '../full_screen_media.dart';
import '../stream_chat_theme.dart';
import 'attachment_widget.dart';
class ImageAttachment extends AttachmentWidget {
final MessageTheme messageTheme;
final bool showTitle;
final ShowMessageCallback onShowMessage;
final ValueChanged<ReturnActionType> onReturnAction;
final VoidCallback onAttachmentTap;
const ImageAttachment({
Key key,
@required Message message,
@required Attachment attachment,
Size size,
this.messageTheme,
this.showTitle = false,
this.onShowMessage,
this.onReturnAction,
this.onAttachmentTap,
}) : super(key: key, message: message, attachment: attachment, size: size);
@override
Widget build(BuildContext context) {
return source.when(
local: () {
if (attachment.localUri == null) {
return AttachmentError(size: size);
}
return _buildImageAttachment(
context,
Image.memory(
attachment.file.bytes,
height: size?.height,
width: size?.width,
fit: BoxFit.cover,
errorBuilder: (context, _, __) {
return Image.asset(
'images/placeholder.png',
package: 'stream_chat_flutter',
);
},
),
);
},
network: () {
final imageUrl =
attachment.thumbUrl ?? attachment.imageUrl ?? attachment.assetUrl;
if (imageUrl == null) {
return AttachmentError(size: size);
}
return _buildImageAttachment(
context,
CachedNetworkImage(
height: size?.height,
width: size?.width,
placeholder: (_, __) {
return Container(
width: size?.width,
height: size?.height,
child: Center(
child: CircularProgressIndicator(),
),
);
},
imageUrl: imageUrl,
errorWidget: (context, url, error) {
return AttachmentError(size: size);
},
fit: BoxFit.cover,
),
);
},
);
}
Widget _buildImageAttachment(BuildContext context, Widget imageWidget) {
return ConstrainedBox(
constraints: BoxConstraints.loose(size),
child: Column(
children: <Widget>[
Expanded(
child: Stack(
children: [
GestureDetector(
onTap: onAttachmentTap ??
() async {
final result = await Navigator.push(
context,
MaterialPageRoute(
builder: (_) {
final channel = StreamChannel.of(context).channel;
return StreamChannel(
channel: channel,
child: FullScreenMedia(
mediaAttachments: [attachment],
userName: message.user.name,
sentAt: message.createdAt,
message: message,
onShowMessage: onShowMessage,
),
);
},
),
);
if (result != null) onReturnAction(result);
},
child: imageWidget,
),
Padding(
padding: const EdgeInsets.all(8.0),
child: AttachmentUploadStateBuilder(
message: message,
attachment: attachment,
),
),
],
),
),
if (showTitle && attachment.title != null)
Material(
color: messageTheme.messageBackgroundColor,
child: AttachmentTitle(
messageTheme: messageTheme,
attachment: attachment,
),
),
],
),
);
}
}
@@ -0,0 +1,122 @@
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/src/full_screen_media.dart';
import 'package:stream_chat_flutter/src/video_thumbnail_image.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
import 'attachment_title.dart';
import 'attachment_upload_state_builder.dart';
import 'attachment_widget.dart';
class VideoAttachment extends AttachmentWidget {
final MessageTheme messageTheme;
final ShowMessageCallback onShowMessage;
final ValueChanged<ReturnActionType> onReturnAction;
const VideoAttachment({
Key key,
@required Message message,
@required Attachment attachment,
Size size,
this.messageTheme,
this.onShowMessage,
this.onReturnAction,
}) : super(key: key, message: message, attachment: attachment, size: size);
@override
Widget build(BuildContext context) {
return source.when(
local: () {
if (attachment.file == null) {
return AttachmentError(size: size);
}
return _buildVideoAttachment(
context,
VideoThumbnailImage(
video: attachment.file.path,
height: size?.height,
width: size?.width,
fit: BoxFit.cover,
errorBuilder: (_, __) => AttachmentError(size: size),
),
);
},
network: () {
if (attachment.assetUrl == null) {
return AttachmentError(size: size);
}
return _buildVideoAttachment(
context,
VideoThumbnailImage(
video: attachment.assetUrl,
height: size?.height,
width: size?.width,
fit: BoxFit.cover,
errorBuilder: (_, __) => AttachmentError(size: size),
),
);
},
);
}
Widget _buildVideoAttachment(BuildContext context, Widget videoWidget) {
return ConstrainedBox(
constraints: BoxConstraints.loose(size),
child: Column(
children: <Widget>[
Expanded(
child: GestureDetector(
onTap: () async {
final channel = StreamChannel.of(context).channel;
final res = await Navigator.push(
context,
MaterialPageRoute(
builder: (_) => StreamChannel(
channel: channel,
child: FullScreenMedia(
mediaAttachments: [attachment],
userName: message.user.name,
sentAt: message.createdAt,
message: message,
onShowMessage: onShowMessage,
),
),
),
);
if (res != null) onReturnAction(res);
},
child: Stack(
children: [
videoWidget,
Center(
child: Material(
shape: CircleBorder(),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Icon(Icons.play_arrow),
),
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: AttachmentUploadStateBuilder(
message: message,
attachment: attachment,
),
),
],
),
),
),
if (attachment.title != null)
Material(
color: messageTheme.messageBackgroundColor,
child: AttachmentTitle(
messageTheme: messageTheme,
attachment: attachment,
),
),
],
),
);
}
}
@@ -1,38 +0,0 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
import '../stream_chat_flutter.dart';
class AttachmentError extends StatelessWidget {
final Attachment attachment;
final Size size;
const AttachmentError({
Key key,
@required this.attachment,
this.size,
}) : super(key: key);
@override
Widget build(BuildContext context) {
if (attachment.localUri != null) {
return Image.file(
File(attachment.localUri.path),
);
}
return Center(
child: Container(
width: size?.width,
height: size?.height ?? 200,
color: StreamChatTheme.of(context).colorTheme.accentRed.withOpacity(.1),
child: Center(
child: Icon(
Icons.error_outline,
color: StreamChatTheme.of(context).colorTheme.black,
),
),
),
);
}
}
@@ -1,6 +1,8 @@
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
import 'attachment/attachment.dart';
class ChannelFileDisplayScreen extends StatefulWidget {
/// The sorting used for the channels matching the filters.
/// Sorting is based on field and direction, multiple sorting options can be provided.
@@ -164,6 +166,7 @@ class _ChannelFileDisplayScreenState extends State<ChannelFileDisplayScreen> {
child: Padding(
padding: const EdgeInsets.all(8.0),
child: FileAttachment(
message: media.values.toList()[position],
attachment: media.keys.toList()[position],
),
),
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
import 'package:video_player/video_player.dart';
import 'attachment/attachment.dart';
class ChannelMediaDisplayScreen extends StatefulWidget {
/// The sorting used for the channels matching the filters.
/// Sorting is based on field and direction, multiple sorting options can be provided.
@@ -1,21 +0,0 @@
import 'dart:async';
import 'package:synchronized/synchronized.dart';
import 'package:video_compress/video_compress.dart';
class ICompressVideoService {
static final ICompressVideoService instance = ICompressVideoService._();
final _lock = Lock();
ICompressVideoService._();
Future<MediaInfo> compressVideo(String path) async {
return _lock.synchronized(() {
return VideoCompress.compressVideo(
path,
);
});
}
}
ICompressVideoService get compressVideoService =>
ICompressVideoService.instance;
@@ -1,5 +1,7 @@
import 'package:characters/characters.dart';
import 'package:emojis/emoji.dart';
import 'package:file_picker/file_picker.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
final _emojis = Emoji.all();
@@ -10,9 +12,11 @@ extension StringExtension on String {
return '${this[0].toUpperCase()}${substring(1)}';
}
// Emojis guidelines
// 1 to 3 emojis: big size with no text bubble.
// 4+ emojis or emojis+text: standard size with text bubble.
/// Returns whether the string contains only emoji's or not.
///
/// Emojis guidelines
/// 1 to 3 emojis: big size with no text bubble.
/// 4+ emojis or emojis+text: standard size with text bubble.
bool get isOnlyEmoji {
final characters = trim().characters;
if (characters.isEmpty) return false;
@@ -29,3 +33,14 @@ extension IterableX<T> on Iterable<T> {
yield e;
}).skip(1).toList(growable: false);
}
/// Useful extension for [PlatformFile]
extension PlatformFileX on PlatformFile {
/// Converts the [PlatformFile] into [AttachmentFile]
AttachmentFile get toAttachmentFile => AttachmentFile(
path: path,
name: name,
bytes: bytes,
size: size,
);
}
@@ -1,207 +0,0 @@
import 'dart:io';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/src/stream_chat_theme.dart';
import 'package:stream_chat_flutter/src/stream_svg_icon.dart';
import 'package:stream_chat_flutter/src/utils.dart';
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
import 'package:video_compress/video_compress.dart';
import 'package:video_player/video_player.dart';
import 'media_utils.dart';
enum FileAttachmentType { local, online }
class FileAttachment extends StatefulWidget {
final Attachment attachment;
final Size size;
final Widget trailing;
final FileAttachmentType attachmentType;
final PlatformFile file;
const FileAttachment({
Key key,
@required this.attachment,
this.size,
this.trailing,
this.attachmentType = FileAttachmentType.online,
this.file,
}) : super(key: key);
@override
_FileAttachmentState createState() => _FileAttachmentState();
}
class _FileAttachmentState extends State<FileAttachment> {
VideoPlayerController _controller;
Future<void> _initializeVideoPlayerFuture;
@override
void initState() {
super.initState();
if (MediaUtils.getMimeType(widget.attachment.title)?.type == 'video') {
if (widget.attachmentType == FileAttachmentType.online) {
_controller = VideoPlayerController.network(
widget.attachment.assetUrl,
);
} else {
_controller = VideoPlayerController.file(
File.fromRawPath(widget.file.bytes),
);
}
_initializeVideoPlayerFuture = _controller.initialize();
}
}
@override
Widget build(BuildContext context) {
return Material(
child: Container(
width: widget.size?.width ?? 100,
height: 56.0,
decoration: BoxDecoration(
color: StreamChatTheme.of(context).colorTheme.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: StreamChatTheme.of(context).colorTheme.greyWhisper,
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
child: _getFileTypeImage(),
height: 40.0,
width: 33.33,
margin: EdgeInsets.all(8.0),
),
SizedBox(width: 8.0),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.attachment?.title ?? 'File',
style: StreamChatTheme.of(context).textTheme.bodyBold,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
SizedBox(height: 3.0),
Text(
'${getSizeText(widget.attachment.extraData['file_size'])}',
style: StreamChatTheme.of(context)
.textTheme
.footnote
.copyWith(
color: StreamChatTheme.of(context)
.colorTheme
.black
.withOpacity(0.5)),
),
],
),
),
SizedBox(width: 8.0),
Material(
type: MaterialType.transparency,
child: widget.trailing ??
IconButton(
icon: StreamSvgIcon.cloudDownload(
color: StreamChatTheme.of(context).colorTheme.black,
),
padding: const EdgeInsets.all(8),
visualDensity: VisualDensity.compact,
splashRadius: 16,
onPressed: () {
launchURL(context, widget.attachment.assetUrl);
},
),
),
],
),
),
);
}
Widget _getFileTypeImage() {
if ((MediaUtils.getMimeType(widget.attachment.title)?.type == 'image')) {
switch (widget.attachmentType) {
case FileAttachmentType.local:
return Image.memory(
widget.file.bytes,
fit: BoxFit.cover,
errorBuilder: (_, obj, trace) {
return getFileTypeImage(widget.attachment.extraData['other']);
},
);
break;
case FileAttachmentType.online:
return CachedNetworkImage(
imageUrl: widget.attachment.imageUrl ??
widget.attachment.assetUrl ??
widget.attachment.thumbUrl,
fit: BoxFit.cover,
errorWidget: (_, obj, trace) {
return getFileTypeImage(widget.attachment.extraData['other']);
},
progressIndicatorBuilder: (context, _, progress) {
return Center(
child: Container(
width: 20.0,
height: 20.0,
child: CircularProgressIndicator(
backgroundColor:
StreamChatTheme.of(context).colorTheme.accentBlue,
),
),
);
},
);
break;
}
}
if ((MediaUtils.getMimeType(widget.attachment.title)?.type == 'video')) {
switch (widget.attachmentType) {
case FileAttachmentType.local:
return FutureBuilder<File>(
future: VideoCompress.getFileThumbnail(widget.file.path),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return Image.asset(
'images/placeholder.png',
package: 'stream_chat_flutter',
);
}
return Image.file(
snapshot.data,
fit: BoxFit.cover,
);
},
);
break;
case FileAttachmentType.online:
return FutureBuilder(
future: _initializeVideoPlayerFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
return AspectRatio(
aspectRatio: _controller.value.aspectRatio,
child: VideoPlayer(_controller),
);
} else {
return Center(child: CircularProgressIndicator());
}
},
);
break;
}
}
return getFileTypeImage(widget.attachment.extraData['mime_type']);
}
}
@@ -1,3 +1,6 @@
import 'dart:async';
import 'dart:io';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:chewie/chewie.dart';
import 'package:flutter/material.dart';
@@ -48,36 +51,34 @@ class _FullScreenMediaState extends State<FullScreenMedia>
int _currentPage;
List<VideoPackage> videoPackages = [];
final videoPackages = <String, VideoPackage>{};
@override
void initState() {
super.initState();
_controller =
AnimationController(vsync: this, duration: Duration(milliseconds: 300));
_controller = AnimationController(
vsync: this,
duration: Duration(milliseconds: 300),
);
_pageController = PageController(initialPage: widget.startIndex);
_currentPage = widget.startIndex;
widget.mediaAttachments
.where((element) => element.type == 'video')
.toList()
.forEach((element) {
videoPackages.add(VideoPackage(
context,
element,
() {
setState(() {});
},
showControls: true,
));
});
for (final attachment in widget.mediaAttachments) {
if (attachment.type != 'video') continue;
final package = VideoPackage(attachment, showControls: true);
videoPackages[attachment.id] = package;
}
initializePlayers();
}
Future<void> initializePlayers() async {
await Future.wait(videoPackages.values.map(
(it) => it.initialize(),
));
setState(() {});
}
@override
Widget build(BuildContext context) {
var videoAttachments = widget.mediaAttachments
.where((element) => element.type == 'video')
.toList();
return Scaffold(
resizeToAvoidBottomInset: false,
body: Stack(
@@ -92,14 +93,18 @@ class _FullScreenMediaState extends State<FullScreenMedia>
_currentPage = val;
});
},
itemBuilder: (context, position) {
if (widget.mediaAttachments[position].type == 'image' ||
widget.mediaAttachments[position].type == 'giphy') {
itemBuilder: (context, index) {
final attachment = widget.mediaAttachments[index];
if (attachment.type == 'image' ||
attachment.type == 'giphy') {
final imageUrl = attachment.imageUrl ??
attachment.assetUrl ??
attachment.thumbUrl;
return PhotoView(
imageProvider: CachedNetworkImageProvider(
widget.mediaAttachments[position].imageUrl ??
widget.mediaAttachments[position].assetUrl ??
widget.mediaAttachments[position].thumbUrl),
imageProvider:
imageUrl == null && attachment.localUri != null
? Image.memory(attachment.file.bytes).image
: CachedNetworkImageProvider(imageUrl),
maxScale: PhotoViewComputedScale.covered,
minScale: PhotoViewComputedScale.contained,
heroAttributes: PhotoViewHeroAttributes(
@@ -125,12 +130,9 @@ class _FullScreenMediaState extends State<FullScreenMedia>
}
},
);
} else if (widget.mediaAttachments[position].type ==
'video') {
var controllerPackage = videoPackages[videoAttachments
.indexOf(widget.mediaAttachments[position])];
if (!controllerPackage.initialised) {
} else if (attachment.type == 'video') {
final controller = videoPackages[attachment.id];
if (!controller.initialized) {
return Center(
child: CircularProgressIndicator(),
);
@@ -151,7 +153,7 @@ class _FullScreenMediaState extends State<FullScreenMedia>
vertical: 50.0,
),
child: Chewie(
controller: controllerPackage.chewieController,
controller: controller.chewieController,
),
),
);
@@ -188,7 +190,6 @@ class _FullScreenMediaState extends State<FullScreenMedia>
totalPages: widget.mediaAttachments.length,
mediaAttachments: widget.mediaAttachments,
message: widget.message,
videoPackages: videoPackages,
mediaSelectedCallBack: (val) {
setState(() {
_currentPage = val;
@@ -224,54 +225,58 @@ class _FullScreenMediaState extends State<FullScreenMedia>
}
@override
void dispose() {
videoPackages.forEach((element) {
element.dispose();
});
void dispose() async {
for (final package in videoPackages.values) {
await package.dispose();
}
super.dispose();
}
}
class VideoPackage {
VideoPlayerController _videoPlayerController;
final bool _showControls;
final bool _autoInitialize;
final VideoPlayerController _videoPlayerController;
ChewieController _chewieController;
bool initialised = false;
VoidCallback onInit;
BuildContext context;
bool showControls;
///
VideoPackage(this.context, Attachment attachment, this.onInit,
{this.showControls = false}) {
_videoPlayerController = VideoPlayerController.network(attachment.assetUrl);
_videoPlayerController.initialize().whenComplete(() {
initialised = true;
_chewieController = ChewieController(
videoPlayerController: _videoPlayerController,
autoInitialize: true,
showControls: showControls,
aspectRatio: _videoPlayerController.value.aspectRatio,
);
onInit();
});
VoidCallback errorListener;
errorListener = () {
if (_videoPlayerController.value.hasError) {
Navigator.pop(context);
launchURL(context, attachment.titleLink);
}
_videoPlayerController.removeListener(errorListener);
};
_videoPlayerController.addListener(errorListener);
}
VideoPlayerController get videoPlayer => _videoPlayerController;
ChewieController get chewieController => _chewieController;
void dispose() {
_videoPlayerController.dispose();
_chewieController.dispose();
bool get initialized => _videoPlayerController.value.initialized;
VideoPackage(
Attachment attachment, {
bool showControls = false,
bool autoInitialize = true,
}) : assert(attachment != null),
_showControls = showControls,
_autoInitialize = autoInitialize,
_videoPlayerController = attachment.localUri != null
? VideoPlayerController.file(File.fromUri(attachment.localUri))
: VideoPlayerController.network(attachment.assetUrl);
Future<void> initialize() {
return _videoPlayerController.initialize().then((_) {
_chewieController = ChewieController(
videoPlayerController: _videoPlayerController,
autoInitialize: _autoInitialize,
showControls: _showControls,
aspectRatio: _videoPlayerController.value.aspectRatio,
);
});
}
void addListener(VoidCallback listener) {
return _videoPlayerController.addListener(listener);
}
void removeListener(VoidCallback listener) {
return _videoPlayerController.removeListener(listener);
}
Future<void> dispose() {
_chewieController?.dispose();
return _videoPlayerController?.dispose();
}
}
@@ -105,10 +105,9 @@ class ImageActionsModal extends StatelessWidget {
() {
Navigator.pop(context);
Navigator.pop(context);
StreamChat.of(context).client.deleteMessage(
message,
StreamChannel.of(context).channel.cid,
);
StreamChannel.of(context)
.channel
.deleteMessage(message);
},
color: StreamChatTheme.of(context).colorTheme.accentRed,
),
@@ -1,124 +0,0 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import '../stream_chat_flutter.dart';
import 'attachment_error.dart';
import 'attachment_title.dart';
import 'full_screen_media.dart';
import 'utils.dart';
class ImageAttachment extends StatelessWidget {
final Attachment attachment;
final Message message;
final MessageTheme messageTheme;
final Size size;
final bool showTitle;
final ShowMessageCallback onShowMessage;
final ValueChanged<ReturnActionType> onReturnAction;
const ImageAttachment({
Key key,
@required this.attachment,
@required this.message,
@required this.size,
this.messageTheme,
this.showTitle = true,
this.onShowMessage,
this.onReturnAction,
}) : super(key: key);
@override
Widget build(BuildContext context) {
if (attachment.thumbUrl == null &&
attachment.imageUrl == null &&
attachment.assetUrl == null) {
return AttachmentError(
attachment: attachment,
);
}
return ConstrainedBox(
constraints: BoxConstraints.loose(size),
child: Stack(
children: <Widget>[
Column(
children: <Widget>[
Expanded(
child: GestureDetector(
onTap: () async {
var result = await Navigator.push(
context,
MaterialPageRoute(
builder: (_) {
final channel = StreamChannel.of(context).channel;
return StreamChannel(
channel: channel,
child: FullScreenMedia(
mediaAttachments: [
attachment,
],
userName: message.user.name,
sentAt: message.createdAt,
message: message,
onShowMessage: onShowMessage,
),
);
},
),
);
if (result != null) {
onReturnAction(result);
}
},
child: CachedNetworkImage(
height: size?.height,
width: size?.width,
placeholder: (_, __) {
return Container(
width: size?.width,
height: size?.height,
child: Center(
child: CircularProgressIndicator(),
),
);
},
imageUrl: attachment.thumbUrl ??
attachment.imageUrl ??
attachment.assetUrl,
errorWidget: (context, url, error) => AttachmentError(
attachment: attachment,
size: size,
),
fit: BoxFit.cover,
),
),
),
if (showTitle && attachment.title != null)
Material(
color: messageTheme.messageBackgroundColor,
child: AttachmentTitle(
messageTheme: messageTheme,
attachment: attachment,
),
),
],
),
if (showTitle &&
(attachment.titleLink != null || attachment.ogScrapeUrl != null))
Positioned.fill(
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: () => launchURL(
context,
attachment.titleLink ?? attachment.ogScrapeUrl,
),
),
),
),
],
),
);
}
}
@@ -2,12 +2,12 @@ import 'dart:async';
import 'dart:io';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:chewie/chewie.dart';
import 'package:dio/dio.dart';
import 'package:esys_flutter_share/esys_flutter_share.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/src/stream_chat_theme.dart';
import 'package:stream_chat_flutter/src/video_thumbnail_image.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
@@ -28,7 +28,6 @@ class ImageFooter extends StatefulWidget implements PreferredSizeWidget {
final List<Attachment> mediaAttachments;
final Message message;
final List<VideoPackage> videoPackages;
final ValueChanged<int> mediaSelectedCallBack;
/// Creates a channel header
@@ -41,7 +40,6 @@ class ImageFooter extends StatefulWidget implements PreferredSizeWidget {
this.totalPages = 0,
this.mediaAttachments,
this.message,
this.videoPackages,
this.mediaSelectedCallBack,
}) : preferredSize = Size.fromHeight(kToolbarHeight),
super(key: key);
@@ -144,10 +142,6 @@ class _ImageFooterState extends State<ImageFooter> {
}
void _showPhotosModal(context) {
var videoAttachments = widget.mediaAttachments
.where((element) => element.type == 'video')
.toList();
showModalBottomSheet(
context: context,
barrierColor: StreamChatTheme.of(context).colorTheme.overlay,
@@ -214,17 +208,14 @@ class _ImageFooterState extends State<ImageFooter> {
itemBuilder: (context, index) {
Widget media;
final attachment = widget.mediaAttachments[index];
if (attachment.type == 'video') {
var controllerPackage = widget.videoPackages[
videoAttachments.indexOf(attachment)];
media = InkWell(
onTap: () => widget.mediaSelectedCallBack(index),
child: FittedBox(
fit: BoxFit.cover,
child: Chewie(
controller: controllerPackage.chewieController,
child: VideoThumbnailImage(
video: attachment.file?.path ??
attachment.assetUrl,
),
),
);
@@ -1,4 +1,3 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
import 'package:stream_chat_flutter/src/full_screen_media.dart';
@@ -9,12 +8,14 @@ class ImageGroup extends StatelessWidget {
Key key,
@required this.images,
@required this.message,
@required this.messageTheme,
@required this.size,
this.onShowMessage,
}) : super(key: key);
final List<Attachment> images;
final Message message;
final MessageTheme messageTheme;
final Size size;
final ShowMessageCallback onShowMessage;
@@ -129,14 +130,12 @@ class ImageGroup extends StatelessWidget {
}
Widget _buildImage(BuildContext context, int index) {
return GestureDetector(
onTap: () => _onTap(context, index),
child: CachedNetworkImage(
imageUrl: images[index].imageUrl ??
images[index].thumbUrl ??
images[index].assetUrl,
fit: BoxFit.cover,
),
return ImageAttachment(
attachment: images[index],
size: size,
message: message,
messageTheme: messageTheme,
onAttachmentTap: () => _onTap(context, index),
);
}
}
@@ -1,17 +0,0 @@
import 'package:http_parser/http_parser.dart' as http_parser;
import 'package:mime/mime.dart';
class MediaUtils {
static http_parser.MediaType getMimeType(String filename) {
http_parser.MediaType mimeType;
if (filename != null) {
if (filename.toLowerCase().endsWith('heic')) {
mimeType = http_parser.MediaType.parse('image/heic');
} else {
mimeType = http_parser.MediaType.parse(lookupMimeType(filename));
}
}
return mimeType;
}
}
@@ -33,7 +33,6 @@ class MessageActionsModal extends StatefulWidget {
final ShapeBorder messageShape;
final ShapeBorder attachmentShape;
final DisplayWidget showUserAvatar;
final Map<String, VideoPackage> videoPackages;
const MessageActionsModal({
Key key,
@@ -54,7 +53,6 @@ class MessageActionsModal extends StatefulWidget {
this.messageShape,
this.attachmentShape,
this.reverse = false,
this.videoPackages,
}) : super(key: key);
@override
@@ -182,7 +180,6 @@ class _MessageActionsModalState extends State<MessageActionsModal> {
showSendingIndicator: false,
shape: widget.messageShape,
attachmentShape: widget.attachmentShape,
videoPackages: widget.videoPackages,
),
),
SizedBox(height: 8),
@@ -298,10 +295,7 @@ class _MessageActionsModalState extends State<MessageActionsModal> {
if (answer) {
try {
Navigator.pop(context);
await StreamChat.of(context).client.deleteMessage(
widget.message,
StreamChannel.of(context).channel.cid,
);
await StreamChannel.of(context).channel.deleteMessage(widget.message);
} catch (err) {
_showErrorAlert();
}
@@ -570,10 +564,9 @@ class _MessageActionsModalState extends State<MessageActionsModal> {
return InkWell(
onTap: () {
Navigator.pop(context);
final client = StreamChat.of(context).client;
final channel = StreamChannel.of(context).channel;
if (isUpdateFailed) {
client.updateMessage(widget.message, channel.cid);
channel.updateMessage(widget.message);
} else {
channel.sendMessage(widget.message);
}
@@ -1,7 +1,7 @@
import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:emojis/emoji.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/cupertino.dart';
@@ -9,11 +9,9 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:http_parser/http_parser.dart' as http_parser;
import 'package:image_picker/image_picker.dart';
import 'package:mime/mime.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:stream_chat_flutter/src/compress_video_service.dart';
import 'package:stream_chat_flutter/src/video_service.dart';
import 'package:stream_chat_flutter/src/media_list_view.dart';
import 'package:stream_chat_flutter/src/message_list_view.dart';
import 'package:stream_chat_flutter/src/stream_chat_theme.dart';
@@ -21,16 +19,16 @@ import 'package:stream_chat_flutter/src/stream_svg_icon.dart';
import 'package:stream_chat_flutter/src/user_avatar.dart';
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
import 'package:substring_highlight/substring_highlight.dart';
import 'package:video_compress/video_compress.dart';
import '../stream_chat_flutter.dart';
import 'attachment/attachment.dart';
import 'extension.dart';
import 'quoted_message_widget.dart';
import 'video_thumbnail_image.dart';
typedef FileUploader = Future<String> Function(PlatformFile, Channel);
typedef AttachmentThumbnailBuilder = Widget Function(
BuildContext,
_SendingAttachment,
Attachment,
);
enum ActionsLocation {
@@ -46,7 +44,7 @@ enum DefaultAttachmentTypes {
const _kMinMediaPickerSize = 360.0;
const _kMaxAttachmentSize = 20480; //20MB
const _kMaxAttachmentSize = 20971520; // 20MB in Bytes
/// Inactive state
/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/screenshots/message_input.png)
@@ -101,8 +99,6 @@ class MessageInput extends StatefulWidget {
this.maxHeight = 150,
this.keyboardType = TextInputType.multiline,
this.disableAttachments = false,
this.doImageUploadRequest,
this.doFileUploadRequest,
this.initialMessage,
this.textEditingController,
this.actions,
@@ -138,12 +134,6 @@ class MessageInput extends StatefulWidget {
/// If true the attachments button will not be displayed
final bool disableAttachments;
/// Override image upload request
final FileUploader doImageUploadRequest;
/// Override file upload request
final FileUploader doFileUploadRequest;
/// The text controller of the TextField
final TextEditingController textEditingController;
@@ -184,7 +174,7 @@ class MessageInput extends StatefulWidget {
}
class MessageInputState extends State<MessageInput> {
final List<_SendingAttachment> _attachments = [];
final _attachments = <String, Attachment>{};
final List<User> _mentionedUsers = [];
final _imagePicker = ImagePicker();
@@ -210,6 +200,38 @@ class MessageInputState extends State<MessageInput> {
bool get _hasQuotedMessage => widget.quotedMessage != null;
@override
void initState() {
super.initState();
_focusNode = widget.focusNode ?? FocusNode();
_emojiNames = Emoji.all().map((e) => e.name);
if (!kIsWeb) {
_keyboardListener =
_keyboardVisibilityController.onChange.listen((visible) {
if (_focusNode.hasFocus) {
_onChanged(context, textEditingController.text);
}
});
}
textEditingController =
widget.textEditingController ?? TextEditingController();
if (widget.editMessage != null || widget.initialMessage != null) {
_parseExistingMessage(widget.editMessage ?? widget.initialMessage);
}
textEditingController.addListener(() {
_onChanged(context, textEditingController.text);
});
_focusNode.addListener(() {
if (_focusNode.hasFocus) {
_openFilePickerSection = false;
}
});
}
@override
Widget build(BuildContext context) {
Widget child = SafeArea(
@@ -363,8 +385,7 @@ class MessageInputState extends State<MessageInput> {
return Padding(
padding: const EdgeInsets.all(8.0),
child: AnimatedCrossFade(
crossFadeState: ((_messageIsPresent || _attachments.isNotEmpty) &&
_attachments.every((a) => a.uploaded == true))
crossFadeState: (_messageIsPresent || _attachments.isNotEmpty)
? CrossFadeState.showFirst
: CrossFadeState.showSecond,
firstChild: _buildSendButton(context),
@@ -757,8 +778,9 @@ class MessageInputState extends State<MessageInput> {
}
Widget _buildFilePickerSection() {
var _attachmentContainsFile =
_attachments.any((element) => element?.attachment?.type == 'file');
final _attachmentContainsFile = _attachments.values.any((it) {
return it.type == 'file';
});
Color _getIconColor(int index) {
switch (index) {
@@ -913,8 +935,9 @@ class MessageInputState extends State<MessageInput> {
}
Widget _buildPickerSection() {
var _attachmentContainsFile =
_attachments.any((element) => element.attachment?.type == 'file');
final _attachmentContainsFile = _attachments.values.any((it) {
return it.type == 'file';
});
switch (_filePickerIndex) {
case 0:
@@ -949,16 +972,12 @@ class MessageInputState extends State<MessageInput> {
);
}
return MediaListView(
selectedIds: _attachments.map((e) => e.id).toList(),
selectedIds: _attachments.keys.toList(),
onSelect: (media) async {
if (!_attachments
.any((element) => element.id == media.id)) {
if (!_attachments.containsKey(media.id)) {
_addAttachment(media);
} else {
setState(() {
_attachments
.removeWhere((element) => element.id == media.id);
});
setState(() => _attachments.remove(media.id));
}
},
);
@@ -1019,100 +1038,47 @@ class MessageInputState extends State<MessageInput> {
}
void _addAttachment(AssetEntity medium) async {
final attachment = _SendingAttachment(
id: medium.id,
final mediaFile = await medium.originFile.timeout(
Duration(seconds: 5),
onTimeout: () => medium.originFile,
);
try {
setState(() {
_attachments.add(attachment);
});
final mediaFile = await medium.originFile.timeout(
Duration(seconds: 5),
onTimeout: () => medium.originFile,
);
var file = AttachmentFile(
path: mediaFile.path,
size: await mediaFile.length(),
bytes: mediaFile.readAsBytesSync(),
);
var file = PlatformFile(
path: mediaFile.path,
size: ((await mediaFile.length()) / 1024).ceil(),
bytes: mediaFile.readAsBytesSync(),
);
if (file.size > _kMaxAttachmentSize) {
if (medium?.type == AssetType.video) {
final mediaInfo = await VideoService.compressVideo(file.path);
if (file.size > _kMaxAttachmentSize) {
if (medium?.type == AssetType.video) {
final mediaInfo = await compressVideoService.compressVideo(file.path);
if (mediaInfo.filesize / (1024 * 1024) > _kMaxAttachmentSize) {
_showErrorAlert(
'The file is too large to upload. The file size limit is 20MB. We tried compressing it, but it was not enough.',
);
setState(() {
_attachments.remove(attachment);
});
return;
}
file = PlatformFile(
name: file.name,
size: (mediaInfo.filesize / 1024).ceil(),
bytes: await mediaInfo.file.readAsBytes(),
path: mediaInfo.path,
);
} else {
if (mediaInfo.filesize > _kMaxAttachmentSize) {
_showErrorAlert(
'The file is too large to upload. The file size limit is 20MB.',
'The file is too large to upload. The file size limit is 20MB. We tried compressing it, but it was not enough.',
);
return;
}
}
final channel = StreamChannel.of(context).channel;
setState(() {
attachment
..file = file
..attachment = Attachment(
localUri: file.path != null ? Uri.parse(file.path) : null,
type: medium?.type == AssetType.image ? 'image' : 'video',
);
});
final url = await _uploadAttachment(
file,
medium.type == AssetType.image
? DefaultAttachmentTypes.image
: DefaultAttachmentTypes.video,
channel);
final fileType = medium.type == AssetType.image
? DefaultAttachmentTypes.image
: DefaultAttachmentTypes.video;
if (fileType == DefaultAttachmentTypes.image) {
attachment.attachment = attachment.attachment.copyWith(
imageUrl: url,
file = AttachmentFile(
name: file.name,
size: mediaInfo.filesize,
bytes: await mediaInfo.file.readAsBytes(),
path: mediaInfo.path,
);
} else {
attachment.attachment = attachment.attachment.copyWith(
assetUrl: url,
_showErrorAlert(
'The file is too large to upload. The file size limit is 20MB.',
);
}
if (mounted) {
setState(() {
attachment.uploaded = true;
});
}
} catch (e, s) {
setState(() {
_attachments.remove(attachment);
});
print(e);
print(s);
// ignore: deprecated_member_use
Scaffold.of(context).showSnackBar(
SnackBar(
content: Text('Error adding the attachment: $e'),
),
);
}
setState(() {
_attachments[medium.id] = Attachment(
id: medium.id,
file: file,
type: medium.type == AssetType.image ? 'image' : 'video',
);
});
}
Widget _buildCommandIcon(String iconType) {
@@ -1536,9 +1502,15 @@ class MessageInputState extends State<MessageInput> {
Widget _buildAttachments() {
if (_attachments.isEmpty) return Offstage();
final fileAttachments = _attachments.values
.where((it) => it.type == 'file')
.toList(growable: false);
final remainingAttachments = _attachments.values
.where((it) => it.type != 'file')
.toList(growable: false);
return Column(
children: [
if (_attachments.any((e) => e.attachment?.type == 'file'))
if (fileAttachments.isNotEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 0),
child: LimitedBox(
@@ -1546,55 +1518,37 @@ class MessageInputState extends State<MessageInput> {
child: ListView(
reverse: true,
shrinkWrap: true,
children: _attachments.reversed
.where((e) => e.attachment?.type == 'file')
children: fileAttachments.reversed
.map<Widget>(
(e) => ClipRRect(
borderRadius: BorderRadius.circular(10),
clipBehavior: Clip.antiAlias,
child: FileAttachment(
attachment: e.attachment,
attachmentType: FileAttachmentType.local,
file: e.file,
message: null,
attachment: e,
size: Size(
MediaQuery.of(context).size.width * 0.65,
56.0,
),
trailing: Padding(
padding: const EdgeInsets.all(8.0),
child: InkWell(
child: CircleAvatar(
backgroundColor: StreamChatTheme.of(context)
.colorTheme
.black
.withOpacity(0.6),
maxRadius: 12.0,
child: StreamSvgIcon.close(
color: StreamChatTheme.of(context)
.colorTheme
.white,
),
),
onTap: () =>
setState(() => _attachments.remove(e)),
),
child: _buildRemoveButton(e),
),
),
),
)
.insertBetween(const SizedBox(width: 8)),
.insertBetween(const SizedBox(height: 8)),
),
),
),
if (_attachments.any((e) => e.attachment?.type != 'file'))
if (remainingAttachments.isNotEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 0),
child: LimitedBox(
maxHeight: 104.0,
child: ListView(
scrollDirection: Axis.horizontal,
children: _attachments
.where((e) => e.attachment?.type != 'file')
children: remainingAttachments
.map<Widget>(
(attachment) => ClipRRect(
borderRadius: BorderRadius.circular(10),
@@ -1609,17 +1563,11 @@ class MessageInputState extends State<MessageInput> {
child: _buildAttachment(attachment),
),
),
_buildRemoveButton(attachment),
attachment.uploaded
? SizedBox()
: Positioned.fill(
child: Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: CircularProgressIndicator(),
),
),
),
Positioned(
top: 8,
right: 8,
child: _buildRemoveButton(attachment),
),
],
),
),
@@ -1632,12 +1580,10 @@ class MessageInputState extends State<MessageInput> {
);
}
Positioned _buildRemoveButton(_SendingAttachment attachment) {
return Positioned(
Widget _buildRemoveButton(Attachment attachment) {
return Container(
height: 24,
width: 24,
top: 4,
right: 4,
child: RawMaterialButton(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
@@ -1648,9 +1594,7 @@ class MessageInputState extends State<MessageInput> {
disabledElevation: 0,
hoverElevation: 0,
onPressed: () {
setState(() {
_attachments.remove(attachment);
});
setState(() => _attachments.remove(attachment.id));
},
fillColor: StreamChatTheme.of(context).colorTheme.black.withOpacity(.5),
child: Center(
@@ -1663,21 +1607,18 @@ class MessageInputState extends State<MessageInput> {
);
}
Widget _buildAttachment(_SendingAttachment attachment) {
if (widget.attachmentThumbnailBuilders
?.containsKey(attachment.attachment?.type) ==
Widget _buildAttachment(Attachment attachment) {
if (attachment == null) return Offstage();
if (widget.attachmentThumbnailBuilders?.containsKey(attachment.type) ==
true) {
return widget.attachmentThumbnailBuilders[attachment.attachment?.type](
return widget.attachmentThumbnailBuilders[attachment.type](
context,
attachment,
);
}
if (attachment.attachment == null) {
return SizedBox();
}
switch (attachment.attachment?.type) {
switch (attachment.type) {
case 'image':
case 'giphy':
return attachment.file != null
@@ -1691,33 +1632,32 @@ class MessageInputState extends State<MessageInput> {
);
},
)
: Image.network(
attachment.attachment.imageUrl,
: CachedNetworkImage(
imageUrl: attachment.imageUrl ??
attachment.assetUrl ??
attachment.thumbUrl,
fit: BoxFit.cover,
errorWidget: (_, obj, trace) {
return getFileTypeImage(attachment.extraData['other']);
},
progressIndicatorBuilder: (context, _, progress) {
return Center(
child: Container(
width: 20.0,
height: 20.0,
child: const CircularProgressIndicator(),
),
);
},
);
break;
case 'video':
return Stack(
children: [
Positioned.fill(
child: Container(
child: FutureBuilder<File>(
future: VideoCompress.getFileThumbnail(attachment.file.path),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return Image.asset(
'images/placeholder.png',
package: 'stream_chat_flutter',
);
}
return Image.file(
snapshot.data,
fit: BoxFit.cover,
);
},
),
),
VideoThumbnailImage(
height: 104,
width: 104,
video: attachment.file?.path ?? attachment.assetUrl,
fit: BoxFit.cover,
),
Positioned(
left: 8,
@@ -1729,7 +1669,6 @@ class MessageInputState extends State<MessageInput> {
),
],
);
break;
default:
return Container(
child: Icon(Icons.insert_drive_file),
@@ -1895,21 +1834,18 @@ class MessageInputState extends State<MessageInput> {
/// Use this to add custom type attachments
void addAttachment(Attachment attachment) {
setState(() {
_attachments.add(_SendingAttachment(
attachment: attachment,
uploaded: true,
));
_attachments[attachment.id] = attachment.copyWith(
uploadState: attachment.uploadState ?? UploadState.success(),
);
});
}
/// Pick a file from the device
/// If [camera] is true then the camera will open
void pickFile(DefaultAttachmentTypes fileType, [bool camera = false]) async {
setState(() {
_inputEnabled = false;
});
setState(() => _inputEnabled = false);
PlatformFile file;
AttachmentFile file;
String attachmentType;
if (fileType == DefaultAttachmentTypes.image) {
@@ -1931,8 +1867,8 @@ class MessageInputState extends State<MessageInput> {
return;
}
final bytes = await pickedFile.readAsBytes();
file = PlatformFile(
size: (bytes.length / 1024).ceil(),
file = AttachmentFile(
size: bytes.length,
path: pickedFile.path,
bytes: bytes,
);
@@ -1950,21 +1886,17 @@ class MessageInputState extends State<MessageInput> {
withData: true,
);
if (res?.files?.isNotEmpty == true) {
file = res.files.single;
file = res.files.single.toAttachmentFile;
}
}
setState(() {
_inputEnabled = true;
});
setState(() => _inputEnabled = true);
if (file == null) {
return;
}
if (file == null) return;
final mimeType = _getMimeType(file.path.split('/').last);
final mimeType = file.path.split('/').last.mimeType;
var extraDataMap = <String, dynamic>{};
final extraDataMap = <String, dynamic>{};
if (camera) {
if (mimeType.type == 'video' || mimeType.type == 'image') {
@@ -1982,125 +1914,48 @@ class MessageInputState extends State<MessageInput> {
extraDataMap['file_size'] = file.size;
}
final channel = StreamChannel.of(context).channel;
final attachment = _SendingAttachment(
final attachment = Attachment(
file: file,
attachment: Attachment(
localUri: file.path != null ? Uri.parse(file.path) : null,
type: attachmentType,
extraData: extraDataMap.isNotEmpty ? extraDataMap : null,
title: file.name,
),
type: attachmentType,
extraData: extraDataMap.isNotEmpty ? extraDataMap : null,
);
setState(() {
_attachments.add(attachment);
});
_attachments[attachment.id] = attachment;
if (file.size / 1024 > _kMaxAttachmentSize) {
if (attachmentType == 'video') {
final mediaInfo = await compressVideoService.compressVideo(file.path);
file = PlatformFile(
name: mediaInfo.title,
size: (mediaInfo.filesize / 1024).ceil(),
if (file.size > _kMaxAttachmentSize) {
if (attachmentType == 'Video') {
final mediaInfo = await VideoService.compressVideo(file.path);
if (mediaInfo.filesize > _kMaxAttachmentSize) {
_showErrorAlert(
'The file is too large to upload. The file size limit is 20MB. We tried compressing it, but it was not enough.',
);
_attachments.remove(attachment.id);
return;
}
file = AttachmentFile(
name: file.name,
size: mediaInfo.filesize,
bytes: await mediaInfo.file.readAsBytes(),
path: mediaInfo.path,
);
setState(() {
attachment.file = file;
});
} else {
// ignore: deprecated_member_use
_showErrorAlert(
'The file is too large to upload. The file size limit is 20MB.',
);
setState(() {
_attachments.remove(attachment);
});
return;
}
}
final url = await _uploadAttachment(file, fileType, channel);
if (fileType == DefaultAttachmentTypes.image) {
attachment.attachment = attachment.attachment.copyWith(
imageUrl: url,
);
} else {
attachment.attachment = attachment.attachment.copyWith(
assetUrl: url,
);
}
setState(() {
attachment.uploaded = true;
_attachments.update(attachment.id, (it) {
return it.copyWith(
file: file,
extraData: {...it.extraData}..update('file_size', (_) => file.size),
);
});
});
}
Future<String> _uploadAttachment(
PlatformFile file,
DefaultAttachmentTypes type,
Channel channel,
) async {
String url;
if (type == DefaultAttachmentTypes.image) {
if (widget.doImageUploadRequest != null) {
url = await widget.doImageUploadRequest(file, channel);
} else {
url = await _uploadImage(file, channel);
}
} else {
if (widget.doFileUploadRequest != null) {
url = await widget.doFileUploadRequest(file, channel);
} else {
url = await _uploadFile(file, channel);
}
}
return url;
}
Future<String> _uploadImage(PlatformFile file, Channel channel) async {
final filename = file.path?.split('/')?.last;
final mimeType = _getMimeType(filename);
final bytes = file.bytes;
final res = await channel.sendImage(
MultipartFile.fromBytes(
bytes,
filename: filename,
contentType: mimeType,
),
);
return res.file;
}
http_parser.MediaType _getMimeType(String filename) {
http_parser.MediaType mimeType;
if (filename != null) {
if (filename.toLowerCase().endsWith('heic')) {
mimeType = http_parser.MediaType.parse('image/heic');
} else {
mimeType = http_parser.MediaType.parse(lookupMimeType(filename));
}
}
return mimeType;
}
Future<String> _uploadFile(PlatformFile file, Channel channel) async {
final filename = file.path?.split('/')?.last;
final mimeType = _getMimeType(filename);
final bytes = file.bytes;
final res = await channel.sendFile(
MultipartFile.fromBytes(
bytes,
filename: filename,
contentType: mimeType,
),
);
return res.file;
}
Widget _buildIdleSendButton(BuildContext context) {
return StreamSvgIcon(
assetName: _getIdleSendIcon(),
@@ -2153,7 +2008,7 @@ class MessageInputState extends State<MessageInput> {
text = '/${_chosenCommand.name} ' + text;
}
final attachments = List<_SendingAttachment>.from(_attachments);
final attachments = [..._attachments.values];
textEditingController.clear();
_attachments.clear();
@@ -2176,7 +2031,7 @@ class MessageInputState extends State<MessageInput> {
if (widget.editMessage != null) {
message = widget.editMessage.copyWith(
text: text,
attachments: _getAttachments(attachments).toList(),
attachments: attachments,
mentionedUsers:
_mentionedUsers.where((u) => text.contains('@${u.name}')).toList(),
);
@@ -2184,7 +2039,7 @@ class MessageInputState extends State<MessageInput> {
message = (widget.initialMessage ?? Message()).copyWith(
parentId: widget.parentMessage?.id,
text: text,
attachments: _getAttachments(attachments).toList(),
attachments: attachments,
mentionedUsers:
_mentionedUsers.where((u) => text.contains('@${u.name}')).toList(),
showInChannel: widget.parentMessage != null ? _sendAsDm : null,
@@ -2210,13 +2065,11 @@ class MessageInputState extends State<MessageInput> {
_mentionedUsers.clear();
if (widget.editMessage == null ||
widget.editMessage.status == MessageSendingStatus.failed) {
widget.editMessage.status == MessageSendingStatus.failed ||
widget.editMessage.status == MessageSendingStatus.sending) {
sendingFuture = channel.sendMessage(message);
} else {
sendingFuture = StreamChat.of(context).client.updateMessage(
message,
channel.cid,
);
sendingFuture = channel.updateMessage(message);
}
return sendingFuture.then((resp) {
@@ -2226,47 +2079,8 @@ class MessageInputState extends State<MessageInput> {
});
}
Iterable<Attachment> _getAttachments(List<_SendingAttachment> attachments) {
return attachments.map((attachment) {
return attachment.attachment;
});
}
StreamSubscription _keyboardListener;
@override
void initState() {
super.initState();
_focusNode = widget.focusNode ?? FocusNode();
_emojiNames = Emoji.all().map((e) => e.name);
if (!kIsWeb) {
_keyboardListener =
_keyboardVisibilityController.onChange.listen((visible) {
if (_focusNode.hasFocus) {
_onChanged(context, textEditingController.text);
}
});
}
textEditingController =
widget.textEditingController ?? TextEditingController();
if (widget.editMessage != null || widget.initialMessage != null) {
_parseExistingMessage(widget.editMessage ?? widget.initialMessage);
}
textEditingController.addListener(() {
_onChanged(context, textEditingController.text);
});
_focusNode.addListener(() {
if (_focusNode.hasFocus) {
_openFilePickerSection = false;
}
});
}
void _showErrorAlert(String description) {
showModalBottomSheet(
backgroundColor: StreamChatTheme.of(context).colorTheme.white,
@@ -2340,15 +2154,12 @@ class MessageInputState extends State<MessageInput> {
void _parseExistingMessage(Message message) {
textEditingController.text = message.text;
_messageIsPresent = true;
message.attachments?.forEach((attachment) {
_attachments.add(_SendingAttachment(
attachment: attachment,
uploaded: true,
));
});
for (final attachment in message.attachments) {
_attachments[attachment.id] = attachment.copyWith(
uploadState: attachment.uploadState ?? UploadState.success(),
);
}
}
@override
@@ -2372,20 +2183,6 @@ class MessageInputState extends State<MessageInput> {
}
}
class _SendingAttachment {
PlatformFile file;
Attachment attachment;
bool uploaded;
String id;
_SendingAttachment({
this.file,
this.attachment,
this.uploaded = false,
this.id,
});
}
/// Represents a 2-tuple, or pair.
class Tuple2<T1, T2> {
/// Returns the first item of the tuple
@@ -257,8 +257,6 @@ class _MessageListViewState extends State<MessageListView> {
final MessageListController _messageListController = MessageListController();
final Map<String, VideoPackage> videoPackages = {};
@override
Widget build(BuildContext context) {
return MessageListCore(
@@ -800,7 +798,6 @@ class _MessageListViewState extends State<MessageListView> {
break;
}
},
videoPackages: videoPackages,
);
}
@@ -961,10 +958,12 @@ class _MessageListViewState extends State<MessageListView> {
break;
}
},
videoPackages: videoPackages,
);
if (!message.isDeleted && !message.isSystem && !message.isEphemeral) {
if (!message.isDeleted &&
!message.isSystem &&
!message.isEphemeral &&
widget.onMessageSwiped != null) {
child = Swipeable(
onSwipeEnd: () {
FocusScope.of(context).unfocus();
@@ -1078,7 +1077,6 @@ class _MessageListViewState extends State<MessageListView> {
streamChannel.reloadChannel();
}
_messageNewListener?.cancel();
videoPackages.values.forEach((e) => e.dispose());
super.dispose();
}
}
@@ -23,7 +23,6 @@ class MessageReactionsModal extends StatelessWidget {
final ShapeBorder messageShape;
final ShapeBorder attachmentShape;
final void Function(User) onUserAvatarTap;
final Map<String, VideoPackage> videoPackages;
const MessageReactionsModal({
Key key,
@@ -37,7 +36,6 @@ class MessageReactionsModal extends StatelessWidget {
this.reverse = false,
this.showUserAvatar = DisplayWidget.show,
this.onUserAvatarTap,
this.videoPackages,
}) : super(key: key);
@override
@@ -146,7 +144,6 @@ class MessageReactionsModal extends StatelessWidget {
(message.status ==
MessageSendingStatus.sent ||
message.status == null),
videoPackages: videoPackages,
),
),
if (message.latestReactions?.isNotEmpty == true) ...[
@@ -14,6 +14,7 @@ import 'package:stream_chat_flutter/src/reaction_bubble.dart';
import 'package:stream_chat_flutter/src/url_attachment.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
import 'attachment/attachment.dart';
import 'extension.dart';
import 'image_group.dart';
import 'message_text.dart';
@@ -142,9 +143,6 @@ class MessageWidget extends StatefulWidget {
/// Function called when quotedMessage is tapped
final OnQuotedMessageTap onQuotedMessageTap;
/// The cache for the video controllers of attachments IDed as message ID + attachment index
final Map<String, VideoPackage> videoPackages;
///
MessageWidget({
Key key,
@@ -193,7 +191,6 @@ class MessageWidget extends StatefulWidget {
this.attachmentPadding = EdgeInsets.zero,
this.allRead = false,
this.onQuotedMessageTap,
this.videoPackages,
}) : attachmentBuilders = {
'image': (context, message, attachment) {
return ImageAttachment(
@@ -236,6 +233,7 @@ class MessageWidget extends StatefulWidget {
},
'file': (context, message, attachment) {
return FileAttachment(
message: message,
attachment: attachment,
size: Size(
MediaQuery.of(context).size.width * 0.8,
@@ -250,7 +248,8 @@ class MessageWidget extends StatefulWidget {
_MessageWidgetState createState() => _MessageWidgetState();
}
class _MessageWidgetState extends State<MessageWidget> {
class _MessageWidgetState extends State<MessageWidget>
with AutomaticKeepAliveClientMixin<MessageWidget> {
bool get showThreadReplyIndicator => widget.showThreadReplyIndicator;
bool get showSendingIndicator => widget.showSendingIndicator;
@@ -298,8 +297,12 @@ class _MessageWidgetState extends State<MessageWidget> {
showSendingIndicator ||
isDeleted;
@override
bool get wantKeepAlive => widget.message.attachments?.isNotEmpty == true;
@override
Widget build(BuildContext context) {
super.build(context);
final avatarWidth = widget.messageTheme.avatarTheme.constraints.maxWidth;
var leftPadding =
widget.showUserAvatar != DisplayWidget.gone ? avatarWidth + 8.5 : 0.5;
@@ -744,7 +747,6 @@ class _MessageWidgetState extends State<MessageWidget> {
!isFailedState &&
widget.onThreadTap != null,
showFlagButton: widget.showFlagButton,
videoPackages: widget.videoPackages,
),
);
});
@@ -773,7 +775,6 @@ class _MessageWidgetState extends State<MessageWidget> {
editMessageInputBuilder: widget.editMessageInputBuilder,
onThreadTap: widget.onThreadTap,
showReactions: widget.showReactions,
videoPackages: widget.videoPackages,
),
);
});
@@ -824,6 +825,7 @@ class _MessageWidgetState extends State<MessageWidget> {
),
images: images,
message: widget.message,
messageTheme: widget.messageTheme,
onShowMessage: widget.onShowMessage,
),
),
@@ -838,41 +840,6 @@ class _MessageWidgetState extends State<MessageWidget> {
children: widget.message.attachments
?.where((element) => element.ogScrapeUrl == null)
?.map((attachment) {
if (attachment.type == 'video') {
VideoPackage package;
if (widget.videoPackages == null) {
package = VideoPackage(context, attachment, () {});
} else {
package = widget?.videoPackages[
'${widget.message.id}${widget.message.attachments.indexOf(attachment)}'] ??
VideoPackage(context, attachment, () {});
}
if (widget.videoPackages != null) {
widget.videoPackages[
'${widget.message.id}${widget.message.attachments.indexOf(attachment)}'] =
package;
}
return Transform(
transform: Matrix4.rotationY(widget.reverse ? pi : 0),
alignment: Alignment.center,
child: VideoAttachment(
attachment: attachment,
messageTheme: widget.messageTheme,
size: Size(
MediaQuery.of(context).size.width * 0.8,
MediaQuery.of(context).size.height * 0.3,
),
message: widget.message,
onShowMessage: widget.onShowMessage,
onReturnAction: widget.onReturnAction,
videoPackage: package,
),
);
}
final attachmentBuilder =
widget.attachmentBuilders[attachment.type];
@@ -885,7 +852,6 @@ class _MessageWidgetState extends State<MessageWidget> {
return wrapAttachmentWidget(
context,
attachmentWidget,
attachment: attachment,
);
})?.insertBetween(SizedBox(
height: widget.attachmentPadding.vertical / 2,
@@ -897,9 +863,8 @@ class _MessageWidgetState extends State<MessageWidget> {
Widget wrapAttachmentWidget(
BuildContext context,
Widget attachmentWidget, {
Attachment attachment,
}) {
Widget attachmentWidget,
) {
final attachmentShape =
widget.attachmentShape ?? _getDefaultAttachmentShape(context);
return Material(
@@ -930,8 +895,29 @@ class _MessageWidgetState extends State<MessageWidget> {
Widget _buildSendingIndicator() {
final style = widget.messageTheme.createdAt;
final message = widget.message;
if (hasNonUrlAttachments &&
(message.status == MessageSendingStatus.sending ||
message.status == MessageSendingStatus.updating)) {
final totalAttachments = message.attachments.length;
final uploadRemaining = message.attachments.where((it) {
return !it.uploadState.isSuccess;
}).length;
if (uploadRemaining == 0) {
return StreamSvgIcon.check(
size: style.fontSize,
color: IconTheme.of(context).color.withOpacity(0.5),
);
}
return Text(
'Uploading $uploadRemaining/$totalAttachments ...',
style: style,
);
}
Widget child = SendingIndicator(
message: widget.message,
message: message,
isMessageRead: isMessageRead,
size: style.fontSize,
);
@@ -1032,18 +1018,12 @@ class _MessageWidgetState extends State<MessageWidget> {
return;
}
if (widget.message.status == MessageSendingStatus.failed_update) {
StreamChat.of(context).client.updateMessage(
widget.message,
channel.cid,
);
channel.updateMessage(widget.message);
return;
}
if (widget.message.status == MessageSendingStatus.failed_delete) {
StreamChat.of(context).client.deleteMessage(
widget.message,
channel.cid,
);
channel.deleteMessage(widget.message);
return;
}
}
@@ -5,9 +5,8 @@ import 'package:flutter/material.dart';
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
import 'package:video_player/video_player.dart';
import 'attachment_error.dart';
import 'attachment/attachment.dart';
import 'extension.dart';
import 'image_attachment.dart';
import 'message_text.dart';
import 'stream_chat_theme.dart';
import 'user_avatar.dart';
@@ -200,10 +199,7 @@ class QuotedMessageWidget extends StatelessWidget {
),
);
}
return AttachmentError(
attachment: attachment,
size: size,
);
return AttachmentError(size: size);
}
Widget _parseAttachments(BuildContext context) {
@@ -231,8 +227,8 @@ class QuotedMessageWidget extends StatelessWidget {
transform: Matrix4.rotationY(reverse ? pi : 0),
alignment: Alignment.center,
child: Material(
clipBehavior: Clip.hardEdge,
color: Colors.transparent,
clipBehavior: Clip.antiAlias,
type: MaterialType.transparency,
shape: attachment.type == 'file' ? null : _getDefaultShape(context),
child: child,
),
@@ -294,10 +290,9 @@ class QuotedMessageWidget extends StatelessWidget {
},
imageUrl:
attachment.thumbUrl ?? attachment.imageUrl ?? attachment.assetUrl,
errorWidget: (context, url, error) => AttachmentError(
attachment: attachment,
size: size,
),
errorWidget: (context, url, error) {
return AttachmentError(size: size);
},
fit: BoxFit.cover,
);
},
@@ -901,4 +901,16 @@ class StreamSvgIcon extends StatelessWidget {
height: size,
);
}
factory StreamSvgIcon.retry({
double size,
Color color,
}) {
return StreamSvgIcon(
assetName: 'icon_retry.svg',
color: color,
width: size,
height: size,
);
}
}
@@ -0,0 +1,62 @@
import 'package:flutter/material.dart';
import 'stream_chat_theme.dart';
class UploadProgressIndicator extends StatelessWidget {
final int uploaded;
final int total;
final Color progressIndicatorColor;
final EdgeInsetsGeometry padding;
final bool showBackground;
final TextStyle textStyle;
const UploadProgressIndicator({
Key key,
@required this.uploaded,
@required this.total,
this.progressIndicatorColor = const Color(0xffb2b2b2),
this.padding = const EdgeInsets.only(top: 5, bottom: 5, right: 11, left: 5),
this.showBackground = true,
this.textStyle,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final theme = StreamChatTheme.of(context);
final _percentage = (uploaded / total) * 100;
Widget child = Padding(
padding: padding,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
height: 16,
width: 16,
child: CircularProgressIndicator(
strokeWidth: 3,
valueColor: AlwaysStoppedAnimation(progressIndicatorColor),
),
),
SizedBox(width: 8),
Text(
'${_percentage.toInt()}%',
style: textStyle ??
theme.textTheme.footnote.copyWith(
color: theme.colorTheme.white,
),
),
],
),
);
if (showBackground) {
child = Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: theme.colorTheme.overlayDark.withOpacity(0.6),
),
child: child,
);
}
return child;
}
}
@@ -214,18 +214,70 @@ String getWebsiteName(String hostName) {
}
}
///
String getSizeText(int bytes) {
if (bytes == null) {
return 'Size N/A';
/// A method returns a human readable string representing a file _size
String fileSize(dynamic size, [int round = 2]) {
if (size == null) return 'Size N/A';
/**
* [size] can be passed as number or as string
*
* the optional parameter [round] specifies the number
* of digits after comma/point (default is 2)
*/
final divider = 1024;
int _size;
try {
_size = int.parse(size.toString());
} catch (e) {
throw ArgumentError('Can not parse the size parameter: $e');
}
if (bytes <= 1000) {
return '$bytes bytes';
} else if (bytes <= 100000) {
return '${(bytes / 1000).toStringAsFixed(2)} KB';
if (_size < divider) {
return '$_size B';
}
if (_size < divider * divider && _size % divider == 0) {
return '${(_size / divider).toStringAsFixed(0)} KB';
}
if (_size < divider * divider) {
return '${(_size / divider).toStringAsFixed(round)} KB';
}
if (_size < divider * divider * divider && _size % divider == 0) {
return '${(_size / (divider * divider)).toStringAsFixed(0)} MB';
}
if (_size < divider * divider * divider) {
return '${(_size / divider / divider).toStringAsFixed(round)} MB';
}
if (_size < divider * divider * divider * divider && _size % divider == 0) {
return '${(_size / (divider * divider * divider)).toStringAsFixed(0)} GB';
}
if (_size < divider * divider * divider * divider) {
return '${(_size / divider / divider / divider).toStringAsFixed(round)} GB';
}
if (_size < divider * divider * divider * divider * divider &&
_size % divider == 0) {
num r = _size / divider / divider / divider / divider;
return '${r.toStringAsFixed(0)} TB';
}
if (_size < divider * divider * divider * divider * divider) {
num r = _size / divider / divider / divider / divider;
return '${r.toStringAsFixed(round)} TB';
}
if (_size < divider * divider * divider * divider * divider * divider &&
_size % divider == 0) {
num r = _size / divider / divider / divider / divider / divider;
return '${r.toStringAsFixed(0)} PB';
} else {
return '${(bytes / 1000000).toStringAsFixed(2)} MB';
num r = _size / divider / divider / divider / divider / divider;
return '${r.toStringAsFixed(round)} PB';
}
}
@@ -1,125 +0,0 @@
import 'package:chewie/chewie.dart';
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/src/full_screen_media.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
import 'attachment_title.dart';
class VideoAttachment extends StatefulWidget {
final Attachment attachment;
final MessageTheme messageTheme;
final Size size;
final Message message;
final ShowMessageCallback onShowMessage;
final ValueChanged<ReturnActionType> onReturnAction;
final VideoPackage videoPackage;
VideoAttachment({
Key key,
@required this.attachment,
@required this.messageTheme,
this.videoPackage,
this.message,
this.size,
this.onShowMessage,
this.onReturnAction,
}) : super(key: key);
@override
_VideoAttachmentState createState() => _VideoAttachmentState();
}
class _VideoAttachmentState extends State<VideoAttachment> {
bool initialized = false;
@override
void initState() {
super.initState();
widget.videoPackage.onInit = () {
setState(() {
initialized = true;
});
};
}
@override
Widget build(BuildContext context) {
if (!widget.videoPackage.initialised) {
return Container(
height: widget.size?.height ?? 100,
width: widget.size?.width ?? 100,
child: Center(
child: CircularProgressIndicator(),
),
);
}
return GestureDetector(
onTap: () async {
final channel = StreamChannel.of(context).channel;
var res = await Navigator.push(
context,
MaterialPageRoute(
builder: (_) => StreamChannel(
channel: channel,
child: FullScreenMedia(
mediaAttachments: [widget.attachment],
userName: widget.message.user.name,
sentAt: widget.message.createdAt,
message: widget.message,
onShowMessage: widget.onShowMessage,
),
),
),
);
if (res != null) {
widget.onReturnAction(res);
}
},
child: Container(
height: widget.size?.height,
width: widget.size?.width,
child: Flex(
direction: Axis.vertical,
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Expanded(
child: FittedBox(
fit: BoxFit.none,
child: Stack(
children: <Widget>[
Chewie(
controller: widget.videoPackage.chewieController,
),
Positioned.fill(
child: Center(
child: Material(
shape: CircleBorder(),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Icon(Icons.play_arrow),
),
),
),
),
],
),
),
),
if (widget.attachment.title != null)
Material(
color: widget.messageTheme.messageBackgroundColor,
child: AttachmentTitle(
messageTheme: widget.messageTheme,
attachment: widget.attachment,
),
),
],
),
),
);
}
}
@@ -0,0 +1,62 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:synchronized/synchronized.dart';
import 'package:video_compress/video_compress.dart';
import 'package:video_thumbnail/video_thumbnail.dart';
import 'package:meta/meta.dart';
class IVideoService {
static final IVideoService instance = IVideoService._();
final _lock = Lock();
IVideoService._();
/// compress video from [path]
/// compress video from [path] return [Future<MediaInfo>]
///
/// you can choose its quality by [quality],
/// determine whether to delete his source file by [deleteOrigin]
/// optional parameters [startTime] [duration] [includeAudio] [frameRate]
///
/// ## example
/// ```dart
/// final info = await _flutterVideoCompress.compressVideo(
/// file.path,
/// deleteOrigin: true,
/// );
/// debugPrint(info.toJson());
/// ```
Future<MediaInfo> compressVideo(String path) async {
return _lock.synchronized(() {
return VideoCompress.compressVideo(
path,
);
});
}
/// Generates a thumbnail image data in memory as UInt8List, it can be easily used by Image.memory(...).
/// The video can be a local video file, or an URL repreents iOS or Android native supported video format.
/// Speicify the maximum height or width for the thumbnail or 0 for same resolution as the original video.
/// The lower quality value creates lower quality of the thumbnail image, but it gets ignored for PNG format.
Future<Uint8List> generateVideoThumbnail({
@required String video,
ImageFormat imageFormat = ImageFormat.PNG,
int maxHeight = 0,
int maxWidth = 0,
int timeMs = 0,
int quality = 10,
}) {
return VideoThumbnail.thumbnailData(
video: video,
imageFormat: imageFormat,
maxHeight: maxHeight,
maxWidth: maxWidth,
timeMs: timeMs,
quality: quality,
);
}
}
// ignore: non_constant_identifier_names
IVideoService get VideoService => IVideoService.instance;
@@ -0,0 +1,92 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:video_thumbnail/video_thumbnail.dart';
import 'stream_svg_icon.dart';
import 'video_service.dart';
class VideoThumbnailImage extends StatefulWidget {
final String video;
final double width;
final double height;
final BoxFit fit;
final ImageFormat format;
final Widget Function(BuildContext, Object) errorBuilder;
final WidgetBuilder placeholderBuilder;
const VideoThumbnailImage({
Key key,
@required this.video,
this.width,
this.height,
this.fit,
this.format = ImageFormat.PNG,
this.errorBuilder,
this.placeholderBuilder,
}) : super(key: key);
@override
_VideoThumbnailImageState createState() => _VideoThumbnailImageState();
}
class _VideoThumbnailImageState extends State<VideoThumbnailImage> {
Future<Uint8List> thumbnailFuture;
@override
void initState() {
thumbnailFuture = VideoService.generateVideoThumbnail(
video: widget.video,
imageFormat: widget.format,
);
super.initState();
}
@override
void didUpdateWidget(covariant VideoThumbnailImage oldWidget) {
if (oldWidget.video != widget.video || oldWidget.format != widget.format) {
thumbnailFuture = VideoService.generateVideoThumbnail(
video: widget.video,
imageFormat: widget.format,
);
}
super.didUpdateWidget(oldWidget);
}
@override
Widget build(BuildContext context) {
return FutureBuilder<Uint8List>(
future: thumbnailFuture,
builder: (context, snapshot) {
return AnimatedSwitcher(
duration: const Duration(milliseconds: 350),
child: Builder(
key: ValueKey<AsyncSnapshot<Uint8List>>(snapshot),
builder: (_) {
if (snapshot.hasError) {
return widget.errorBuilder?.call(context, snapshot.error) ??
Center(child: StreamSvgIcon.error());
}
if (!snapshot.hasData) {
return widget.placeholderBuilder?.call(context) ??
Image.asset(
'images/placeholder.png',
package: 'stream_chat_flutter',
fit: widget.fit,
height: widget.height,
width: widget.width,
);
}
return Image.memory(
snapshot.data,
fit: widget.fit,
height: widget.height,
width: widget.width,
);
},
),
);
},
);
}
}
@@ -7,12 +7,10 @@ export 'src/channel_name.dart';
export 'src/channel_preview.dart';
export 'src/date_divider.dart';
export 'src/deleted_message.dart';
export 'src/file_attachment.dart';
export 'src/attachment/attachment.dart';
export 'src/full_screen_media.dart';
export 'src/image_header.dart';
export 'src/image_footer.dart';
export 'src/giphy_attachment.dart';
export 'src/image_attachment.dart';
export 'src/message_input.dart';
export 'src/message_list_view.dart';
export 'src/message_text.dart';
@@ -31,7 +29,6 @@ export 'src/user_item.dart';
export 'src/user_list_view.dart';
export 'src/user_list_view.dart';
export 'src/utils.dart';
export 'src/video_attachment.dart';
export 'src/message_search_item.dart';
export 'src/message_search_list_view.dart';
export 'src/unread_indicator.dart';
@@ -0,0 +1,5 @@
<svg width="24" height="19" viewBox="0 0 24 19" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M11.96 2C8.26563 2 5.20156 4.75636 4.71677 8.32007H6.31992C7.10992 8.32007 7.57992 9.21007 7.12992 9.87007L4.45992 13.7401C4.06992 14.3101 3.22992 14.3101 2.83992 13.7401L0.179922 9.87007C-0.280078 9.21007 0.189922 8.32007 0.989922 8.32007H2.70214C3.19783 3.6451 7.16369 0 11.96 0C13.76 0 15.5 0.51 17.01 1.49L15.92 3.17C14.74 2.4 13.37 2 11.96 2ZM6.90991 17.1201C8.41991 18.1001 10.1599 18.6101 11.9599 18.6101C16.7556 18.6101 20.7211 14.9659 21.2175 10.2999H22.9299C23.7299 10.2999 24.1999 9.40989 23.7499 8.74989L21.0799 4.87989C20.6899 4.30989 19.8499 4.30989 19.4599 4.87989L16.7899 8.74989C16.3399 9.40989 16.8099 10.2999 17.5999 10.2999H19.2031C18.7184 13.8637 15.6543 16.6201 11.9599 16.6201C10.5499 16.6201 9.17991 16.2101 7.99991 15.4501L6.90991 17.1201Z"
fill="white" />
</svg>

After

Width:  |  Height:  |  Size: 954 B

+4 -3
View File
@@ -1,7 +1,7 @@
name: stream_chat_flutter
homepage: https://github.com/GetStream/stream-chat-flutter
description: Stream Chat official Flutter SDK. Build your own chat experience using Dart and Flutter.
version: 1.0.2-beta
version: 1.1.0-beta
repository: https://github.com/GetStream/stream-chat-flutter
issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues
@@ -11,7 +11,7 @@ environment:
dependencies:
flutter:
sdk: flutter
stream_chat_flutter_core: ^1.0.2-beta
stream_chat_flutter_core: ^1.1.0-beta
flutter_app_badger: ^1.1.2
photo_view: ^0.10.3
rxdart: ^0.25.0
@@ -29,10 +29,10 @@ dependencies:
file_picker: ^2.1.5
image_picker: ^0.6.7+17
flutter_keyboard_visibility: ^4.0.2
mime: ^0.9.7
video_compress: ^2.1.1
visibility_detector: ^0.1.5
http_parser: ^3.1.4
meta: ^1.2.4
lottie: ^0.7.0+1
substring_highlight: ^0.1.2
flutter_slidable: ^0.5.7
@@ -46,6 +46,7 @@ dependencies:
characters: ^1.0.0
dio: ^3.0.10
path_provider: ^1.6.27
video_thumbnail: ^0.2.5+1
flutter:
assets:
@@ -1,3 +1,7 @@
## 1.1.0-beta
* Update llc dependency
## 1.0.2-beta
* Update llc dependency
@@ -1,7 +1,7 @@
name: stream_chat_flutter_core
homepage: https://github.com/GetStream/stream-chat-flutter
description: Stream Chat official Flutter SDK Core. Build your own chat experience using Dart and Flutter.
version: 1.0.2-beta
version: 1.1.0-beta
repository: https://github.com/GetStream/stream-chat-flutter
issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues
@@ -10,7 +10,7 @@ environment:
flutter: ">=1.17.0"
dependencies:
stream_chat: ^1.0.3-beta
stream_chat: ^1.1.0-beta
flutter:
sdk: flutter
rxdart: ^0.25.0
@@ -1,3 +1,7 @@
## 1.1.0-beta
* Update llc dependency
## 1.0.2-beta
* Update llc dependency
@@ -11,7 +11,7 @@ dependencies:
flutter:
sdk: flutter
cupertino_icons: ^1.0.0
stream_chat: ^1.0.0-beta
stream_chat: ^1.1.0-beta
stream_chat_persistence:
path: ../
@@ -18,7 +18,7 @@ extension MessageEntityX on MessageEntity {
ownReactions: ownReactions,
attachments: attachments?.map((it) {
final json = jsonDecode(it);
return Attachment.fromJson(json);
return Attachment.fromData(json);
})?.toList(),
createdAt: createdAt,
extraData: extraData,
@@ -47,7 +47,9 @@ extension MessageX on Message {
MessageEntity toEntity({String cid}) {
return MessageEntity(
id: id,
attachments: attachments?.map((it) => jsonEncode(it))?.toList() ?? [],
attachments: attachments?.map((it) {
return jsonEncode(it.toData());
})?.toList(),
channelCid: cid,
type: type,
parentId: parentId,
@@ -1,7 +1,7 @@
name: stream_chat_persistence
homepage: https://github.com/GetStream/stream-chat-flutter
description: Official Stream Chat Persistence library. Build your own chat experience using Dart and Flutter.
version: 1.0.2-beta
version: 1.1.0-beta
repository: https://github.com/GetStream/stream-chat-flutter
issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues
@@ -12,8 +12,8 @@ dependencies:
moor: ^3.4.0
path: ^1.7.0
path_provider: ^1.6.27
sqlite3_flutter_libs: ^0.3.0
stream_chat: ^1.0.3-beta
sqlite3_flutter_libs: ^0.4.0+1
stream_chat: ^1.1.0-beta
dev_dependencies:
test: ^1.15.7