diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml index 6fb8664b..0492c6da 100644 --- a/example/android/app/src/main/AndroidManifest.xml +++ b/example/android/app/src/main/AndroidManifest.xml @@ -6,8 +6,8 @@ additional functionality it is fine to subclass or reimplement FlutterApplication and put your custom class here. --> - - + + { - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoidmlzaGFsIn0._JHWzo92fpTWZMZriJHXqOng6ShYVmWrdaIaPwEPKBg': + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoidmlzaGFsIn0.lCz-idDgaZ-xszjnuB_hTfeIOhTFmJtTB2fEjhwrcCI': User( id: 'vishal', extraData: { 'name': 'Vishal', }, ), - 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoic3VwZXItYmFuZC05In0.0L6lGoeLwkz0aZRUcpZKsvaXtNEDHBcezVTZ0oPq40A': + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoic3VwZXItYmFuZC05In0.1AvvK2TvEsfcxAlfOK2iUMFACucnk6N-Q8f5bbfnjCM': User( id: 'super-band-9', extraData: { 'name': 'John Doe', }, ), - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoic2FsdmF0b3JlIn0.GMEKyEhONmFnqtkf1TR1A3oUOSIWhjfQv5RpI906dAM': + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoic2FsdmF0b3JlIn0.uQ-pz8e1_C-Df2rIFpkkvWf7g4M0aW2Zkr7pHVhmU1Y': User( id: 'salvatore', extraData: { 'name': 'Salvatore', }, ), - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoidG9tbWFzbyJ9.GTalMeZHaBdpIM5w-KWrVIDSy-ODkHTRkf1GZbWAveM': + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoidG9tbWFzbyJ9.feLvMytd5_3-eETKrRdwtO3jcA0HxD8woFUbFU7gQCg': User( id: 'tommaso', extraData: { 'name': 'Tommaso', }, ), - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiamFhcCJ9.xpGE2lu4OoYS7-2yy6PbF0gJnxOaxeGO4EU6xo4EdMU': + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiamFhcCJ9.VLkLs6KH-jRRkPvhYMa4_lqN0R6WiK7JhJVmYvxcKz0': User( id: 'jaap', extraData: { diff --git a/lib/src/channel_list_view.dart b/lib/src/channel_list_view.dart index 8301f339..80b42183 100644 --- a/lib/src/channel_list_view.dart +++ b/lib/src/channel_list_view.dart @@ -485,6 +485,11 @@ class _ChannelListViewState extends State onTap: () { onTap(channel, widget.channelWidget); }, + onLongPress: widget.onChannelLongPress != null + ? () { + widget.onChannelLongPress(channel); + } + : null, ), ), ), diff --git a/lib/src/file_attachment.dart b/lib/src/file_attachment.dart index 05318862..fff13bbe 100644 --- a/lib/src/file_attachment.dart +++ b/lib/src/file_attachment.dart @@ -1,32 +1,178 @@ import 'package:flutter/material.dart'; import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_flutter/src/stream_svg_icon.dart'; import 'package:stream_chat_flutter/src/utils.dart'; class FileAttachment extends StatelessWidget { final Attachment attachment; final Size size; + final Widget trailing; const FileAttachment({ Key key, @required this.attachment, this.size, + this.trailing, }) : super(key: key); @override Widget build(BuildContext context) { return Material( - child: InkWell( - onTap: () { - launchURL(context, attachment.assetUrl); - }, - child: Container( - width: size?.width ?? 100, - height: size?.height ?? 100, - child: Center( - child: Icon(Icons.attach_file), - ), + child: Container( + width: size?.width ?? 100, + height: 56.0, + margin: trailing != null ? EdgeInsets.only(top: 4.0) : null, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: trailing != null ? BorderRadius.circular(16.0) : null, + border: trailing != null + ? Border.fromBorderSide(BorderSide(color: Color(0xFFE6E6E6))) + : null, ), + child: Row( + children: [ + Container( + child: _getFileTypeImage(attachment.extraData['mime_type']), + height: 40.0, + width: 33.33, + margin: EdgeInsets.all(8.0), + ), + SizedBox( + width: 6.0, + ), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + attachment?.title ?? 'File', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14.0, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + SizedBox( + height: 3.0, + ), + Text( + '${attachment.extraData['file_size'] ?? 'N/A'} bytes', + style: TextStyle( + color: Colors.black.withOpacity(0.5), + fontSize: 14.0, + ), + ), + ], + ), + ), + Column( + children: [ + trailing ?? + IconButton( + icon: StreamSvgIcon.cloud_download( + color: Colors.black, + ), + onPressed: () { + launchURL(context, attachment.assetUrl); + }, + ), + ], + ), + ], + ), + + // ListTile( + // dense: true, + // leading: Container( + // child: _getFileTypeImage(attachment.extraData['mime_type']), + // height: 40.0, + // width: 33.33, + // ), + // title: Text( + // attachment?.title ?? 'File', + // style: TextStyle( + // fontWeight: FontWeight.bold, + // ), + // maxLines: 3, + // ), + // subtitle: Text( + // '${attachment.extraData['file_size'] ?? 'N/A'} bytes', + // style: TextStyle( + // color: Colors.black.withOpacity(0.5), + // ), + // ), + // trailing: trailing ?? + // IconButton( + // icon: StreamSvgIcon.cloud_download( + // color: Colors.black, + // ), + // onPressed: () { + // launchURL(context, attachment.assetUrl); + // }, + // ), + // ), ), ); } + + StreamSvgIcon _getFileTypeImage(String type) { + switch (type) { + case '7z': + return StreamSvgIcon.filetype_7z(); + break; + case 'csv': + return StreamSvgIcon.filetype_csv(); + break; + case 'doc': + return StreamSvgIcon.filetype_doc(); + break; + case 'docx': + return StreamSvgIcon.filetype_docx(); + break; + case 'html': + return StreamSvgIcon.filetype_html(); + break; + case 'md': + return StreamSvgIcon.filetype_md(); + break; + case 'odt': + return StreamSvgIcon.filetype_odt(); + break; + case 'pdf': + return StreamSvgIcon.filetype_pdf(); + break; + case 'ppt': + return StreamSvgIcon.filetype_ppt(); + break; + case 'pptx': + return StreamSvgIcon.filetype_pptx(); + break; + case 'rar': + return StreamSvgIcon.filetype_rar(); + break; + case 'rtf': + return StreamSvgIcon.filetype_rtf(); + break; + case 'tar': + return StreamSvgIcon.filetype_tar(); + break; + case 'txt': + return StreamSvgIcon.filetype_txt(); + break; + case 'xls': + return StreamSvgIcon.filetype_xls(); + break; + case 'xlsx': + return StreamSvgIcon.filetype_xlsx(); + break; + case 'zip': + return StreamSvgIcon.filetype_zip(); + break; + default: + return StreamSvgIcon.filetype_Generic(); + break; + } + } } diff --git a/lib/src/full_screen_image.dart b/lib/src/full_screen_image.dart deleted file mode 100644 index 76953b1a..00000000 --- a/lib/src/full_screen_image.dart +++ /dev/null @@ -1,35 +0,0 @@ -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:flutter/material.dart'; -import 'package:photo_view/photo_view.dart'; - -/// A full screen image widget -class FullScreenImage extends StatelessWidget { - /// The url of the image - final String url; - - /// Instantiate a new FullScreenImage - const FullScreenImage({ - Key key, - @required this.url, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - backgroundColor: Colors.black, - iconTheme: IconThemeData( - color: Colors.white, - ), - ), - body: PhotoView( - imageProvider: CachedNetworkImageProvider(url), - maxScale: PhotoViewComputedScale.covered, - minScale: PhotoViewComputedScale.contained, - heroAttributes: PhotoViewHeroAttributes( - tag: url, - ), - ), - ); - } -} diff --git a/lib/src/full_screen_media.dart b/lib/src/full_screen_media.dart new file mode 100644 index 00000000..7e3f3d7d --- /dev/null +++ b/lib/src/full_screen_media.dart @@ -0,0 +1,259 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:chewie/chewie.dart'; +import 'package:flutter/material.dart'; +import 'package:jiffy/jiffy.dart'; +import 'package:photo_view/photo_view.dart'; +import 'package:stream_chat_flutter/src/image_footer.dart'; +import 'package:stream_chat_flutter/src/image_header.dart'; +import 'package:video_player/video_player.dart'; + +import '../stream_chat_flutter.dart'; + +/// A full screen image widget +class FullScreenMedia extends StatefulWidget { + /// The url of the image + final List mediaAttachments; + final Message message; + + final int startIndex; + final String userName; + final DateTime sentAt; + + /// Instantiate a new FullScreenImage + const FullScreenMedia({ + Key key, + @required this.mediaAttachments, + this.message, + this.startIndex = 0, + this.userName = '', + this.sentAt, + }) : super(key: key); + + @override + _FullScreenMediaState createState() => _FullScreenMediaState(); +} + +class _FullScreenMediaState extends State + with SingleTickerProviderStateMixin { + bool _optionsShown = true; + + AnimationController _controller; + PageController _pageController; + + int _currentPage; + + List videoPackages = []; + + @override + void initState() { + super.initState(); + _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(() {}); + })); + }); + } + + @override + Widget build(BuildContext context) { + var videoAttachments = widget.mediaAttachments + .where((element) => element.type == 'video') + .toList(); + + return Scaffold( + resizeToAvoidBottomInset: false, + body: Stack( + children: [ + AnimatedBuilder( + animation: _controller, + builder: (context, snapshot) { + return PageView.builder( + controller: _pageController, + onPageChanged: (val) { + setState(() { + _currentPage = val; + }); + }, + itemBuilder: (context, position) { + if (widget.mediaAttachments[position].type == 'image' || + widget.mediaAttachments[position].type == 'giphy') { + return PhotoView( + imageProvider: CachedNetworkImageProvider( + widget.mediaAttachments[position].imageUrl ?? + widget.mediaAttachments[position].assetUrl ?? + widget.mediaAttachments[position].thumbUrl), + maxScale: PhotoViewComputedScale.covered, + minScale: PhotoViewComputedScale.contained, + heroAttributes: PhotoViewHeroAttributes( + tag: widget.mediaAttachments, + ), + backgroundDecoration: BoxDecoration( + color: ColorTween( + begin: StreamChatTheme.of(context) + .channelTheme + .channelHeaderTheme + .color, + end: Colors.black) + .lerp(_controller.value), + ), + onTapUp: (a, b, c) { + setState(() { + _optionsShown = !_optionsShown; + }); + if (_controller.isCompleted) { + _controller.reverse(); + } else { + _controller.forward(); + } + }, + ); + } else if (widget.mediaAttachments[position].type == + 'video') { + var controllerPackage = videoPackages[videoAttachments + .indexOf(widget.mediaAttachments[position])]; + + if (!controllerPackage.initialised) { + return Center( + child: CircularProgressIndicator(), + ); + } + return InkWell( + onTap: () { + setState(() { + _optionsShown = !_optionsShown; + }); + if (_controller.isCompleted) { + _controller.reverse(); + } else { + _controller.forward(); + } + }, + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 50.0, + ), + child: Chewie( + controller: controllerPackage.chewieController, + ), + ), + ); + } + return Container(); + }, + itemCount: widget.mediaAttachments.length, + ); + }), + AnimatedOpacity( + opacity: _optionsShown ? 1.0 : 0.0, + duration: Duration(milliseconds: 300), + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + ImageHeader( + userName: widget.userName, + sentAt: widget.message.createdAt == null + ? '' + : 'Sent ${getDay(widget.message.createdAt)} at ${Jiffy(widget.sentAt.toLocal()).format('HH:mm')}', + onBackPressed: () { + Navigator.of(context).pop(); + }, + message: widget.message, + urls: widget.mediaAttachments, + currentIndex: _currentPage, + ), + ImageFooter( + currentPage: _currentPage, + totalPages: widget.mediaAttachments.length, + mediaAttachments: widget.mediaAttachments, + message: widget.message, + videoPackages: videoPackages, + mediaSelectedCallBack: (val) { + setState(() { + _currentPage = val; + _pageController.animateToPage(val, + duration: Duration(milliseconds: 300), + curve: Curves.easeInOut); + Navigator.pop(context); + }); + }, + ), + ], + ), + ), + ], + ), + ); + } + + String getDay(DateTime dateTime) { + var now = DateTime.now(); + + if (DateTime(dateTime.year, dateTime.month, dateTime.day) == + DateTime(now.year, now.month, now.day)) { + return 'today'; + } else if (DateTime(now.year, now.month, now.day) + .difference(dateTime) + .inHours < + 24) { + return 'yesterday'; + } else { + return 'on ${Jiffy(dateTime).format("MMM do")}'; + } + } + + @override + void dispose() { + videoPackages.forEach((element) { + element.dispose(); + }); + super.dispose(); + } +} + +class VideoPackage { + VideoPlayerController _videoPlayerController; + ChewieController _chewieController; + bool initialised = false; + VoidCallback onInit; + BuildContext context; + + /// + VideoPackage(this.context, Attachment attachment, this.onInit) { + _videoPlayerController = VideoPlayerController.network(attachment.assetUrl); + _videoPlayerController.initialize().whenComplete(() { + initialised = true; + _chewieController = ChewieController( + videoPlayerController: _videoPlayerController, + autoInitialize: false, + 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); + } + + get videoPlayer => _videoPlayerController; + + get chewieController => _chewieController; + + void dispose() { + _videoPlayerController.dispose(); + _chewieController.dispose(); + } +} diff --git a/lib/src/full_screen_video.dart b/lib/src/full_screen_video.dart deleted file mode 100644 index 98729243..00000000 --- a/lib/src/full_screen_video.dart +++ /dev/null @@ -1,84 +0,0 @@ -import 'package:chewie/chewie.dart'; -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; -import 'package:video_player/video_player.dart'; - -import 'utils.dart'; - -class FullScreenVideo extends StatefulWidget { - final Attachment attachment; - - FullScreenVideo({ - Key key, - @required this.attachment, - }) : super(key: key); - - @override - _FullScreenVideoState createState() => _FullScreenVideoState(); -} - -class _FullScreenVideoState extends State { - ChewieController _chewieController; - VideoPlayerController _videoPlayerController; - bool initialized = false; - final GlobalKey _scaffoldKey = GlobalKey(); - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - backgroundColor: Colors.black, - iconTheme: IconThemeData( - color: Colors.white, - ), - ), - body: Builder( - key: _scaffoldKey, - builder: (context) { - if (!initialized) { - return Center( - child: CircularProgressIndicator(), - ); - } - return Chewie( - controller: _chewieController, - ); - }, - ), - ); - } - - @override - void initState() { - super.initState(); - _videoPlayerController = - VideoPlayerController.network(widget.attachment.assetUrl); - _videoPlayerController.initialize().whenComplete(() { - setState(() { - initialized = true; - _chewieController = ChewieController( - videoPlayerController: _videoPlayerController, - autoInitialize: false, - aspectRatio: _videoPlayerController.value.aspectRatio, - ); - }); - }); - - VoidCallback errorListener; - errorListener = () { - if (_videoPlayerController.value.hasError) { - Navigator.pop(context); - launchURL(_scaffoldKey.currentContext, widget.attachment.titleLink); - } - _videoPlayerController.removeListener(errorListener); - }; - _videoPlayerController.addListener(errorListener); - } - - @override - void dispose() { - _videoPlayerController?.dispose(); - _chewieController?.dispose(); - super.dispose(); - } -} diff --git a/lib/src/giphy_attachment.dart b/lib/src/giphy_attachment.dart index 7abf7057..3e29b2f1 100644 --- a/lib/src/giphy_attachment.dart +++ b/lib/src/giphy_attachment.dart @@ -5,7 +5,7 @@ import 'package:stream_chat_flutter/src/stream_svg_icon.dart'; import '../stream_chat_flutter.dart'; import 'attachment_error.dart'; -import 'full_screen_image.dart'; +import 'full_screen_media.dart'; class GiphyAttachment extends StatelessWidget { final Attachment attachment; @@ -64,64 +64,75 @@ class GiphyAttachment extends StatelessWidget { child: GestureDetector( onTap: () { Navigator.push(context, MaterialPageRoute(builder: (_) { - return FullScreenImage( - url: attachment.imageUrl ?? - attachment.assetUrl ?? - attachment.thumbUrl, + final channel = StreamChannel.of(context).channel; + + return StreamChannel( + channel: channel, + child: FullScreenMedia( + mediaAttachments: [ + attachment, + ], + userName: message.user.name, + sentAt: message.createdAt, + message: message, + ), ); })); }, - 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, + child: ClipRRect( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(8), + topRight: Radius.circular(8), + ), + 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, ), - fit: BoxFit.cover, ), ), ), Positioned( - left: 0, - top: 0, - child: Container( - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.only( - bottomRight: Radius.circular(16.0), - )), + bottom: 16, + left: 16, + child: Material( + color: Colors.black.withOpacity(.5), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), child: Padding( - padding: const EdgeInsets.only( - left: 8.0, - right: 8.0, - top: 8.0, - bottom: 4.0, + padding: const EdgeInsets.symmetric( + horizontal: 8.0, + vertical: 4.0, ), child: Row( children: [ StreamSvgIcon.lightning( - color: StreamChatTheme.of(context).accentColor, - size: 16.0, + color: Colors.white, + size: 16, ), Text( 'GIPHY', style: TextStyle( - color: StreamChatTheme.of(context).accentColor, + color: Colors.white, fontWeight: FontWeight.bold, - fontSize: 11.0, + fontSize: 11, ), ), ], @@ -278,10 +289,18 @@ class GiphyAttachment extends StatelessWidget { child: GestureDetector( onTap: () { Navigator.push(context, MaterialPageRoute(builder: (_) { - return FullScreenImage( - url: attachment.imageUrl ?? - attachment.assetUrl ?? - attachment.thumbUrl, + var channel = StreamChannel.of(context).channel; + + return StreamChannel( + channel: channel, + child: FullScreenMedia( + mediaAttachments: [ + attachment, + ], + userName: message.user.name, + sentAt: message.createdAt, + message: message, + ), ); })); }, diff --git a/lib/src/image_actions_modal.dart b/lib/src/image_actions_modal.dart new file mode 100644 index 00000000..fc81635c --- /dev/null +++ b/lib/src/image_actions_modal.dart @@ -0,0 +1,222 @@ +import 'dart:typed_data'; +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:dio/dio.dart'; +import 'package:image_gallery_saver/image_gallery_saver.dart'; +import '../stream_chat_flutter.dart'; +import 'package:path_provider/path_provider.dart'; + +class ImageActionsModal extends StatelessWidget { + final Message message; + final String userName; + final String sentAt; + final List urls; + final currentIndex; + + ImageActionsModal( + {this.message, this.userName, this.sentAt, this.urls, this.currentIndex}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () { + Navigator.pop(context); + }, + child: Stack( + children: [ + Positioned.fill( + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.black.withOpacity(0.8), + Colors.transparent, + ], + stops: [0.0, 0.4], + )), + )), + _buildPage(context), + ], + ), + ); + } + + Widget _buildPage(context) { + return Material( + color: Colors.transparent, + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: Container( + width: 40.0, + ), + ), + Column( + children: [ + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Text( + userName, + style: TextStyle( + fontSize: 16.0, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + ), + Text( + sentAt, + style: StreamChatTheme.of(context) + .channelPreviewTheme + .subtitle + .copyWith(color: Colors.white.withOpacity(0.5)), + ), + ], + ), + IconButton( + icon: StreamSvgIcon.close( + size: 24.0, + color: Colors.white, + ), + onPressed: () { + Navigator.pop(context); + }, + ), + ], + ), + Align( + alignment: Alignment.centerRight, + child: Container( + width: MediaQuery.of(context).size.width / 1.8, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Material( + clipBehavior: Clip.hardEdge, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + color: Color(0xffe5e5e5), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: ListTile.divideTiles( + context: context, + tiles: [ + _buildButton( + context, + 'Reply', + StreamSvgIcon.Icon_curve_line_left_up( + size: 24.0, + color: Colors.black.withOpacity(0.5), + ), + () {}), + _buildButton( + context, + 'Show in Chat', + StreamSvgIcon.eye( + size: 24.0, + color: Colors.black, + ), () { + Navigator.pop(context); + Navigator.pop(context); + }), + _buildButton( + context, + 'Save ${urls[currentIndex].type == 'video' ? 'Video' : 'Image'}', + StreamSvgIcon.Icon_save( + size: 24.0, + color: Colors.black.withOpacity(0.5), + ), () async { + var url = urls[currentIndex].imageUrl ?? + urls[currentIndex].assetUrl ?? + urls[currentIndex].thumbUrl; + + if (urls[currentIndex].type == 'video') { + await _saveVideo(url); + Navigator.pop(context); + } else { + await _saveImage(url); + Navigator.pop(context); + } + }), + if (StreamChat.of(context).user.id == message.user.id) + _buildButton( + context, + 'Delete', + StreamSvgIcon.delete( + size: 24.0, + color: Color(0xffFF3742), + ), + () { + Navigator.pop(context); + Navigator.pop(context); + StreamChat.of(context).client.deleteMessage( + message, + StreamChannel.of(context).channel.cid, + ); + }, + color: Color(0xffFF3742), + ), + ], + ).toList(), + ), + ), + ), + ), + ), + ], + ), + ); + } + + Widget _buildButton( + context, String title, StreamSvgIcon icon, VoidCallback onTap, + {Color color}) { + var titleStyle = TextStyle( + fontSize: 14.5, + color: Colors.black, + ); + + return Material( + child: InkWell( + onTap: onTap, + child: ListTile( + dense: true, + title: Text( + title, + style: + color == null ? titleStyle : titleStyle.copyWith(color: color), + ), + leading: icon, + ), + ), + ); + } + + Future _saveImage(String url) async { + var response = await Dio() + .get(url, options: Options(responseType: ResponseType.bytes)); + final result = await ImageGallerySaver.saveImage( + Uint8List.fromList(response.data), + quality: 60, + name: "${DateTime.now().millisecondsSinceEpoch}"); + return result; + } + + Future _saveVideo(String url) async { + var appDocDir = await getTemporaryDirectory(); + var savePath = + appDocDir.path + "/${DateTime.now().millisecondsSinceEpoch}.mp4"; + await Dio().download(url, savePath); + final result = await ImageGallerySaver.saveFile(savePath); + print(result); + } +} diff --git a/lib/src/image_attachment.dart b/lib/src/image_attachment.dart index ba11252a..2345b2b8 100644 --- a/lib/src/image_attachment.dart +++ b/lib/src/image_attachment.dart @@ -4,7 +4,7 @@ import 'package:flutter/material.dart'; import '../stream_chat_flutter.dart'; import 'attachment_error.dart'; import 'attachment_title.dart'; -import 'full_screen_image.dart'; +import 'full_screen_media.dart'; import 'utils.dart'; class ImageAttachment extends StatelessWidget { @@ -43,10 +43,18 @@ class ImageAttachment extends StatelessWidget { context, MaterialPageRoute( builder: (_) { - return FullScreenImage( - url: attachment.imageUrl ?? - attachment.assetUrl ?? - attachment.thumbUrl, + final channel = StreamChannel.of(context).channel; + + return StreamChannel( + channel: channel, + child: FullScreenMedia( + mediaAttachments: [ + attachment, + ], + userName: message.user.name, + sentAt: message.createdAt, + message: message, + ), ); }, ), diff --git a/lib/src/image_footer.dart b/lib/src/image_footer.dart new file mode 100644 index 00000000..3c37a66a --- /dev/null +++ b/lib/src/image_footer.dart @@ -0,0 +1,683 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:chewie/chewie.dart'; +import 'package:esys_flutter_share/esys_flutter_share.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:dio/dio.dart'; +import 'package:image_gallery_saver/image_gallery_saver.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_flutter/src/stream_chat.dart'; +import 'package:stream_chat_flutter/src/stream_chat_theme.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import 'stream_channel.dart'; + +class ImageFooter extends StatefulWidget { + /// Callback to call when pressing the back button. + /// By default it calls [Navigator.pop] + final VoidCallback onBackPressed; + + /// Callback to call when the header is tapped. + final VoidCallback onTitleTap; + + /// Callback to call when the image is tapped. + final VoidCallback onImageTap; + + final int currentPage; + final int totalPages; + + final List mediaAttachments; + final Message message; + + final List videoPackages; + final ValueChanged mediaSelectedCallBack; + + /// Creates a channel header + ImageFooter({ + Key key, + this.onBackPressed, + this.onTitleTap, + this.onImageTap, + this.currentPage = 0, + this.totalPages = 0, + this.mediaAttachments, + this.message, + this.videoPackages, + this.mediaSelectedCallBack, + }) : preferredSize = Size.fromHeight(kToolbarHeight), + super(key: key); + + @override + _ImageFooterState createState() => _ImageFooterState(); + + @override + final Size preferredSize; +} + +class _ImageFooterState extends State { + bool _userSearchMode = false; + TextEditingController _searchController; + TextEditingController _messageController = TextEditingController(); + + String _userNameQuery; + bool _isSearchActive = false; + + Set _selectedUsers = {}; + bool _loading = false; + + Timer _debounce; + + Function modalSetStateCallback; + + void _userNameListener() { + if (_debounce?.isActive ?? false) _debounce.cancel(); + _debounce = Timer(const Duration(milliseconds: 350), () { + if (mounted && modalSetStateCallback != null) { + modalSetStateCallback(() { + _userNameQuery = _searchController.text; + _isSearchActive = _userNameQuery.isNotEmpty; + }); + } + }); + } + + @override + void initState() { + super.initState(); + _searchController = TextEditingController()..addListener(_userNameListener); + } + + @override + void dispose() { + _searchController?.clear(); + _searchController?.removeListener(_userNameListener); + _searchController?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Container( + color: + StreamChatTheme.of(context).channelTheme.channelHeaderTheme.color, + padding: EdgeInsets.symmetric(vertical: 8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + IconButton( + icon: StreamSvgIcon.icon_SHARE( + size: 24.0, + color: Colors.black, + ), + onPressed: () { + _buildShareModal(context); + }, + ), + InkWell( + onTap: widget.onTitleTap, + child: Container( + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '${widget.currentPage + 1} of ${widget.totalPages}', + style: TextStyle( + fontSize: 16.0, + fontWeight: FontWeight.w700, + color: Colors.black, + ), + ), + ], + ), + ), + ), + IconButton( + icon: StreamSvgIcon.Icon_grid( + color: Colors.black, + ), + onPressed: () { + _buildPhotosModal(context); + }, + ), + ], + ), + ), + ); + } + + Widget _buildPhotosModal(context) { + var videoAttachments = widget.mediaAttachments + .where((element) => element.type == 'video') + .toList(); + + showModalBottomSheet( + context: context, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16.0), + ), + builder: (context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.only( + topRight: Radius.circular(16.0), + topLeft: Radius.circular(16.0), + )), + child: Stack( + children: [ + Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Text( + 'Photos', + style: TextStyle( + fontSize: 16.0, + fontWeight: FontWeight.w700, + color: Colors.black, + ), + ), + ), + ), + Align( + alignment: Alignment.centerRight, + child: IconButton( + icon: StreamSvgIcon.close( + color: Colors.black, + ), + onPressed: () { + Navigator.pop(context); + }, + ), + ), + ], + ), + ), + Container( + color: Colors.white, + child: GridView.builder( + shrinkWrap: true, + physics: NeverScrollableScrollPhysics(), + itemBuilder: (context, position) { + if (widget.mediaAttachments[position].type == 'video') { + var controllerPackage = widget.videoPackages[ + videoAttachments + .indexOf(widget.mediaAttachments[position])]; + + return InkWell( + onTap: () { + widget.mediaSelectedCallBack(position); + }, + child: FittedBox( + fit: BoxFit.cover, + child: Chewie( + controller: controllerPackage.chewieController, + ), + ), + ); + } else { + return InkWell( + onTap: () { + widget.mediaSelectedCallBack(position); + }, + child: Padding( + padding: const EdgeInsets.all(1.0), + child: AspectRatio( + child: CachedNetworkImage( + imageUrl: widget + .mediaAttachments[position].imageUrl ?? + widget.mediaAttachments[position].assetUrl ?? + widget.mediaAttachments[position].thumbUrl, + fit: BoxFit.cover, + ), + aspectRatio: 1.0, + ), + ), + ); + } + }, + itemCount: widget.mediaAttachments.length, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3), + ), + ), + ], + ); + }, + ); + } + + Widget _buildShareModal(context) { + showDialog( + context: context, + builder: (context) { + return StatefulBuilder(builder: (context, modalSetState) { + modalSetStateCallback = modalSetState; + return Padding( + padding: EdgeInsets.only( + top: _userSearchMode + ? 16.0 + : MediaQuery.of(context).size.height / 2, + left: 8.0, + right: 8.0), + child: Material( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(16.0), + topRight: Radius.circular(16.0), + ), + clipBehavior: Clip.antiAlias, + child: Scaffold( + body: UsersBloc( + child: Column( + children: [ + _buildTextInputSection(modalSetState), + Expanded( + child: UserListView( + selectedUsers: _selectedUsers, + onUserTap: (user, _) { + _searchController.clear(); + if (!_selectedUsers.contains(user)) { + modalSetState(() { + _selectedUsers.add(user); + }); + } else { + modalSetState(() { + _selectedUsers.remove(user); + }); + } + }, + crossAxisCount: 4, + pagination: PaginationParams( + limit: 25, + ), + filter: { + if (_searchController.text.isNotEmpty) + 'name': { + r'$autocomplete': _userNameQuery, + }, + 'id': { + r'$ne': StreamChat.of(context).user.id, + }, + }, + sort: [ + SortOption( + 'name', + direction: 1, + ), + ], + emptyBuilder: (_) { + return LayoutBuilder( + builder: (context, viewportConstraints) { + return SingleChildScrollView( + physics: AlwaysScrollableScrollPhysics(), + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: viewportConstraints.maxHeight, + ), + child: Center( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(24), + child: StreamSvgIcon.search( + size: 96, + color: Colors.grey, + ), + ), + Text( + 'No user matches these keywords...'), + ], + ), + ), + ), + ); + }, + ); + }, + ), + ), + if (_selectedUsers.isNotEmpty) + _buildShareTextInputSection(modalSetState), + if (!_userSearchMode && _selectedUsers.isEmpty) + Align( + alignment: Alignment.bottomCenter, + child: Container( + color: Colors.white, + height: 48.0, + child: Material( + child: InkWell( + onTap: () async { + var url = widget + .mediaAttachments[widget.currentPage] + .imageUrl ?? + widget + .mediaAttachments[widget.currentPage] + .assetUrl ?? + widget + .mediaAttachments[widget.currentPage] + .thumbUrl; + + if (widget + .mediaAttachments[widget.currentPage] + .type == + 'video') { + await _saveVideo(url); + Navigator.pop(context); + } else { + await _saveImage(url); + Navigator.pop(context); + } + }, + child: SizedBox.expand( + child: Center( + child: Text( + 'Save to Photos', + style: TextStyle( + color: StreamChatTheme.of(context) + .accentColor, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ); + }); + }, + ); + } + + Widget _buildTextInputSection(modalSetState) { + if (_userSearchMode) { + return Column( + children: [ + SizedBox( + height: 16.0, + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + SizedBox( + width: 16.0, + ), + Expanded( + child: TextField( + controller: _searchController, + cursorColor: Colors.black, + autofocus: true, + decoration: InputDecoration( + isDense: true, + prefixIconConstraints: + BoxConstraints.tight(Size(36.0, 44.0)), + prefixIcon: Padding( + padding: const EdgeInsets.symmetric( + vertical: 2.0, horizontal: 6.0), + child: StreamSvgIcon.search( + color: Colors.black, + ), + ), + hintText: 'Search', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(32.0), + borderSide: + BorderSide(color: Colors.black.withOpacity(0.08)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(32.0), + borderSide: + BorderSide(color: Colors.black.withOpacity(0.08)), + ), + contentPadding: EdgeInsets.zero, + ), + ), + ), + SizedBox( + width: 8.0, + ), + IconButton( + icon: StreamSvgIcon.close_small( + color: Colors.black.withOpacity(0.5), + ), + onPressed: () { + modalSetState(() { + _userSearchMode = false; + }); + setState(() {}); + }, + ) + ], + ), + ], + ); + } else { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 6.0), + child: IconButton( + icon: StreamSvgIcon.search( + color: Colors.black, + ), + iconSize: 24.0, + onPressed: () { + modalSetState(() { + _userSearchMode = true; + }); + }, + ), + ), + Padding( + padding: const EdgeInsets.all(16.0), + child: Text( + 'Select a Chat to Share', + style: TextStyle( + fontSize: 16.0, + fontWeight: FontWeight.w700, + color: Colors.black, + ), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 6.0), + child: IconButton( + icon: StreamSvgIcon.share_arrow( + color: Colors.black, + ), + onPressed: () async { + var url = + widget.mediaAttachments[widget.currentPage].imageUrl ?? + widget.mediaAttachments[widget.currentPage].assetUrl ?? + widget.mediaAttachments[widget.currentPage].thumbUrl; + var type = + widget.mediaAttachments[widget.currentPage].type == 'image' + ? 'jpg' + : url?.split('?')?.first?.split('.')?.last ?? 'jpg'; + var request = await HttpClient().getUrl(Uri.parse(url)); + var response = await request.close(); + var bytes = await consolidateHttpClientResponseBytes(response); + await Share.file('File', 'image.$type', bytes, 'image/$type'); + }, + ), + ), + ], + ); + } + } + + Widget _buildShareTextInputSection(modalSetState) { + return Align( + alignment: Alignment.bottomCenter, + child: Container( + color: Colors.white, + height: 56.0, + child: _loading + ? Center( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: CircularProgressIndicator(), + ), + ) + : Row( + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.only(left: 8.0), + child: TextField( + controller: _messageController, + onChanged: (val) { + modalSetState(() {}); + }, + onTap: () { + modalSetState(() {}); + setState(() {}); + }, + decoration: InputDecoration( + isDense: true, + prefixText: ' ', + hintText: 'Add a comment', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(32.0), + borderSide: BorderSide( + color: Colors.black.withOpacity(0.16), + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(32.0), + borderSide: BorderSide( + color: Colors.black.withOpacity(0.16), + )), + contentPadding: EdgeInsets.symmetric(vertical: 12.0), + ), + ), + ), + ), + IconTheme( + data: StreamChatTheme.of(context) + .channelTheme + .messageInputButtonIconTheme, + child: Center( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: InkWell( + onTap: () async { + modalSetState(() { + _loading = true; + }); + await sendMessage(); + modalSetState(() { + _loading = false; + }); + }, + child: Transform.rotate( + angle: -pi / 2, + child: StreamSvgIcon.Icon_send_message( + color: StreamChatTheme.of(context).accentColor, + ), + ), + ), + ), + ), + ), + ], + ), + ), + ); + } + + /// Sends the current message + Future sendMessage() async { + var text = _messageController.text.trim(); + + final attachments = widget.message.attachments; + + _messageController.clear(); + + final client = StreamChat.of(context).client; + + for (var user in _selectedUsers) { + var c = client.channel('messaging', extraData: { + 'members': [ + user.id, + StreamChat.of(context).user.id, + ], + }); + + await c.watch(); + + final message = Message( + text: text, + attachments: [attachments[widget.currentPage]], + ); + + await c.sendMessage(message); + } + + _selectedUsers.clear(); + Navigator.pop(context); + } + + Future _saveImage(String url) async { + var response = await Dio() + .get(url, options: Options(responseType: ResponseType.bytes)); + final result = await ImageGallerySaver.saveImage( + Uint8List.fromList(response.data), + quality: 60, + name: "${DateTime.now().millisecondsSinceEpoch}"); + return result; + } + + Future _saveVideo(String url) async { + var appDocDir = await getTemporaryDirectory(); + var savePath = + appDocDir.path + "/${DateTime.now().millisecondsSinceEpoch}.mp4"; + await Dio().download(url, savePath); + final result = await ImageGallerySaver.saveFile(savePath); + print(result); + } +} + +/// Used for clipping textfield prefix icon +class IconClipper extends CustomClipper { + @override + Path getClip(Size size) { + var leftX = size.width / 5; + var rightX = 4 * size.width / 5; + var topY = size.height / 5; + var bottomY = 4 * size.height / 5; + + final path = Path(); + path.moveTo(leftX, topY); + path.lineTo(leftX, bottomY); + path.lineTo(rightX, bottomY); + path.lineTo(rightX, topY); + path.lineTo(leftX, topY); + path.lineTo(0.0, 0.0); + path.close(); + return path; + } + + @override + bool shouldReclip(CustomClipper oldClipper) { + return false; + } +} diff --git a/lib/src/image_group.dart b/lib/src/image_group.dart index 1a7144d8..44c15623 100644 --- a/lib/src/image_group.dart +++ b/lib/src/image_group.dart @@ -3,7 +3,8 @@ import 'package:carousel_slider/carousel_options.dart'; import 'package:carousel_slider/carousel_slider.dart'; import 'package:flutter/material.dart'; import 'package:stream_chat/stream_chat.dart'; -import 'package:stream_chat_flutter/src/full_screen_image.dart'; +import 'package:stream_chat_flutter/src/full_screen_media.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; class ImageGroup extends StatelessWidget { const ImageGroup({ @@ -107,37 +108,21 @@ class ImageGroup extends StatelessWidget { BuildContext context, [ int index, ]) { + final channel = StreamChannel.of(context).channel; + Navigator.push( context, MaterialPageRoute( - builder: (context) { - return Scaffold( - appBar: AppBar( - backgroundColor: Colors.transparent, - iconTheme: IconThemeData( - color: Colors.white, - ), - ), - backgroundColor: Colors.black, - body: SizedBox.expand( - child: CarouselSlider( - items: images - .map((image) => FullScreenImage( - url: image.imageUrl ?? - image.thumbUrl ?? - image.assetUrl, - )) - .toList(), - options: CarouselOptions( - initialPage: index ?? 0, - enableInfiniteScroll: false, - viewportFraction: 0.95, - height: MediaQuery.of(context).size.height, - ), - ), - ), - ); - }, + builder: (context) => StreamChannel( + channel: channel, + child: FullScreenMedia( + mediaAttachments: images, + startIndex: index, + userName: message.user.name, + sentAt: message.createdAt, + message: message, + ), + ), ), ); } diff --git a/lib/src/image_header.dart b/lib/src/image_header.dart new file mode 100644 index 00000000..62c3f8ec --- /dev/null +++ b/lib/src/image_header.dart @@ -0,0 +1,122 @@ +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 'image_actions_modal.dart'; +import 'stream_channel.dart'; + +class ImageHeader extends StatelessWidget implements PreferredSizeWidget { + /// True if this header shows the leading back button + final bool showBackButton; + + /// Callback to call when pressing the back button. + /// By default it calls [Navigator.pop] + final VoidCallback onBackPressed; + + /// Callback to call when the header is tapped. + final VoidCallback onTitleTap; + + /// Callback to call when the image is tapped. + final VoidCallback onImageTap; + + final Message message; + + final String userName; + final String sentAt; + + final List urls; + final currentIndex; + + /// Creates a channel header + ImageHeader({ + Key key, + this.message, + this.urls, + this.currentIndex, + this.showBackButton = true, + this.onBackPressed, + this.onTitleTap, + this.onImageTap, + this.userName = '', + this.sentAt = '', + }) : preferredSize = Size.fromHeight(kToolbarHeight), + super(key: key); + + @override + Widget build(BuildContext context) { + return AppBar( + brightness: Theme.of(context).brightness, + elevation: 1, + leading: showBackButton + ? IconButton( + icon: StreamSvgIcon.close( + color: Colors.black, + size: 24.0, + ), + onPressed: onBackPressed, + ) + : SizedBox(), + backgroundColor: + StreamChatTheme.of(context).channelTheme.channelHeaderTheme.color, + actions: [ + IconButton( + icon: StreamSvgIcon.Icon_menu_point_v( + color: Colors.black, + ), + onPressed: () { + _showMessageActionModalBottomSheet(context); + }, + ), + ], + centerTitle: true, + title: InkWell( + onTap: onTitleTap, + child: Container( + height: preferredSize.height, + width: preferredSize.width, + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + userName, + style: TextStyle( + fontSize: 16.0, + fontWeight: FontWeight.w700, + color: Colors.black, + ), + ), + Text( + sentAt, + style: StreamChatTheme.of(context).channelPreviewTheme.subtitle, + ), + ], + ), + ), + ), + ); + } + + @override + final Size preferredSize; + + void _showMessageActionModalBottomSheet(BuildContext context) { + final channel = StreamChannel.of(context).channel; + + showDialog( + context: context, + builder: (context) { + return StreamChannel( + channel: channel, + child: ImageActionsModal( + userName: userName, + sentAt: sentAt, + message: message, + urls: urls, + currentIndex: currentIndex, + ), + ); + }); + } +} diff --git a/lib/src/message_input.dart b/lib/src/message_input.dart index 1dbc45df..4e30afe1 100644 --- a/lib/src/message_input.dart +++ b/lib/src/message_input.dart @@ -658,6 +658,34 @@ class MessageInputState extends State { } Widget _buildFilePickerSection() { + var _attachmentContainsFile = + _attachments.any((element) => element?.attachment?.type == 'file'); + + Color _getIconColor(int index) { + switch (index) { + case 0: + return _attachmentContainsFile && _attachments.isNotEmpty + ? Colors.black.withOpacity(0.2) + : Colors.black.withOpacity(0.5); + break; + case 1: + return !_attachmentContainsFile && _attachments.isNotEmpty + ? Colors.black.withOpacity(0.2) + : Colors.black.withOpacity(0.5); + break; + case 2: + return _attachmentContainsFile && _attachments.isNotEmpty + ? Colors.black.withOpacity(0.2) + : Colors.black.withOpacity(0.5); + break; + case 3: + return _attachmentContainsFile && _attachments.isNotEmpty + ? Colors.black.withOpacity(0.2) + : Colors.black.withOpacity(0.5); + break; + } + } + return AnimatedContainer( duration: _animateContainer ? Duration(milliseconds: 300) : Duration.zero, height: _openFilePickerSection ? _filePickerSize : 0, @@ -671,49 +699,49 @@ class MessageInputState extends State { IconButton( iconSize: 24, icon: StreamSvgIcon.pictures( - color: _filePickerIndex == 0 - ? StreamChatTheme.of(context).accentColor - : Colors.black.withOpacity(0.5), + color: _getIconColor(0), ), - onPressed: () { - setState(() { - _filePickerIndex = 0; - }); - }, + onPressed: _attachmentContainsFile && _attachments.isNotEmpty + ? null + : () { + setState(() { + _filePickerIndex = 0; + }); + }, ), IconButton( iconSize: 32, icon: StreamSvgIcon.files( - color: _filePickerIndex == 1 - ? StreamChatTheme.of(context).accentColor - : Colors.black.withOpacity(0.5), + color: _getIconColor(1), ), - onPressed: () { - pickFile(DefaultAttachmentTypes.file, false); - }, + onPressed: !_attachmentContainsFile && _attachments.isNotEmpty + ? null + : () { + pickFile(DefaultAttachmentTypes.file, false); + }, ), IconButton( iconSize: 24, icon: StreamSvgIcon.camera( - color: _filePickerIndex == 2 - ? StreamChatTheme.of(context).accentColor - : Colors.black.withOpacity(0.5), + color: _getIconColor(2), ), - onPressed: () { - pickFile(DefaultAttachmentTypes.image, true); - }, + onPressed: _attachmentContainsFile && _attachments.isNotEmpty + ? null + : () { + pickFile(DefaultAttachmentTypes.image, true); + }, ), IconButton( padding: const EdgeInsets.all(0), iconSize: 24, icon: StreamSvgIcon.record( - color: _filePickerIndex == 3 - ? StreamChatTheme.of(context).accentColor - : Colors.black.withOpacity(0.5), + color: _getIconColor(3), ), - onPressed: () { - pickFile(DefaultAttachmentTypes.video, true); - }, + onPressed: _attachmentContainsFile && _attachments.isNotEmpty + ? null + : () { + pickFile(DefaultAttachmentTypes.video, true); + }, ), ], ), @@ -770,6 +798,9 @@ class MessageInputState extends State { } Widget _buildPickerSection() { + var _attachmentContainsFile = + _attachments.any((element) => element.attachment.type == 'file'); + switch (_filePickerIndex) { case 0: return FutureBuilder( @@ -782,19 +813,22 @@ class MessageInputState extends State { } if (snapshot.data) { - 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); - }); - } - }, + 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); + }); + } + }, + ), ); } @@ -850,7 +884,7 @@ class MessageInputState extends State { ); if (file.size > _kMaxAttachmentSize) { - if (medium.type == AssetType.video) { + if (medium?.type == AssetType.video) { final mediaInfo = await CompressVideoService.compressVideo(file.path); if (mediaInfo.filesize / (1024 * 1024) > _kMaxAttachmentSize) { @@ -890,7 +924,7 @@ class MessageInputState extends State { ..file = file ..attachment = Attachment( localUri: file.path != null ? Uri.parse(file.path) : null, - type: medium.type == AssetType.image ? 'image' : 'video', + type: medium?.type == AssetType.image ? 'image' : 'video', ); }); @@ -1053,7 +1087,7 @@ class MessageInputState extends State { offset: rejoin.length, ), ); - + _debounce.cancel(); _mentionsOverlay?.remove(); _mentionsOverlay = null; }, @@ -1201,44 +1235,98 @@ class MessageInputState extends State { Widget _buildAttachments() { return _attachments.isEmpty ? Container() - : LimitedBox( - maxHeight: 104.0, - child: ListView( - scrollDirection: Axis.horizontal, - children: _attachments - .map( - (attachment) => Padding( - padding: const EdgeInsets.all(8.0), - child: ClipRRect( - borderRadius: BorderRadius.circular(10), - child: Stack( - children: [ - AspectRatio( - aspectRatio: 1.0, - child: Container( - height: 104, - width: 104, - child: _buildAttachment(attachment), - ), - ), - _buildRemoveButton(attachment), - attachment.uploaded - ? SizedBox() - : Positioned.fill( - child: Center( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: CircularProgressIndicator(), + : Column( + children: [ + if (_attachments.any((e) => e.attachment?.type == 'file')) + LimitedBox( + maxHeight: 73.0, + child: ListView( + scrollDirection: Axis.horizontal, + children: _attachments + .where((e) => e.attachment?.type == 'file') + .map( + (e) => Padding( + padding: + const EdgeInsets.symmetric(horizontal: 8.0), + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + clipBehavior: Clip.antiAlias, + child: FileAttachment( + attachment: e.attachment, + size: Size( + MediaQuery.of(context).size.width * 0.55, + MediaQuery.of(context).size.height * 0.3, + ), + trailing: Padding( + padding: const EdgeInsets.all(8.0), + child: InkWell( + child: CircleAvatar( + backgroundColor: + Colors.black.withOpacity(0.2), + maxRadius: 12.0, + child: StreamSvgIcon.close( + color: Colors.white, ), ), + onTap: () { + setState(() { + _attachments.remove(e); + }); + }, ), - ], - ), - ), - ), - ) - .toList(), - ), + ), + ), + ), + ), + ) + .toList(), + ), + ), + if (_attachments.any((e) => e.attachment?.type != 'file')) + LimitedBox( + maxHeight: 104.0, + child: ListView( + scrollDirection: Axis.horizontal, + children: _attachments + .where((e) => e.attachment?.type != 'file') + .map( + (attachment) => Padding( + padding: const EdgeInsets.all(8.0), + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + clipBehavior: Clip.antiAlias, + child: Stack( + children: [ + AspectRatio( + aspectRatio: 1.0, + child: Container( + height: 104, + width: 104, + child: _buildAttachment(attachment), + ), + ), + _buildRemoveButton(attachment), + attachment.uploaded + ? SizedBox() + : Positioned.fill( + child: Center( + child: Padding( + padding: + const EdgeInsets.all(16.0), + child: + CircularProgressIndicator(), + ), + ), + ), + ], + ), + ), + ), + ) + .toList(), + ), + ), + ], ); } @@ -1275,9 +1363,9 @@ class MessageInputState extends State { Widget _buildAttachment(_SendingAttachment attachment) { if (widget.attachmentThumbnailBuilders - ?.containsKey(attachment.attachment.type) == + ?.containsKey(attachment.attachment?.type) == true) { - return widget.attachmentThumbnailBuilders[attachment.attachment.type]( + return widget.attachmentThumbnailBuilders[attachment.attachment?.type]( context, attachment, ); @@ -1287,7 +1375,7 @@ class MessageInputState extends State { return SizedBox(); } - switch (attachment.attachment.type) { + switch (attachment.attachment?.type) { case 'image': case 'giphy': return attachment.file != null @@ -1562,12 +1650,24 @@ class MessageInputState extends State { attachmentType = mimeType.type; } + Map extraDataMap = {}; + + if (mimeType?.subtype != null) { + extraDataMap['mime_type'] = mimeType.subtype.toLowerCase(); + } + + if (file.size != null) { + extraDataMap['file_size'] = file.size; + } + final channel = StreamChannel.of(context).channel; final attachment = _SendingAttachment( file: file, attachment: Attachment( localUri: file.path != null ? Uri.parse(file.path) : null, type: attachmentType, + extraData: extraDataMap.isNotEmpty ? extraDataMap : null, + title: file.name ?? 'File', ), ); diff --git a/lib/src/message_list_view.dart b/lib/src/message_list_view.dart index 8a4ac9c3..b4e8de74 100644 --- a/lib/src/message_list_view.dart +++ b/lib/src/message_list_view.dart @@ -617,7 +617,12 @@ class _MessageListViewState extends State { showDeleteMessage: isMyMessage, borderSide: isMyMessage ? BorderSide.none : null, onThreadTap: _onThreadTap, - attachmentBorderRadiusGeometry: BorderRadius.circular(16), + attachmentBorderRadiusGeometry: BorderRadius.only( + topLeft: Radius.circular(16), + bottomLeft: Radius.circular(!isNextUser ? 0 : 16), + topRight: Radius.circular(16), + bottomRight: Radius.circular(16), + ), attachmentPadding: const EdgeInsets.all(2), borderRadiusGeometry: BorderRadius.only( topLeft: Radius.circular(16), diff --git a/lib/src/message_widget.dart b/lib/src/message_widget.dart index 88a93d6f..f761f756 100644 --- a/lib/src/message_widget.dart +++ b/lib/src/message_widget.dart @@ -180,14 +180,13 @@ class MessageWidget extends StatefulWidget { MediaQuery.of(context).size.width * 0.8, MediaQuery.of(context).size.height * 0.3, ), + message: message, ); }, 'giphy': (context, message, attachment) { return GiphyAttachment( attachment: attachment, - messageTheme: messageTheme.copyWith( - messageBackgroundColor: Colors.white, - ), + messageTheme: messageTheme, message: message, size: Size( MediaQuery.of(context).size.width * 0.8, @@ -218,8 +217,8 @@ class _MessageWidgetState extends State { ? widget.messageTheme.avatarTheme.constraints.maxWidth + 16.0 : 6.0; - final isGiphy = - widget.message.attachments?.any((element) => element.type == 'giphy') == + final hasFiles = + widget.message.attachments?.any((element) => element.type == 'file') == true; return Portal( @@ -294,18 +293,46 @@ class _MessageWidgetState extends State { messageTheme: widget.messageTheme, ), ) - : Column( - crossAxisAlignment: - CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - ..._parseAttachments(context), - if (widget.message.text - .trim() - .isNotEmpty && - !isGiphy) - _buildTextBubble(context), - ], + : Material( + clipBehavior: Clip.antiAlias, + shape: widget.shape ?? + RoundedRectangleBorder( + side: isOnlyEmoji + ? BorderSide.none + : widget.borderSide ?? + BorderSide( + color: Theme.of(context) + .brightness == + Brightness + .dark + ? Colors.white + .withAlpha(24) + : Colors.black + .withAlpha( + 24), + ), + borderRadius: widget + .borderRadiusGeometry ?? + BorderRadius.zero, + ), + color: _getBackgroundColor(), + child: Padding( + padding: EdgeInsets.all( + hasFiles ? 2.0 : 0.0), + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + ..._parseAttachments(context), + if (widget.message.text + .trim() + .isNotEmpty && + !isGiphy) + _buildTextBubble(context), + ], + ), + ), ), ), if (widget.showReactionPickerIndicator) @@ -448,6 +475,10 @@ class _MessageWidgetState extends State { ); } + bool get isGiphy => + widget.message.attachments?.any((element) => element.type == 'giphy') == + true; + Widget _buildReadIndicator() { var padding = 0.0; return Stack( @@ -628,46 +659,39 @@ class _MessageWidgetState extends State { []; } - Padding wrapAttachmentWidget( + Widget wrapAttachmentWidget( BuildContext context, Widget attachmentWidget, { Attachment attachment, }) { final attachmentShape = widget.attachmentShape ?? widget.shape ?? _getDefaultShape(context); - return Padding( - padding: EdgeInsets.only( - bottom: 4, - ), - child: GestureDetector( - onTap: () => retryMessage(context), - onLongPress: () => onLongPress(context), - child: Material( - color: attachment?.type == 'giphy' - ? Colors.white - : _getBackgroundColor(), - clipBehavior: Clip.hardEdge, - shape: attachmentShape, - child: Padding( - padding: widget.attachmentPadding, - child: Material( - clipBehavior: Clip.hardEdge, - shape: attachmentShape, - type: MaterialType.transparency, - child: Transform( - transform: Matrix4.rotationY(widget.reverse ? pi : 0), - alignment: Alignment.center, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - getFailedMessageWidget( - context, - padding: const EdgeInsets.all(8.0), - ), - attachmentWidget, - ], - ), + return GestureDetector( + onTap: () => retryMessage(context), + onLongPress: () => onLongPress(context), + child: Material( + color: _getBackgroundColor(), + clipBehavior: Clip.antiAlias, + shape: attachmentShape, + child: Padding( + padding: widget.attachmentPadding, + child: Material( + clipBehavior: Clip.hardEdge, + shape: attachmentShape, + type: MaterialType.transparency, + child: Transform( + transform: Matrix4.rotationY(widget.reverse ? pi : 0), + alignment: Alignment.center, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + getFailedMessageWidget( + context, + padding: const EdgeInsets.all(8.0), + ), + attachmentWidget, + ], ), ), ), @@ -797,30 +821,7 @@ class _MessageWidgetState extends State { return SizedBox(); } - Widget _wrapTextInBubble({ - BuildContext context, - Widget child, - }) { - return Material( - shape: widget.shape ?? - RoundedRectangleBorder( - side: widget.borderSide ?? - BorderSide( - color: Theme.of(context).brightness == Brightness.dark - ? Colors.white.withAlpha(24) - : Colors.black.withAlpha(24), - ), - borderRadius: widget.borderRadiusGeometry ?? BorderRadius.zero, - ), - color: _getBackgroundColor(), - child: child, - ); - } - Widget _buildTextBubble(BuildContext context) { - final isOnlyEmoji = - widget.message.text.characters.every((c) => Emoji.byChar(c) != null); - Widget child = Transform( transform: Matrix4.rotationY(widget.reverse ? pi : 0), alignment: Alignment.center, @@ -857,13 +858,6 @@ class _MessageWidgetState extends State { ], ), ); - - if (!isOnlyEmoji) { - child = _wrapTextInBubble( - context: context, - child: child, - ); - } return GestureDetector( onTap: () => retryMessage(context), onLongPress: () => onLongPress(context), @@ -871,6 +865,10 @@ class _MessageWidgetState extends State { ); } + bool get isOnlyEmoji => + widget.message.text.characters.isNotEmpty && + widget.message.text.characters.every((c) => Emoji.byChar(c) != null); + Color _getBackgroundColor() { if ((widget.message.status == MessageSendingStatus.FAILED || widget.message.status == MessageSendingStatus.FAILED_UPDATE || @@ -884,6 +882,14 @@ class _MessageWidgetState extends State { return Color(0xFFE9F2FF); } + if (isOnlyEmoji) { + return Colors.transparent; + } + + if (isGiphy) { + return Colors.transparent; + } + return widget.messageTheme.messageBackgroundColor; } diff --git a/lib/src/stream_svg_icon.dart b/lib/src/stream_svg_icon.dart index 9a87ce9d..42fed46e 100644 --- a/lib/src/stream_svg_icon.dart +++ b/lib/src/stream_svg_icon.dart @@ -338,6 +338,30 @@ class StreamSvgIcon extends StatelessWidget { ); } + factory StreamSvgIcon.download({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'Icon_download.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.cloud_download({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'Icon_cloud_download.svg', + color: color, + width: size, + height: size, + ); + } + factory StreamSvgIcon.copy({ double size, Color color, @@ -397,4 +421,304 @@ class StreamSvgIcon extends StatelessWidget { height: size, ); } + + factory StreamSvgIcon.Icon_curve_line_left_up({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'Icon_curve_line_left_up.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.icon_SHARE({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'icon_SHARE.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.Icon_grid({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'Icon_grid.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.Icon_send_message({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'Icon_send_message.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.Icon_menu_point_v({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'Icon_menu_point_v.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.Icon_save({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'Icon_save.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.share_arrow({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'share_arrow.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.filetype_7z({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'filetype_7z.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.filetype_csv({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'filetype_CSV.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.filetype_doc({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'filetype_DOC.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.filetype_docx({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'filetype_DOCX.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.filetype_Generic({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'filetype_Generic.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.filetype_html({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'filetype_html.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.filetype_md({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'filetype_MD.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.filetype_odt({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'filetype_ODT.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.filetype_pdf({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'filetype_PDF.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.filetype_ppt({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'filetype_PPT.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.filetype_pptx({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'filetype_PPTX.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.filetype_rar({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'filetype_RAR.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.filetype_rtf({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'filetype_RTF.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.filetype_tar({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'filetype_TAR.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.filetype_txt({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'filetype_TXT.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.filetype_xls({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'filetype_XLS.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.filetype_xlsx({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'filetype_XLSX.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.filetype_zip({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'filetype_ZIP.svg', + color: color, + width: size, + height: size, + ); + } } diff --git a/lib/src/video_attachment.dart b/lib/src/video_attachment.dart index b0d1f60d..21ede87a 100644 --- a/lib/src/video_attachment.dart +++ b/lib/src/video_attachment.dart @@ -1,7 +1,7 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:chewie/chewie.dart'; import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/full_screen_video.dart'; +import 'package:stream_chat_flutter/src/full_screen_media.dart'; import 'package:stream_chat_flutter/src/utils.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:video_player/video_player.dart'; @@ -13,11 +13,13 @@ class VideoAttachment extends StatefulWidget { final Attachment attachment; final MessageTheme messageTheme; final Size size; + final Message message; VideoAttachment({ Key key, @required this.attachment, @required this.messageTheme, + this.message, this.size, }) : super(key: key); @@ -81,11 +83,19 @@ class _VideoAttachmentState extends State { return GestureDetector( onTap: () { + final channel = StreamChannel.of(context).channel; + Navigator.push( context, MaterialPageRoute( - builder: (_) => FullScreenVideo( - attachment: widget.attachment, + builder: (_) => StreamChannel( + channel: channel, + child: FullScreenMedia( + mediaAttachments: [widget.attachment], + userName: widget.message.user.name, + sentAt: widget.message.createdAt, + message: widget.message, + ), ), ), ); @@ -100,7 +110,7 @@ class _VideoAttachmentState extends State { children: [ Expanded( child: FittedBox( - fit: BoxFit.cover, + fit: BoxFit.none, child: Stack( children: [ Chewie( diff --git a/lib/stream_chat_flutter.dart b/lib/stream_chat_flutter.dart index 1cbe21e4..5dd46fa3 100644 --- a/lib/stream_chat_flutter.dart +++ b/lib/stream_chat_flutter.dart @@ -11,7 +11,9 @@ export 'src/channels_bloc.dart'; export 'src/date_divider.dart'; export 'src/deleted_message.dart'; export 'src/file_attachment.dart'; -export 'src/full_screen_video.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'; diff --git a/lib/svgs/Icon_menu_point_v.svg b/lib/svgs/Icon_menu_point_v.svg new file mode 100644 index 00000000..f7b61163 --- /dev/null +++ b/lib/svgs/Icon_menu_point_v.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/lib/svgs/Icon_save.svg b/lib/svgs/Icon_save.svg new file mode 100644 index 00000000..14bedc6f --- /dev/null +++ b/lib/svgs/Icon_save.svg @@ -0,0 +1,4 @@ + + + + diff --git a/lib/svgs/Icon_send_message.svg b/lib/svgs/Icon_send_message.svg new file mode 100644 index 00000000..0d504a40 --- /dev/null +++ b/lib/svgs/Icon_send_message.svg @@ -0,0 +1,3 @@ + + + diff --git a/pubspec.yaml b/pubspec.yaml index 75195a30..b41f58c6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -38,6 +38,8 @@ dependencies: flutter_slidable: ^0.5.4 carousel_slider: ^2.2.1 clipboard: ^0.1.2+8 + image_gallery_saver: ^1.6.6 + esys_flutter_share: ^1.0.2 photo_manager: ^0.5.8 transparent_image: ^1.0.0 ezanimation: ^0.4.1