Merge pull request #298 from GetStream/feat/attachment_downloader

[UI-Kit] Refactor, add support for downloading attachments.
This commit is contained in:
Salvatore Giordano
2021-03-02 11:34:10 +01:00
committed by GitHub
4 changed files with 322 additions and 196 deletions
+1 -1
View File
@@ -468,7 +468,7 @@ class StreamChatClient {
user: state.user,
connectParams: {
'api_key': apiKey,
'Authorization': token,
'authorization': token,
'stream-auth-type': _authType,
'X-Stream-Client': _userAgent,
},
@@ -0,0 +1,318 @@
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<String> Function(
Attachment attachment, {
ProgressCallback progressCallback,
});
class AttachmentActionsModal extends StatelessWidget {
final Message message;
final String userName;
final String sentAt;
final List<Attachment> 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,
);
},
).catchError((_) {
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).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<Widget>((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 WillPopScope(
onWillPop: () => Future.value(false),
child: 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).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: progress == null
? Container(
height: 100,
width: 100,
child: StreamSvgIcon.error(
color: theme.colorTheme.greyGainsboro,
),
)
: progress.toProgressIndicatorValue == 1.0
? 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: progress.toProgressIndicatorValue,
strokeWidth: 8.0,
valueColor: AlwaysStoppedAnimation(
theme.colorTheme.accentBlue,
),
),
Center(
child: Text(
'${progress.toPercentage}%',
style: theme.textTheme.headline.copyWith(
color: theme.colorTheme.grey,
),
),
),
],
),
),
),
),
),
);
},
),
);
}
Future<String> _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;
}
@@ -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<Attachment> 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<Widget>((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<void> _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<void> _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);
}
}
@@ -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,
),