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. --> - - + + onTap: () { onTap(channel, widget.channelWidget); }, + onLongPress: widget.onChannelLongPress != null + ? () { + widget.onChannelLongPress(channel); + } + : null, ), ), ), 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..6ab38c83 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,10 +64,18 @@ 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, + ), ); })); }, @@ -278,10 +286,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..7c39fb4c --- /dev/null +++ b/lib/src/image_footer.dart @@ -0,0 +1,682 @@ +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( + 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_widget.dart b/lib/src/message_widget.dart index 88a93d6f..64dab4ac 100644 --- a/lib/src/message_widget.dart +++ b/lib/src/message_widget.dart @@ -180,6 +180,7 @@ class MessageWidget extends StatefulWidget { MediaQuery.of(context).size.width * 0.8, MediaQuery.of(context).size.height * 0.3, ), + message: message, ); }, 'giphy': (context, message, attachment) { diff --git a/lib/src/stream_svg_icon.dart b/lib/src/stream_svg_icon.dart index 9a87ce9d..e5ef7ec9 100644 --- a/lib/src/stream_svg_icon.dart +++ b/lib/src/stream_svg_icon.dart @@ -397,4 +397,88 @@ 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, + ); + } } 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 1d2a299e..a226f9c3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -41,6 +41,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