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 'package:stream_chat_flutter/src/extension.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// Callback to download an attachment asset typedef AttachmentDownloader = Future Function( Attachment attachment, { ProgressCallback? progressCallback, }); /// Widget that shows the options in the gallery view class AttachmentActionsModal extends StatelessWidget { /// Returns a new [AttachmentActionsModal] const AttachmentActionsModal({ Key? key, required this.currentIndex, required this.message, this.onShowMessage, this.imageDownloader, this.fileDownloader, }) : super(key: key); /// The message containing the attachments final Message message; /// Current page index final int currentIndex; /// Callback to show the message final VoidCallback? onShowMessage; /// Callback to download images final AttachmentDownloader? imageDownloader; /// Callback to provide download files final AttachmentDownloader? fileDownloader; @override Widget build(BuildContext context) => GestureDetector( behavior: HitTestBehavior.translucent, onTap: () => Navigator.maybePop(context), child: _buildPage(context), ); Widget _buildPage(BuildContext context) { final theme = StreamChatTheme.of(context); return Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ const SizedBox(height: kToolbarHeight), Padding( padding: const EdgeInsets.only(right: 8), child: Container( width: MediaQuery.of(context).size.width * 0.5, clipBehavior: Clip.hardEdge, decoration: BoxDecoration( borderRadius: BorderRadius.circular(16), ), child: SizedBox( child: Column( crossAxisAlignment: CrossAxisAlignment.end, mainAxisSize: MainAxisSize.min, children: [ _buildButton( context, context.translations.replyLabel, StreamSvgIcon.iconCurveLineLeftUp( size: 24, color: theme.colorTheme.textLowEmphasis, ), () { Navigator.pop(context, ReturnActionType.reply); }, ), _buildButton( context, context.translations.showInChatLabel, StreamSvgIcon.eye( size: 24, color: theme.colorTheme.textHighEmphasis, ), onShowMessage, ), _buildButton( context, message.attachments[currentIndex].type == 'video' ? context.translations.saveVideoLabel : context.translations.saveImageLabel, StreamSvgIcon.iconSave( size: 24, color: theme.colorTheme.textLowEmphasis, ), () { final attachment = message.attachments[currentIndex]; final isImage = attachment.type == 'image'; final Future Function(Attachment, {void Function(int, int) progressCallback}) saveFile = fileDownloader ?? _downloadAttachment; final Future Function(Attachment, {void Function(int, int) progressCallback}) saveImage = imageDownloader ?? _downloadAttachment; final downloader = isImage ? saveImage : saveFile; final progressNotifier = ValueNotifier<_DownloadProgress?>( _DownloadProgress.initial(), ); downloader( attachment, progressCallback: (received, total) { progressNotifier.value = _DownloadProgress( total, received, ); }, ).catchError((e, stk) { progressNotifier.value = null; }); // 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).currentUser?.id == message.user?.id) _buildButton( context, context.translations.deleteLabel.capitalize(), StreamSvgIcon.delete( size: 24, color: theme.colorTheme.accentError, ), () { final channel = StreamChannel.of(context).channel; if (message.attachments.length > 1 || message.text?.isNotEmpty == true) { final remainingAttachments = [...message.attachments] ..removeAt(currentIndex); channel.updateMessage(message.copyWith( attachments: remainingAttachments, )); Navigator.of(context) ..pop() ..maybePop(); } else { channel.deleteMessage(message); Navigator.of(context) ..pop() ..maybePop(); } }, color: theme.colorTheme.accentError, ), ] .map((e) => Align( alignment: Alignment.centerRight, child: e, )) .insertBetween( Container( height: 1, color: theme.colorTheme.borders, ), ), ), ), ), ) ], ); } Widget _buildButton( context, String title, StreamSvgIcon icon, VoidCallback? onTap, { Color? color, Key? key, }) => Material( key: key, color: StreamChatTheme.of(context).colorTheme.barsBg, child: InkWell( onTap: onTap, child: Padding( padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), child: Row( children: [ icon, const 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, __) { // Pop the dialog in case the progress is null or it's completed. if (progress == null || progress.toProgressIndicatorValue == 1.0) { Future.delayed( const Duration(milliseconds: 500), () => Navigator.of(context).maybePop(), ); } return Material( type: MaterialType.transparency, child: Center( child: Container( height: 182, width: 182, decoration: BoxDecoration( borderRadius: BorderRadius.circular(16), color: theme.colorTheme.barsBg, ), child: Center( child: progress == null ? SizedBox( height: 100, width: 100, child: StreamSvgIcon.error( color: theme.colorTheme.disabled, ), ) : progress.toProgressIndicatorValue == 1.0 ? SizedBox( key: const Key('completedIcon'), height: 160, width: 160, child: StreamSvgIcon.check( color: theme.colorTheme.disabled, ), ) : SizedBox( height: 100, width: 100, child: Stack( fit: StackFit.expand, children: [ CircularProgressIndicator( value: progress.toProgressIndicatorValue, strokeWidth: 8, valueColor: AlwaysStoppedAnimation( theme.colorTheme.accentPrimary, ), ), Center( child: Text( '${progress.toPercentage}%', style: theme.textTheme.headline.copyWith( color: theme.colorTheme.textLowEmphasis, ), ), ), ], ), ), ), ), ), ); }, ); } 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 { const _DownloadProgress(this.total, this.received); factory _DownloadProgress.initial() => _DownloadProgress(double.maxFinite.toInt(), 0); final int total; final int received; double get toProgressIndicatorValue => received / total; int get toPercentage => (received * 100) ~/ total; }