diff --git a/packages/stream_chat_flutter/lib/src/attachment_actions_modal.dart b/packages/stream_chat_flutter/lib/src/attachment_actions_modal.dart new file mode 100644 index 00000000..a1755143 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/attachment_actions_modal.dart @@ -0,0 +1,306 @@ +import 'dart:ui'; + +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:image_gallery_saver/image_gallery_saver.dart'; +import 'package:path_provider/path_provider.dart'; + +import '../stream_chat_flutter.dart'; +import 'extension.dart'; + +typedef AttachmentDownloader = Future Function( + Attachment attachment, { + ProgressCallback progressCallback, +}); + +class AttachmentActionsModal extends StatelessWidget { + final Message message; + final String userName; + final String sentAt; + final List attachments; + final currentIndex; + final VoidCallback onShowMessage; + final AttachmentDownloader imageDownloader; + final AttachmentDownloader fileDownloader; + + const AttachmentActionsModal({ + this.message, + this.userName, + this.sentAt, + this.attachments, + this.currentIndex, + this.onShowMessage, + this.imageDownloader, + this.fileDownloader, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () => Navigator.maybePop(context), + child: _buildPage(context), + ); + } + + Widget _buildPage(context) { + final theme = StreamChatTheme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + SizedBox(height: kToolbarHeight), + Padding( + padding: const EdgeInsets.only(right: 8.0), + child: Container( + width: MediaQuery.of(context).size.width * 0.5, + clipBehavior: Clip.hardEdge, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16.0), + ), + child: Container( + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + _buildButton( + context, + 'Reply', + StreamSvgIcon.iconCurveLineLeftUp( + size: 24.0, + color: theme.colorTheme.grey, + ), + () { + Navigator.pop(context, ReturnActionType.reply); + }, + ), + _buildButton( + context, + 'Show in Chat', + StreamSvgIcon.eye( + size: 24.0, + color: theme.colorTheme.black, + ), + onShowMessage, + ), + _buildButton( + context, + 'Save ${attachments[currentIndex].type == 'video' ? 'Video' : 'Image'}', + StreamSvgIcon.iconSave( + size: 24.0, + color: theme.colorTheme.grey, + ), + () { + final attachment = attachments[currentIndex]; + final isImage = attachment.type == 'image'; + final saveFile = fileDownloader ?? _downloadAttachment; + final saveImage = imageDownloader ?? _downloadAttachment; + final downloader = isImage ? saveImage : saveFile; + + final progressNotifier = ValueNotifier<_DownloadProgress>( + _DownloadProgress.initial(), + ); + + downloader( + attachment, + progressCallback: (received, total) { + progressNotifier.value = _DownloadProgress( + total, + received, + ); + }, + ); + + // Closing attachment actions modal before opening + // attachment download dialog + Navigator.pop(context); + + showDialog( + barrierDismissible: false, + context: context, + barrierColor: theme.colorTheme.overlay, + builder: (context) => _buildDownloadProgressDialog( + context, + progressNotifier, + ), + ); + }, + ), + if (StreamChat.of(context).user.id == message.user.id) + _buildButton( + context, + 'Delete', + StreamSvgIcon.delete( + size: 24.0, + color: theme.colorTheme.accentRed, + ), + () { + final channel = StreamChannel.of(context).channel; + if (message.attachments.length > 1 || + message.text.isNotEmpty) { + final remainingAttachments = [...message.attachments] + ..removeAt(currentIndex); + channel.updateMessage(message.copyWith( + attachments: remainingAttachments, + )); + Navigator.pop(context); + Navigator.pop(context); + } else { + channel.deleteMessage(message).then((value) { + Navigator.pop(context); + Navigator.pop(context); + }); + } + }, + color: theme.colorTheme.accentRed, + ), + ] + .map((e) => + Align(alignment: Alignment.centerRight, child: e)) + .insertBetween( + Container( + height: 1, + color: theme.colorTheme.greyWhisper, + ), + ), + ), + ), + ), + ) + ], + ); + } + + Widget _buildButton( + context, + String title, + StreamSvgIcon icon, + VoidCallback onTap, { + Color color, + }) { + return Material( + color: StreamChatTheme.of(context).colorTheme.white, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0), + child: Row( + children: [ + icon, + SizedBox(width: 16), + Text( + title, + style: StreamChatTheme.of(context) + .textTheme + .body + .copyWith(color: color), + ), + ], + ), + ), + ), + ); + } + + Widget _buildDownloadProgressDialog( + BuildContext context, + ValueNotifier<_DownloadProgress> progressNotifier, + ) { + final theme = StreamChatTheme.of(context); + return ValueListenableBuilder( + valueListenable: progressNotifier, + builder: (_, _DownloadProgress progress, __) { + final value = progress.toProgressIndicatorValue; + final downloadComplete = value == 1.0; + if (value == 1.0) { + Future.delayed( + const Duration(milliseconds: 500), + Navigator.of(context).pop, + ); + } + return Material( + type: MaterialType.transparency, + child: Center( + child: Container( + height: 182, + width: 182, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + color: theme.colorTheme.white, + ), + child: Center( + child: downloadComplete + ? Container( + height: 160, + width: 160, + child: StreamSvgIcon.check( + color: theme.colorTheme.greyGainsboro, + ), + ) + : Container( + height: 100, + width: 100, + child: Stack( + fit: StackFit.expand, + children: [ + CircularProgressIndicator( + value: value, + strokeWidth: 8.0, + valueColor: AlwaysStoppedAnimation( + theme.colorTheme.accentBlue, + ), + ), + Center( + child: Text( + '${progress.toPercentage}%', + style: theme.textTheme.headline.copyWith( + color: theme.colorTheme.grey, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + }, + ); + } + + Future _downloadAttachment( + Attachment attachment, { + ProgressCallback progressCallback, + }) async { + String filePath; + final appDocDir = await getTemporaryDirectory(); + await Dio().download( + attachment.assetUrl ?? attachment.imageUrl ?? attachment.thumbUrl, + (Headers responseHeaders) { + final contentType = responseHeaders[Headers.contentTypeHeader]; + final mimeType = contentType.first?.split('/')?.last; + filePath ??= '${appDocDir.path}/${attachment.id}.$mimeType'; + return filePath; + }, + onReceiveProgress: progressCallback, + ); + final result = await ImageGallerySaver.saveFile(filePath); + return (result as Map)['filePath']; + } +} + +class _DownloadProgress { + final int total; + final int received; + + const _DownloadProgress(this.total, this.received); + + factory _DownloadProgress.initial() { + return _DownloadProgress(double.maxFinite.toInt(), 0); + } + + double get toProgressIndicatorValue => received / total; + + int get toPercentage => (received * 100) ~/ total; +} diff --git a/packages/stream_chat_flutter/lib/src/image_actions_modal.dart b/packages/stream_chat_flutter/lib/src/image_actions_modal.dart deleted file mode 100644 index 1304b90d..00000000 --- a/packages/stream_chat_flutter/lib/src/image_actions_modal.dart +++ /dev/null @@ -1,192 +0,0 @@ -import 'dart:typed_data'; -import 'dart:ui'; - -import 'package:dio/dio.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:image_gallery_saver/image_gallery_saver.dart'; -import 'package:path_provider/path_provider.dart'; - -import '../stream_chat_flutter.dart'; -import 'extension.dart'; - -class ImageActionsModal extends StatelessWidget { - final Message message; - final String userName; - final String sentAt; - final List urls; - final currentIndex; - final VoidCallback onShowMessage; - - ImageActionsModal( - {this.message, - this.userName, - this.sentAt, - this.urls, - this.currentIndex, - this.onShowMessage}); - - @override - Widget build(BuildContext context) { - return GestureDetector( - behavior: HitTestBehavior.translucent, - onTap: () => Navigator.maybePop(context), - child: _buildPage(context), - ); - } - - Widget _buildPage(context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - SizedBox(height: kToolbarHeight), - Padding( - padding: const EdgeInsets.only(right: 8.0), - child: Container( - width: MediaQuery.of(context).size.width * 0.5, - clipBehavior: Clip.hardEdge, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16.0), - ), - child: Container( - child: Column( - crossAxisAlignment: CrossAxisAlignment.end, - mainAxisSize: MainAxisSize.min, - children: [ - _buildButton( - context, - 'Reply', - StreamSvgIcon.iconCurveLineLeftUp( - size: 24.0, - color: StreamChatTheme.of(context).colorTheme.grey, - ), - () { - Navigator.pop(context, ReturnActionType.reply); - }, - ), - _buildButton( - context, - 'Show in Chat', - StreamSvgIcon.eye( - size: 24.0, - color: StreamChatTheme.of(context).colorTheme.black, - ), - onShowMessage, - ), - _buildButton( - context, - 'Save ${urls[currentIndex].type == 'video' ? 'Video' : 'Image'}', - StreamSvgIcon.iconSave( - size: 24.0, - color: StreamChatTheme.of(context).colorTheme.grey, - ), - () async { - var url = urls[currentIndex].imageUrl ?? - urls[currentIndex].assetUrl ?? - urls[currentIndex].thumbUrl; - - Navigator.pop(context); - - if (urls[currentIndex].type == 'video') { - await _saveVideo(url); - } else { - await _saveImage(url); - } - }, - ), - if (StreamChat.of(context).user.id == message.user.id) - _buildButton( - context, - 'Delete', - StreamSvgIcon.delete( - size: 24.0, - color: StreamChatTheme.of(context).colorTheme.accentRed, - ), - () { - final channel = StreamChannel.of(context).channel; - if (message.attachments.length > 1 || - message.text.isNotEmpty) { - final remainingAttachments = [...message.attachments] - ..removeAt(currentIndex); - channel.updateMessage(message.copyWith( - attachments: remainingAttachments, - )); - Navigator.pop(context); - Navigator.pop(context); - } else { - channel.deleteMessage(message).then((value) { - Navigator.pop(context); - Navigator.pop(context); - }); - } - }, - color: StreamChatTheme.of(context).colorTheme.accentRed, - ), - ] - .map((e) => - Align(alignment: Alignment.centerRight, child: e)) - .insertBetween( - Container( - height: 1, - color: - StreamChatTheme.of(context).colorTheme.greyWhisper, - ), - ), - ), - ), - ), - ) - ], - ); - } - - Widget _buildButton( - context, - String title, - StreamSvgIcon icon, - VoidCallback onTap, { - Color color, - }) { - return Material( - color: StreamChatTheme.of(context).colorTheme.white, - child: InkWell( - onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0), - child: Row( - children: [ - icon, - SizedBox(width: 16), - Text( - title, - style: StreamChatTheme.of(context) - .textTheme - .body - .copyWith(color: color), - ), - ], - ), - ), - ), - ); - } - - 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/packages/stream_chat_flutter/lib/src/image_header.dart b/packages/stream_chat_flutter/lib/src/image_header.dart index 25eab839..67e8e5cd 100644 --- a/packages/stream_chat_flutter/lib/src/image_header.dart +++ b/packages/stream_chat_flutter/lib/src/image_header.dart @@ -3,7 +3,7 @@ import 'package:stream_chat_flutter/src/stream_chat_theme.dart'; import 'package:stream_chat_flutter/src/stream_svg_icon.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; -import 'image_actions_modal.dart'; +import 'attachment_actions_modal.dart'; class ImageHeader extends StatelessWidget implements PreferredSizeWidget { /// True if this header shows the leading back button @@ -110,11 +110,11 @@ class ImageHeader extends StatelessWidget implements PreferredSizeWidget { builder: (context) { return StreamChannel( channel: channel, - child: ImageActionsModal( + child: AttachmentActionsModal( userName: userName, sentAt: sentAt, message: message, - urls: urls, + attachments: urls, currentIndex: currentIndex, onShowMessage: onShowMessage, ),