diff --git a/lib/src/file_attachment.dart b/lib/src/file_attachment.dart index fff13bbe..4bba7779 100644 --- a/lib/src/file_attachment.dart +++ b/lib/src/file_attachment.dart @@ -1,38 +1,79 @@ +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/stream_chat.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:video_compress/video_compress.dart'; +import 'package:video_player/video_player.dart'; +import 'media_utils.dart'; -class FileAttachment extends StatelessWidget { +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 { + VideoPlayerController _controller; + Future _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: size?.width ?? 100, + width: widget.size?.width ?? 100, height: 56.0, - margin: trailing != null ? EdgeInsets.only(top: 4.0) : null, + margin: widget.trailing != null ? EdgeInsets.only(top: 4.0) : null, decoration: BoxDecoration( color: Colors.white, - borderRadius: trailing != null ? BorderRadius.circular(16.0) : null, - border: trailing != null + borderRadius: + widget.trailing != null ? BorderRadius.circular(16.0) : null, + border: widget.trailing != null ? Border.fromBorderSide(BorderSide(color: Color(0xFFE6E6E6))) : null, ), child: Row( children: [ Container( - child: _getFileTypeImage(attachment.extraData['mime_type']), + child: _getFileTypeImage(), height: 40.0, width: 33.33, margin: EdgeInsets.all(8.0), @@ -46,7 +87,7 @@ class FileAttachment extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - attachment?.title ?? 'File', + widget.attachment?.title ?? 'File', style: TextStyle( fontWeight: FontWeight.bold, fontSize: 14.0, @@ -58,7 +99,7 @@ class FileAttachment extends StatelessWidget { height: 3.0, ), Text( - '${attachment.extraData['file_size'] ?? 'N/A'} bytes', + '${_getSizeText(widget.attachment.extraData['file_size'])}', style: TextStyle( color: Colors.black.withOpacity(0.5), fontSize: 14.0, @@ -69,13 +110,13 @@ class FileAttachment extends StatelessWidget { ), Column( children: [ - trailing ?? + widget.trailing ?? IconButton( icon: StreamSvgIcon.cloud_download( color: Colors.black, ), onPressed: () { - launchURL(context, attachment.assetUrl); + launchURL(context, widget.attachment.assetUrl); }, ), ], @@ -117,8 +158,76 @@ class FileAttachment extends StatelessWidget { ); } - StreamSvgIcon _getFileTypeImage(String type) { - switch (type) { + 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, + ); + break; + case FileAttachmentType.online: + return CachedNetworkImage( + imageUrl: widget.attachment.imageUrl ?? + widget.attachment.assetUrl ?? + widget.attachment.thumbUrl, + fit: BoxFit.cover, + progressIndicatorBuilder: (context, _, progress) { + return Center( + child: Container( + width: 20.0, + height: 20.0, + child: CircularProgressIndicator( + backgroundColor: StreamChatTheme.of(context).accentColor, + ), + ), + ); + }, + ); + break; + } + } + + if ((MediaUtils.getMimeType(widget.attachment.title).type == 'video')) { + switch (widget.attachmentType) { + case FileAttachmentType.local: + return FutureBuilder( + 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; + } + } + + switch (widget.attachment.extraData['mime_type']) { case '7z': return StreamSvgIcon.filetype_7z(); break; @@ -175,4 +284,18 @@ class FileAttachment extends StatelessWidget { break; } } + + String _getSizeText(int bytes) { + if (bytes == null) { + return 'Size N/A'; + } + + if (bytes <= 1000) { + return '${bytes} bytes'; + } else if (bytes <= 100000) { + return '${(bytes / 1000).toStringAsFixed(2)} KB'; + } else { + return '${(bytes / 1000000).toStringAsFixed(2)} MB'; + } + } } diff --git a/lib/src/media_list_view.dart b/lib/src/media_list_view.dart index 507238da..5f52e9b5 100644 --- a/lib/src/media_list_view.dart +++ b/lib/src/media_list_view.dart @@ -180,7 +180,7 @@ class MediaThumbnailProvider extends ImageProvider { MediaThumbnailProvider key, DecoderCallback decode) async { assert(key == this); final bytes = await media.thumbData; - if (bytes.isEmpty) return null; + if (bytes?.isNotEmpty != true) return null; return await decode(bytes); } diff --git a/lib/src/media_utils.dart b/lib/src/media_utils.dart new file mode 100644 index 00000000..e91a47d6 --- /dev/null +++ b/lib/src/media_utils.dart @@ -0,0 +1,17 @@ +import 'package:http_parser/http_parser.dart' as httpParser; +import 'package:mime/mime.dart'; + +class MediaUtils { + static httpParser.MediaType getMimeType(String filename) { + httpParser.MediaType mimeType; + if (filename != null) { + if (filename.toLowerCase().endsWith('heic')) { + mimeType = httpParser.MediaType.parse('image/heic'); + } else { + mimeType = httpParser.MediaType.parse(lookupMimeType(filename)); + } + } + + return mimeType; + } +} diff --git a/lib/src/message_input.dart b/lib/src/message_input.dart index 4e30afe1..542f63e7 100644 --- a/lib/src/message_input.dart +++ b/lib/src/message_input.dart @@ -326,14 +326,17 @@ class MessageInputState extends State { return AnimatedCrossFade( crossFadeState: _actionsShrunk ? CrossFadeState.showFirst : CrossFadeState.showSecond, - firstChild: IconButton( - onPressed: () { + firstChild: InkWell( + onTap: () { setState(() { _actionsShrunk = false; }); }, - icon: StreamSvgIcon.emptyCircleLeft( - color: StreamChatTheme.of(context).accentColor, + child: Padding( + padding: const EdgeInsets.all(8.0) + EdgeInsets.only(bottom: 3.0), + child: StreamSvgIcon.emptyCircleLeft( + color: StreamChatTheme.of(context).accentColor, + ), ), ), secondChild: Row( @@ -354,11 +357,12 @@ class MessageInputState extends State { child: Container( clipBehavior: Clip.antiAlias, decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20.0), + borderRadius: BorderRadius.circular(24.0), border: Border.all( color: Colors.grey, ), ), + padding: _attachments.isEmpty ? null : EdgeInsets.all(6.0), child: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -404,26 +408,45 @@ class MessageInputState extends State { child: Chip( backgroundColor: StreamChatTheme.of(context).accentColor, - label: Text( - _chosenCommand?.name ?? "", - style: TextStyle(color: Colors.white), - ), - avatar: StreamSvgIcon.lightning( - color: Colors.white, + padding: EdgeInsets.zero, + labelPadding: + EdgeInsets.symmetric(horizontal: 9.0), + label: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + StreamSvgIcon.lightning( + color: Colors.white, + size: 16.0, + ), + Text( + _chosenCommand?.name?.toUpperCase() ?? "", + style: TextStyle( + color: Colors.white, fontSize: 12.0), + ), + ], ), ), ) : null, suffixIcon: _commandEnabled - ? IconButton( - icon: Icon(Icons.cancel_outlined), - onPressed: () { + ? InkWell( + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 8.0), + child: StreamSvgIcon.close_small(), + ), + onTap: () { setState(() { _commandEnabled = false; }); }, ) : null, + suffixIconConstraints: BoxConstraints( + maxHeight: 24.0, + maxWidth: 40.0, + ), ), textCapitalization: TextCapitalization.sentences, ), @@ -664,14 +687,18 @@ class MessageInputState extends State { Color _getIconColor(int index) { switch (index) { case 0: - return _attachmentContainsFile && _attachments.isNotEmpty - ? Colors.black.withOpacity(0.2) - : Colors.black.withOpacity(0.5); + return _attachments.isEmpty + ? StreamChatTheme.of(context).accentColor + : (!_attachmentContainsFile + ? StreamChatTheme.of(context).accentColor + : Colors.black.withOpacity(0.2)); break; case 1: - return !_attachmentContainsFile && _attachments.isNotEmpty - ? Colors.black.withOpacity(0.2) - : Colors.black.withOpacity(0.5); + return _attachmentContainsFile + ? StreamChatTheme.of(context).accentColor + : (_attachments.isEmpty + ? Colors.black.withOpacity(0.5) + : Colors.black.withOpacity(0.2)); break; case 2: return _attachmentContainsFile && _attachments.isNotEmpty @@ -799,7 +826,7 @@ class MessageInputState extends State { Widget _buildPickerSection() { var _attachmentContainsFile = - _attachments.any((element) => element.attachment.type == 'file'); + _attachments.any((element) => element.attachment?.type == 'file'); switch (_filePickerIndex) { case 0: @@ -813,22 +840,38 @@ class MessageInputState extends State { } if (snapshot.data) { - return IgnorePointer( - ignoring: _attachmentContainsFile, - child: MediaListView( - selectedIds: _attachments.map((e) => e.id).toList(), - onSelect: (media) async { - if (!_attachments - .any((element) => element.id == media.id)) { - _addAttachment(media); - } else { - setState(() { - _attachments - .removeWhere((element) => element.id == media.id); - }); - } + if (_attachmentContainsFile) { + return GestureDetector( + onTap: () { + pickFile(DefaultAttachmentTypes.file); }, - ), + child: Container( + constraints: BoxConstraints.expand(), + color: Color(0xfff2f2f2), + child: Text( + 'Add more files', + style: TextStyle( + color: StreamChatTheme.of(context).accentColor, + fontWeight: FontWeight.bold, + ), + ), + alignment: Alignment.center, + ), + ); + } + return MediaListView( + selectedIds: _attachments.map((e) => e.id).toList(), + onSelect: (media) async { + if (!_attachments + .any((element) => element.id == media.id)) { + _addAttachment(media); + } else { + setState(() { + _attachments + .removeWhere((element) => element.id == media.id); + }); + } + }, ); } @@ -1253,6 +1296,8 @@ class MessageInputState extends State { clipBehavior: Clip.antiAlias, child: FileAttachment( attachment: e.attachment, + attachmentType: FileAttachmentType.local, + file: e.file, size: Size( MediaQuery.of(context).size.width * 0.55, MediaQuery.of(context).size.height * 0.3, @@ -1442,16 +1487,31 @@ class MessageInputState extends State { padding: const EdgeInsets.only(left: 4.0, right: 8.0, top: 8.0, bottom: 8.0), child: StreamSvgIcon.lightning( - color: Color(0xFF000000).withAlpha(128), + color: _commandsOverlay != null + ? StreamChatTheme.of(context).accentColor + : Color(0xFF000000).withAlpha(128), ), ), - onTap: () { + onTap: () async { + if (_openFilePickerSection) { + setState(() { + _animateContainer = false; + _openFilePickerSection = false; + _filePickerSize = _kMinMediaPickerSize; + }); + await Future.delayed(Duration(milliseconds: 300)); + } + if (_commandsOverlay == null) { - _commandsOverlay = _buildCommandsOverlayEntry(); - Overlay.of(context).insert(_commandsOverlay); + setState(() { + _commandsOverlay = _buildCommandsOverlayEntry(); + Overlay.of(context).insert(_commandsOverlay); + }); } else { - _commandsOverlay?.remove(); - _commandsOverlay = null; + setState(() { + _commandsOverlay?.remove(); + _commandsOverlay = null; + }); } }, ); @@ -1646,12 +1706,16 @@ class MessageInputState extends State { final mimeType = _getMimeType(file.path.split('/').last); - if (mimeType.type == 'video' || mimeType.type == 'image') { - attachmentType = mimeType.type; - } - Map extraDataMap = {}; + if (camera) { + if (mimeType.type == 'video' || mimeType.type == 'image') { + attachmentType = mimeType.type; + } + } else { + attachmentType = 'file'; + } + if (mimeType?.subtype != null) { extraDataMap['mime_type'] = mimeType.subtype.toLowerCase(); } @@ -1667,7 +1731,7 @@ class MessageInputState extends State { localUri: file.path != null ? Uri.parse(file.path) : null, type: attachmentType, extraData: extraDataMap.isNotEmpty ? extraDataMap : null, - title: file.name ?? 'File', + title: file.name, ), ); @@ -1784,7 +1848,7 @@ class MessageInputState extends State { Widget _buildIdleSendButton(BuildContext context) { return Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(8.0) + EdgeInsets.only(bottom: 3.0), child: Center( child: InkWell( onTap: () { @@ -1793,6 +1857,8 @@ class MessageInputState extends State { child: StreamSvgIcon( assetName: _getIdleSendIcon(), color: Colors.grey, + height: 24.0, + width: 24.0, ), )), ); @@ -1801,7 +1867,7 @@ class MessageInputState extends State { Widget _buildSendButton(BuildContext context) { return Center( child: Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(8.0) + EdgeInsets.only(bottom: 3.0), child: InkWell( onTap: () { sendMessage(); @@ -1809,6 +1875,8 @@ class MessageInputState extends State { child: StreamSvgIcon( assetName: _getSendIcon(), color: StreamChatTheme.of(context).accentColor, + height: 24.0, + width: 24.0, ), ), ), diff --git a/lib/src/url_attachment.dart b/lib/src/url_attachment.dart index d735ad67..ad314455 100644 --- a/lib/src/url_attachment.dart +++ b/lib/src/url_attachment.dart @@ -78,8 +78,9 @@ class UrlAttachment extends StatelessWidget { children: [ if (urlAttachment.title != null) Text( - urlAttachment.title, + urlAttachment.title.trim(), maxLines: 1, + overflow: TextOverflow.ellipsis, style: TextStyle( fontWeight: FontWeight.w700, fontSize: 12.0,