diff --git a/lib/features/upload/presentation/controller/upload_controller.dart b/lib/features/upload/presentation/controller/upload_controller.dart new file mode 100644 index 000000000..5b2bb0a24 --- /dev/null +++ b/lib/features/upload/presentation/controller/upload_controller.dart @@ -0,0 +1,216 @@ + +import 'package:async/async.dart'; +import 'package:collection/collection.dart'; +import 'package:core/presentation/extensions/color_extension.dart'; +import 'package:core/presentation/resources/image_paths.dart'; +import 'package:core/presentation/state/failure.dart'; +import 'package:core/presentation/state/success.dart'; +import 'package:core/presentation/utils/app_toast.dart'; +import 'package:core/utils/app_logger.dart'; +import 'package:dartz/dartz.dart'; +import 'package:dio/dio.dart'; +import 'package:get/get.dart'; +import 'package:jmap_dart_client/jmap/mail/email/email_body_part.dart'; +import 'package:model/email/attachment.dart'; +import 'package:model/extensions/attachment_extension.dart'; +import 'package:model/extensions/list_attachment_extension.dart'; +import 'package:model/upload/file_info.dart'; +import 'package:tmail_ui_user/features/composer/domain/usecases/upload_attachment_interactor.dart'; +import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller.dart'; +import 'package:tmail_ui_user/features/upload/domain/model/upload_task_id.dart'; +import 'package:tmail_ui_user/features/upload/domain/state/attachment_upload_state.dart'; +import 'package:tmail_ui_user/features/upload/presentation/model/upload_file_state.dart'; +import 'package:tmail_ui_user/features/upload/presentation/model/upload_file_state_list.dart'; +import 'package:tmail_ui_user/features/upload/presentation/model/upload_file_status.dart'; +import 'package:tmail_ui_user/main/localizations/app_localizations.dart'; +import 'package:tmail_ui_user/main/routes/route_navigation.dart'; + +class UploadController extends GetxController { + + final _imagePaths = Get.find(); + final _appToast = Get.find(); + final _mailboxDashBoardController = Get.find(); + + final UploadAttachmentInteractor _uploadAttachmentInteractor; + + final listUploadAttachments = [].obs; + + final StreamGroup> _progressUploadStateStreamGroup + = StreamGroup>.broadcast(); + + final UploadFileStateList _uploadingStateFiles = UploadFileStateList(); + + UploadController(this._uploadAttachmentInteractor); + + @override + void onInit() { + _registerProgressUploadStateStream(); + super.onInit(); + } + + @override + void onClose() { + _uploadingStateFiles.clear(); + _progressUploadStateStreamGroup.close(); + super.onClose(); + } + + void _registerProgressUploadStateStream() { + _progressUploadStateStreamGroup.stream.listen(_handleProgressUploadStateStream); + } + + void _handleProgressUploadStateStream(Either uploadState) { + uploadState.fold( + (failure) { + log('UploadController::_handleProgressUploadStateStream():failure: $failure'); + if (failure is ErrorAttachmentUploadState) { + _uploadingStateFiles.updateElementByUploadTaskId( + failure.uploadId, + (currentState) => currentState?.copyWith(uploadStatus: UploadFileStatus.uploadFailed)); + deleteFileUploaded(failure.uploadId); + _handleUploadAttachmentsFailure(failure); + } + }, + (success) { + if (success is UploadingAttachmentUploadState) { + log('UploadController::_registerUploadAttachmentsState():uploading[${success.uploadId}]: ${success.progress}'); + _uploadingStateFiles.updateElementByUploadTaskId( + success.uploadId, + (currentState) { + if (currentState?.uploadStatus.completed ?? false) { + return currentState; + } else { + return currentState?.copyWith( + uploadingProgress: (success.progress * 100 / success.total).floor(), + uploadStatus: UploadFileStatus.uploading); + } + } + ); + + _refreshListUploadAttachmentState(); + } else if (success is SuccessAttachmentUploadState) { + log('UploadController::_handleProgressUploadStateStream():succeed[${success.uploadId}]'); + _uploadingStateFiles.updateElementByUploadTaskId( + success.uploadId, + (currentState) { + final newState = currentState?.copyWith( + uploadingProgress: 100, + uploadStatus: UploadFileStatus.succeed, + attachment: success.attachment + ); + return newState; + }, + ); + + _refreshListUploadAttachmentState(); + _handleUploadAttachmentsSuccess(success); + } + } + ); + } + + void initializeUploadAttachments(List attachments) { + listUploadAttachments.value = attachments + .map((attachment) => UploadFileState(UploadTaskId(attachment.blobId!.value))) + .toList(); + } + + void deleteFileUploaded(UploadTaskId uploadId) { + _uploadingStateFiles.deleteElementByUploadTaskId(uploadId); + _refreshListUploadAttachmentState(); + } + + Future justUploadAttachmentsAction(List uploadFiles, Uri uploadUri) async { + return Future.forEach(uploadFiles, (uploadFile) async { + await _uploadFile(uploadFile, uploadUri); + }); + } + + Future _uploadFile(FileInfo uploadFile, Uri uploadUri) async { + log('UploadAttachmentManager::_uploadFile(): ${uploadFile.fileName}'); + + final cancelToken = CancelToken(); + final uploadAttachment = _uploadAttachmentInteractor.execute( + uploadFile, uploadUri, + cancelToken: cancelToken); + + _uploadingStateFiles.add(UploadFileState( + uploadAttachment.uploadTaskId, + file: uploadFile, + cancelToken: cancelToken)); + + await _progressUploadStateStreamGroup.add(uploadAttachment.progressState); + + _refreshListUploadAttachmentState(); + } + + void _refreshListUploadAttachmentState() { + listUploadAttachments.value = + _uploadingStateFiles.uploadingStateFiles.whereNotNull().toList(); + listUploadAttachments.refresh(); + } + + List get attachmentsUploaded { + if (listUploadAttachments.isEmpty) { + return List.empty(); + } + return listUploadAttachments + .map((fileState) => fileState.attachment) + .whereNotNull() + .toList(); + } + + Set? generateAttachments() { + if (attachmentsUploaded.isEmpty) { + return null; + } + return attachmentsUploaded + .map((attachment) => attachment.toEmailBodyPart(ContentDisposition.attachment.value)) + .toSet(); + } + + void _handleUploadAttachmentsFailure(ErrorAttachmentUploadState failure) { + if (currentContext != null && currentOverlayContext != null) { + _appToast.showToastWithIcon(currentOverlayContext!, + message: AppLocalizations.of(currentContext!).can_not_upload_this_file_as_attachments, + textColor: AppColor.toastErrorBackgroundColor, + iconColor: AppColor.toastErrorBackgroundColor, + icon: _imagePaths.icAttachment); + } + } + + void _handleUploadAttachmentsSuccess(SuccessAttachmentUploadState success) { + if (currentContext != null && currentOverlayContext != null && _uploadingStateFiles.allSuccess) { + _appToast.showToastWithIcon(currentOverlayContext!, + message: AppLocalizations.of(currentContext!).attachments_uploaded_successfully, + iconColor: AppColor.primaryColor, + icon: _imagePaths.icAttachment); + } + } + + bool hasEnoughMaxAttachmentSize({List? listFiles}) { + final currentTotalAttachmentsSize = attachmentsUploaded.totalSize(); + log('ComposerController::_validateAttachmentsSize(): $currentTotalAttachmentsSize'); + num uploadedTotalSize = 0; + if (listFiles != null && listFiles.isNotEmpty) { + final uploadedListSize = listFiles.map((file) => file.fileSize).toList(); + uploadedTotalSize = uploadedListSize.reduce((sum, size) => sum + size); + log('ComposerController::_validateAttachmentsSize(): uploadedTotalSize: $uploadedTotalSize'); + } + + final totalSizeReadyToUpload = currentTotalAttachmentsSize + uploadedTotalSize; + log('ComposerController::_validateAttachmentsSize(): totalSizeReadyToUpload: $totalSizeReadyToUpload'); + + final maxSizeAttachmentsPerEmail = _mailboxDashBoardController.maxSizeAttachmentsPerEmail?.value; + if (maxSizeAttachmentsPerEmail != null) { + return totalSizeReadyToUpload <= maxSizeAttachmentsPerEmail; + } else { + return false; + } + } + + bool get allUploadAttachmentsCompleted { + return listUploadAttachments + .every((uploadFile) => uploadFile.uploadStatus.completed); + } +} \ No newline at end of file diff --git a/lib/features/upload/presentation/extensions/list_upload_file_state_extension.dart b/lib/features/upload/presentation/extensions/list_upload_file_state_extension.dart new file mode 100644 index 000000000..5c762120d --- /dev/null +++ b/lib/features/upload/presentation/extensions/list_upload_file_state_extension.dart @@ -0,0 +1,13 @@ +import 'package:tmail_ui_user/features/upload/presentation/model/upload_file_state.dart'; + +extension ListUploadFileStateExtension on List { + + num get totalSize { + if (isNotEmpty) { + final currentListSize = map((file) => file.fileSize).toList(); + final totalSize = currentListSize.reduce((sum, size) => sum + size); + return totalSize; + } + return 0; + } +} \ No newline at end of file diff --git a/lib/features/upload/presentation/model/upload_file_state.dart b/lib/features/upload/presentation/model/upload_file_state.dart new file mode 100644 index 000000000..17174f6d9 --- /dev/null +++ b/lib/features/upload/presentation/model/upload_file_state.dart @@ -0,0 +1,101 @@ + +import 'package:core/domain/extensions/media_type_extension.dart'; +import 'package:core/presentation/resources/image_paths.dart'; +import 'package:core/utils/app_logger.dart'; +import 'package:dio/dio.dart'; +import 'package:equatable/equatable.dart'; +import 'package:http_parser/http_parser.dart'; +import 'package:model/email/attachment.dart'; +import 'package:model/upload/file_info.dart'; +import 'package:tmail_ui_user/features/upload/domain/model/upload_task_id.dart'; +import 'package:tmail_ui_user/features/upload/presentation/model/upload_file_status.dart'; + +class UploadFileState with EquatableMixin { + + final FileInfo? file; + final UploadTaskId uploadTaskId; + final UploadFileStatus uploadStatus; + final int uploadingProgress; + final Attachment? attachment; + final CancelToken? cancelToken; + + UploadFileState(this.uploadTaskId, { + this.file, + this.uploadStatus = UploadFileStatus.waiting, + this.uploadingProgress = 0, + this.attachment, + this.cancelToken, + }); + + UploadFileState copyWith({ + UploadTaskId? uploadTaskId, + FileInfo? file, + UploadFileStatus? uploadStatus, + int? uploadingProgress, + Attachment? attachment, + CancelToken? cancelToken + }) { + return UploadFileState( + uploadTaskId ?? this.uploadTaskId, + file: file ?? this.file, + uploadStatus: uploadStatus ?? this.uploadStatus, + uploadingProgress: uploadingProgress ?? this.uploadingProgress, + attachment: attachment ?? this.attachment, + cancelToken: cancelToken ?? this.cancelToken + ); + } + + num get fileSize { + if (attachment != null) { + return attachment?.size?.value ?? 0; + } else if (file != null) { + return file?.fileSize ?? 0; + } + return 0; + } + + String get fileName { + if (attachment != null) { + return attachment?.name ?? ''; + } else if (file != null) { + return file?.fileName ?? ''; + } + return ''; + } + + double get percentUploading => uploadingProgress / 100; + + String getIcon(ImagePaths imagePaths) { + var mediaType = attachment?.type; + if (mediaType == null && file != null) { + mediaType = MediaType.parse(file!.mimeType); + } + log('AttachmentExtension::getIcon(): mediaType: $mediaType'); + if (mediaType == null) { + return imagePaths.icFileEPup; + } + if (mediaType.isDocFile()) { + return imagePaths.icFileDocx; + } else if (mediaType.isExcelFile()) { + return imagePaths.icFileXlsx; + } else if (mediaType.isPowerPointFile()) { + return imagePaths.icFilePptx; + } else if (mediaType.isPdfFile()) { + return imagePaths.icFilePdf; + } else if (mediaType.isZipFile()) { + return imagePaths.icFileZip; + } else if (mediaType.isImageFile()) { + return imagePaths.icFilePng; + } + return imagePaths.icFileEPup; + } + + @override + List get props => [ + uploadTaskId, + file, + uploadStatus, + uploadingProgress, + attachment + ]; +} diff --git a/lib/features/upload/presentation/model/upload_file_state_list.dart b/lib/features/upload/presentation/model/upload_file_state_list.dart new file mode 100644 index 000000000..13ff6ef5b --- /dev/null +++ b/lib/features/upload/presentation/model/upload_file_state_list.dart @@ -0,0 +1,64 @@ + +import 'package:collection/collection.dart'; +import 'package:tmail_ui_user/features/upload/domain/model/upload_task_id.dart'; +import 'package:tmail_ui_user/features/upload/presentation/model/upload_file_state.dart'; +import 'package:tmail_ui_user/features/upload/presentation/model/upload_file_status.dart'; + +typedef UpdateFileUploadingState = UploadFileState? Function(UploadFileState? currentState); +typedef MatchedState = bool Function(UploadFileState? state); + +class UploadFileStateList { + + final List _uploadingStateFiles = []; + + List get uploadingStateFiles => _uploadingStateFiles.toList(growable: false); + + UploadFileStateList add(UploadFileState element) { + _uploadingStateFiles.add(element); + return this; + } + + UploadFileStateList updateElementBy( + MatchedState matchedState, + UpdateFileUploadingState updateFileUploadingState, + ) { + final matchIndex = _uploadingStateFiles.indexWhere((element) => matchedState(element)); + if (matchIndex >= 0) { + _uploadingStateFiles[matchIndex] = updateFileUploadingState(_uploadingStateFiles[matchIndex]); + } + return this; + } + + UploadFileStateList updateElementByUploadTaskId( + UploadTaskId uploadTaskId, + UpdateFileUploadingState updateFileUploadingState, + ) { + return updateElementBy( + (state) => state?.uploadTaskId == uploadTaskId, + updateFileUploadingState + ); + } + + bool get allSuccess { + if (_uploadingStateFiles.isEmpty) { + return false; + } + return _uploadingStateFiles + .whereNotNull() + .every((file) => file.uploadStatus == UploadFileStatus.succeed); + } + + void clear() { + _uploadingStateFiles.clear(); + } + + void deleteElementByUploadTaskId(UploadTaskId uploadTaskId) { + final fileState = _uploadingStateFiles + .firstWhereOrNull((fileState) => fileState?.uploadTaskId == uploadTaskId); + + if (fileState != null) { + fileState.cancelToken?.cancel(); + _uploadingStateFiles.remove(fileState); + } + } +} \ No newline at end of file diff --git a/lib/features/upload/presentation/model/upload_file_status.dart b/lib/features/upload/presentation/model/upload_file_status.dart new file mode 100644 index 000000000..645a1ae1d --- /dev/null +++ b/lib/features/upload/presentation/model/upload_file_status.dart @@ -0,0 +1,12 @@ + +enum UploadFileStatus { + waiting, + uploading, + uploadFailed, + succeed +} + +extension UploadFileStatusExtension on UploadFileStatus { + bool get completed => + this == UploadFileStatus.uploadFailed || this == UploadFileStatus.succeed; +} \ No newline at end of file