diff --git a/core/lib/presentation/extensions/color_extension.dart b/core/lib/presentation/extensions/color_extension.dart index c6740d52f..1eec437fe 100644 --- a/core/lib/presentation/extensions/color_extension.dart +++ b/core/lib/presentation/extensions/color_extension.dart @@ -130,6 +130,7 @@ extension AppColor on Color { static const colorBackgroundWrapIconStyleCode = Color(0xFFF2F3F5); static const colorSelectedRichTextButton = Color(0x99EBEDF0); static const colorShadowDestinationPicker = Color(0x33000000); + static const colorBackgroundSnackBar = Color(0xFF343438); static const mapGradientColor = [ [Color(0xFF21D4FD), Color(0xFFB721FF)], diff --git a/lib/features/base/mixin/app_loader_mixin.dart b/lib/features/base/mixin/app_loader_mixin.dart index 220ad24c0..a9395e619 100644 --- a/lib/features/base/mixin/app_loader_mixin.dart +++ b/lib/features/base/mixin/app_loader_mixin.dart @@ -2,6 +2,7 @@ import 'package:core/core.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:percent_indicator/circular_percent_indicator.dart'; import 'package:percent_indicator/linear_percent_indicator.dart'; mixin AppLoaderMixin { @@ -21,6 +22,18 @@ mixin AppLoaderMixin { backgroundColor: AppColor.colorBgMailboxSelected)); } + Widget circularPercentLoadingWidget(double percent) { + return Center( + child: CircularPercentIndicator( + percent: percent > 1.0 ? 1.0 : percent, + backgroundColor: AppColor.colorBgMailboxSelected, + progressColor: AppColor.primaryColor, + lineWidth: 3, + radius: 14, + ) + ); + } + Widget horizontalPercentLoadingWidget(double percent) { return Center( child: LinearPercentIndicator( diff --git a/lib/features/email/presentation/email_controller.dart b/lib/features/email/presentation/email_controller.dart index 76705a3c4..2ea06a701 100644 --- a/lib/features/email/presentation/email_controller.dart +++ b/lib/features/email/presentation/email_controller.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:core/core.dart'; @@ -17,6 +18,7 @@ import 'package:permission_handler/permission_handler.dart'; import 'package:pointer_interceptor/pointer_interceptor.dart'; import 'package:share/share.dart' as share_library; import 'package:tmail_ui_user/features/base/base_controller.dart'; +import 'package:tmail_ui_user/features/base/mixin/app_loader_mixin.dart'; import 'package:tmail_ui_user/features/composer/presentation/extensions/email_action_type_extension.dart'; import 'package:tmail_ui_user/features/destination_picker/presentation/model/destination_picker_arguments.dart'; import 'package:tmail_ui_user/features/email/domain/model/move_action.dart'; @@ -40,17 +42,21 @@ import 'package:tmail_ui_user/features/email/presentation/widgets/email_address_ import 'package:tmail_ui_user/features/email/presentation/widgets/email_address_dialog_builder.dart'; import 'package:tmail_ui_user/features/mailbox/presentation/model/mailbox_actions.dart'; import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller.dart'; +import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/model/download/download_task_state.dart'; import 'package:tmail_ui_user/features/thread/presentation/model/delete_action_type.dart'; import 'package:tmail_ui_user/main/localizations/app_localizations.dart'; import 'package:tmail_ui_user/main/routes/app_routes.dart'; import 'package:tmail_ui_user/main/routes/route_navigation.dart'; +import 'package:uuid/uuid.dart'; -class EmailController extends BaseController { +class EmailController extends BaseController with AppLoaderMixin { final mailboxDashBoardController = Get.find(); final responsiveUtils = Get.find(); final imagePaths = Get.find(); final _appToast = Get.find(); + final _uuid = Get.find(); + final _downloadManager = Get.find(); final GetEmailContentInteractor _getEmailContentInteractor; final MarkAsEmailReadInteractor _markAsEmailReadInteractor; @@ -71,6 +77,10 @@ class EmailController extends BaseController { late Worker emailWorker; + final StreamController> _downloadProgressStateController = + StreamController>.broadcast(); + Stream> get downloadProgressState => _downloadProgressStateController.stream; + PresentationMailbox? get currentMailbox => mailboxDashBoardController.selectedMailbox.value; PresentationEmail? get currentEmail => mailboxDashBoardController.selectedEmail.value; @@ -89,11 +99,13 @@ class EmailController extends BaseController { @override void onInit() { _initWorker(); + _listenDownloadAttachmentProgressState(); super.onInit(); } @override void onClose() { + _downloadProgressStateController.close(); _clearWorker(); super.onClose(); } @@ -117,6 +129,43 @@ class EmailController extends BaseController { emailWorker.call(); } + void _listenDownloadAttachmentProgressState() { + downloadProgressState.listen((state) { + log('EmailController::_listenDownloadAttachmentProgressState(): $state'); + state.fold( + (failure) => null, + (success) { + if (success is StartDownloadAttachmentForWeb) { + mailboxDashBoardController.addDownloadTask( + DownloadTaskState( + taskId: success.taskId, + attachment: success.attachment)); + + if (currentOverlayContext != null && currentContext != null) { + _appToast.showToastWithIcon(currentOverlayContext!, + message: AppLocalizations.of(currentContext!).your_download_has_started, + iconColor: AppColor.primaryColor, + icon: imagePaths.icDownload); + } + } else if (success is DownloadingAttachmentForWeb) { + final percent = success.progress.round(); + log('EmailController::DownloadingAttachmentForWeb(): $percent%'); + + mailboxDashBoardController.updateDownloadTask( + success.taskId, + (currentTask) { + final newTask = currentTask.copyWith( + progress: success.progress, + downloaded: success.downloaded, + total: success.total); + + return newTask; + }); + } + }); + }); + } + void _getEmailContentAction(EmailId emailId) async { final accountId = mailboxDashBoardController.accountId.value; final baseDownloadUrl = mailboxDashBoardController.sessionCurrent?.getDownloadUrl(); @@ -150,6 +199,8 @@ class EmailController extends BaseController { _moveToMailboxSuccess(success); } else if (success is MarkAsStarEmailSuccess) { _markAsEmailStarSuccess(success); + } else if (success is DownloadAttachmentForWebSuccess) { + _downloadAttachmentForWebSuccessAction(success); } }); } @@ -346,17 +397,30 @@ class EmailController extends BaseController { void _downloadAttachmentForWebAction(BuildContext context, Attachment attachment) async { final accountId = mailboxDashBoardController.accountId.value; if (accountId != null && mailboxDashBoardController.sessionCurrent != null) { - _appToast.showToastWithIcon(context, - message: AppLocalizations.of(currentContext!).your_download_has_started, - iconColor: AppColor.primaryColor, - icon: imagePaths.icDownload); - final baseDownloadUrl = mailboxDashBoardController.sessionCurrent!.getDownloadUrl(); - consumeState(_downloadAttachmentForWebInteractor.execute(attachment, accountId, baseDownloadUrl)); + final generateTaskId = DownloadTaskId(_uuid.v4()); + consumeState(_downloadAttachmentForWebInteractor.execute( + generateTaskId, + attachment, + accountId, + baseDownloadUrl, + _downloadProgressStateController)); } } + void _downloadAttachmentForWebSuccessAction(DownloadAttachmentForWebSuccess success) { + log('EmailController::_downloadAttachmentForWebSuccessAction():'); + mailboxDashBoardController.deleteDownloadTask(success.taskId); + + _downloadManager.createAnchorElementDownloadFileWeb( + success.bytes, + success.attachment.generateFileName()); + } + void _downloadAttachmentForWebFailureAction(DownloadAttachmentForWebFailure failure) { + log('EmailController::_downloadAttachmentForWebFailureAction(): $failure'); + mailboxDashBoardController.deleteDownloadTask(failure.taskId); + if (currentOverlayContext != null && currentContext != null) { _appToast.showToastWithIcon(currentOverlayContext!, message: AppLocalizations.of(currentContext!).attachment_download_failed, diff --git a/lib/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller.dart b/lib/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller.dart index 1bd0470d4..bf3df7e17 100644 --- a/lib/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller.dart +++ b/lib/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller.dart @@ -38,8 +38,10 @@ import 'package:tmail_ui_user/features/mailbox_dashboard/domain/state/remove_ema import 'package:tmail_ui_user/features/mailbox/domain/usecases/mark_as_mailbox_read_interactor.dart'; import 'package:tmail_ui_user/features/mailbox_dashboard/domain/usecases/remove_email_drafts_interactor.dart'; import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/action/dashboard_action.dart'; +import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/controller/download/download_controller.dart'; import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/controller/search_controller.dart'; import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/model/composer_overlay_state.dart'; +import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/model/download/download_task_state.dart'; import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/model/search/quick_search_filter.dart'; import 'package:tmail_ui_user/features/manage_account/domain/usecases/log_out_oidc_interactor.dart'; import 'package:tmail_ui_user/features/manage_account/presentation/model/manage_account_arguments.dart'; @@ -63,6 +65,7 @@ class MailboxDashBoardController extends ReloadableController { final EmailReceiveManager _emailReceiveManager = Get.find(); final SearchController searchController = Get.find(); final Executor _isolateExecutor = Get.find(); + final DownloadController downloadController = Get.find(); final MoveToMailboxInteractor _moveToMailboxInteractor; final DeleteEmailPermanentlyInteractor _deleteEmailPermanentlyInteractor; @@ -570,6 +573,23 @@ class MailboxDashBoardController extends ReloadableController { Future> quickSearchEmails() => searchController.quickSearchEmails(accountId: accountId.value!); + void addDownloadTask(DownloadTaskState task) { + downloadController.addDownloadTask(task); + } + + void updateDownloadTask( + DownloadTaskId taskId, + UpdateDownloadTaskStateCallback updateDownloadTaskCallback + ) { + downloadController.updateDownloadTaskByTaskId( + taskId, + updateDownloadTaskCallback); + } + + void deleteDownloadTask(DownloadTaskId taskId) { + downloadController.deleteDownloadTask(taskId); + } + @override void onClose() { _emailReceiveManager.closeEmailReceiveManagerStream(); @@ -578,6 +598,7 @@ class MailboxDashBoardController extends ReloadableController { _connectivityStreamSubscription.cancel(); _progressStateController.close(); _isolateExecutor.dispose(); + Get.delete(); super.onClose(); } } \ No newline at end of file diff --git a/lib/features/mailbox_dashboard/presentation/mailbox_dashboard_view_web.dart b/lib/features/mailbox_dashboard/presentation/mailbox_dashboard_view_web.dart index 864f39f48..903616086 100644 --- a/lib/features/mailbox_dashboard/presentation/mailbox_dashboard_view_web.dart +++ b/lib/features/mailbox_dashboard/presentation/mailbox_dashboard_view_web.dart @@ -16,6 +16,7 @@ import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/model/comp import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/model/search/quick_search_filter.dart'; import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/widgets/advanced_search/advanced_search_filter_overlay.dart'; import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/widgets/advanced_search/icon_open_advanced_search_widget.dart'; +import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/widgets/download/download_task_item_widget.dart'; import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/widgets/email_quick_search_item_tile_widget.dart'; import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/widgets/recent_search_item_tile_widget.dart'; import 'package:tmail_ui_user/features/thread/domain/model/filter_message_option.dart'; @@ -140,6 +141,7 @@ class MailboxDashBoardView extends BaseMailboxDashBoardView { child: buildNetworkConnectionWidget(context)); } }), + _buildDownloadTaskStateWidget(), ]), ), ); @@ -644,4 +646,38 @@ class MailboxDashBoardView extends BaseMailboxDashBoardView { }); }); } + + Widget _buildDownloadTaskStateWidget() { + return Obx(() { + if (controller.downloadController.notEmptyListDownloadTask) { + final downloadTasks = controller.downloadController.listDownloadTaskState; + + return Align( + alignment: Alignment.bottomCenter, + child: Container( + color: AppColor.colorBackgroundSnackBar, + height: 60, + width: double.infinity, + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: EdgeInsets.zero, + itemCount: downloadTasks.length, + separatorBuilder: (context, index) => + const Padding( + padding: EdgeInsets.symmetric(vertical: 5), + child: VerticalDivider( + color: Colors.grey, + width: 2.5, + thickness: 0.2), + ), + itemBuilder: (context, index) => + DownloadTaskItemWidget(downloadTasks[index]) + ), + ), + ); + } else { + return const SizedBox.shrink(); + } + }); + } } \ No newline at end of file diff --git a/lib/features/mailbox_dashboard/presentation/widgets/download/download_task_item_widget.dart b/lib/features/mailbox_dashboard/presentation/widgets/download/download_task_item_widget.dart new file mode 100644 index 000000000..d5e7c19d0 --- /dev/null +++ b/lib/features/mailbox_dashboard/presentation/widgets/download/download_task_item_widget.dart @@ -0,0 +1,73 @@ + +import 'package:byte_converter/byte_converter.dart'; +import 'package:core/presentation/resources/image_paths.dart'; +import 'package:core/presentation/utils/style_utils.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:get/get.dart'; +import 'package:tmail_ui_user/features/base/mixin/app_loader_mixin.dart'; +import 'package:tmail_ui_user/features/email/presentation/extensions/attachment_extension.dart'; +import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/model/download/download_task_state.dart'; + +class DownloadTaskItemWidget extends StatelessWidget with AppLoaderMixin { + + final DownloadTaskState taskState; + + const DownloadTaskItemWidget(this.taskState, { + Key? key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final _imagePaths = Get.find(); + + return Container( + padding: EdgeInsets.zero, + alignment: Alignment.center, + width: 240, + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox(width: 12), + Container( + width: 30, + color: Colors.transparent, + height: 30, + child: Stack(alignment: Alignment.center, children: [ + SvgPicture.asset( + taskState.attachment.getIcon(_imagePaths), + width: 16, + height: 16, + fit: BoxFit.fill), + circularPercentLoadingWidget(taskState.percentDownloading) + ]), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(taskState.attachment.generateFileName(), + overflow: CommonTextStyle.defaultTextOverFlow, + softWrap: CommonTextStyle.defaultSoftWrap, + maxLines: 1, + style: const TextStyle(color: Colors.white, fontSize: 14)), + const SizedBox(height: 4), + Text( + '${ByteConverter(taskState.downloaded.toDouble()).toHumanReadable(SizeUnit.MB)}/' + '${ByteConverter(taskState.total.toDouble()).toHumanReadable(SizeUnit.MB)}', + softWrap: CommonTextStyle.defaultSoftWrap, + overflow: CommonTextStyle.defaultTextOverFlow, + maxLines: 1, + style: const TextStyle(color: Colors.white, fontSize: 12)) + ], + ), + ) + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/thread/presentation/thread_view.dart b/lib/features/thread/presentation/thread_view.dart index 88a1418d8..b59285cbb 100644 --- a/lib/features/thread/presentation/thread_view.dart +++ b/lib/features/thread/presentation/thread_view.dart @@ -589,7 +589,7 @@ class ThreadView extends GetWidget with AppLoaderMixin, double? _getItemExtent(BuildContext context) { if (BuildUtils.isWeb) { - return _responsiveUtils.isDesktop(context) ? 52 : 90; + return _responsiveUtils.isDesktop(context) ? 52 : 95; } else { return null; } diff --git a/lib/main/bindings/network/network_bindings.dart b/lib/main/bindings/network/network_bindings.dart index 6ecc50da1..a25fe8450 100644 --- a/lib/main/bindings/network/network_bindings.dart +++ b/lib/main/bindings/network/network_bindings.dart @@ -76,7 +76,8 @@ class NetworkBindings extends Bindings { Get.put(ThreadAPI(Get.find())); Get.put(EmailAPI( Get.find(), - Get.find())); + Get.find(), + Get.find())); Get.put(ManageAccountAPI(Get.find())); Get.put(ContactAPI(Get.find())); } diff --git a/model/lib/email/attachment.dart b/model/lib/email/attachment.dart index e766a6e82..444f8aaaa 100644 --- a/model/lib/email/attachment.dart +++ b/model/lib/email/attachment.dart @@ -42,6 +42,14 @@ class Attachment with EquatableMixin { return Uri.decodeFull(downloadUri); } + String generateFileName() { + if (name?.isNotEmpty == true) { + return name!; + } else { + return '${blobId?.value}.${type?.subtype}'; + } + } + @override List get props => [partId, blobId, size, name, type, cid, disposition]; }