diff --git a/core/lib/core.dart b/core/lib/core.dart index 944f522d6..bd90cff75 100644 --- a/core/lib/core.dart +++ b/core/lib/core.dart @@ -6,6 +6,7 @@ export 'presentation/extensions/url_extension.dart'; export 'presentation/extensions/uri_extension.dart'; export 'presentation/extensions/capitalize_extension.dart'; export 'presentation/extensions/list_extensions.dart'; +export 'presentation/extensions/list_nullable_extensions.dart'; export 'domain/extensions/datetime_extension.dart'; export 'presentation/extensions/html_extension.dart'; export 'presentation/extensions/compare_string_extension.dart'; diff --git a/core/lib/presentation/extensions/list_nullable_extensions.dart b/core/lib/presentation/extensions/list_nullable_extensions.dart new file mode 100644 index 000000000..82e9477a8 --- /dev/null +++ b/core/lib/presentation/extensions/list_nullable_extensions.dart @@ -0,0 +1,4 @@ + +extension ListNullableExtensions on List? { + bool get validateFilesTransfer => this?.any((type) => type == 'Files') ?? false; +} \ No newline at end of file diff --git a/lib/features/composer/presentation/composer_controller.dart b/lib/features/composer/presentation/composer_controller.dart index f873f8048..e016aae3e 100644 --- a/lib/features/composer/presentation/composer_controller.dart +++ b/lib/features/composer/presentation/composer_controller.dart @@ -385,7 +385,7 @@ class ComposerController extends BaseController with DragDropFileMixin implement _subscriptionOnDragEnter = html.window.onDragEnter.listen((event) { event.preventDefault(); - if (_validateFilesTransfer(event.dataTransfer.types)) { + if (event.dataTransfer.types.validateFilesTransfer) { mailboxDashBoardController.localFileDraggableAppState.value = DraggableAppState.active; } }); @@ -393,7 +393,7 @@ class ComposerController extends BaseController with DragDropFileMixin implement _subscriptionOnDragOver = html.window.onDragOver.listen((event) { event.preventDefault(); - if (_validateFilesTransfer(event.dataTransfer.types)) { + if (event.dataTransfer.types.validateFilesTransfer) { mailboxDashBoardController.localFileDraggableAppState.value = DraggableAppState.active; } }); @@ -401,7 +401,7 @@ class ComposerController extends BaseController with DragDropFileMixin implement _subscriptionOnDragLeave = html.window.onDragLeave.listen((event) { event.preventDefault(); - if (_validateFilesTransfer(event.dataTransfer.types)) { + if (event.dataTransfer.types.validateFilesTransfer) { mailboxDashBoardController.localFileDraggableAppState.value = DraggableAppState.inActive; } }); @@ -409,16 +409,12 @@ class ComposerController extends BaseController with DragDropFileMixin implement _subscriptionOnDrop = html.window.onDrop.listen((event) { event.preventDefault(); - if (_validateFilesTransfer(event.dataTransfer.types)) { + if (event.dataTransfer.types.validateFilesTransfer) { mailboxDashBoardController.localFileDraggableAppState.value = DraggableAppState.inActive; } }); } - bool _validateFilesTransfer(List? types) { - return types?.any((type) => type == 'Files') ?? false; - } - Future _saveComposerCacheOnWebAction() async { _autoCreateEmailTag(); @@ -2130,7 +2126,7 @@ class ComposerController extends BaseController with DragDropFileMixin implement } void handleOnDragEnterHtmlEditorWeb(List? types) { - if (_validateFilesTransfer(types)) { + if (types.validateFilesTransfer) { mailboxDashBoardController.localFileDraggableAppState.value = DraggableAppState.active; } } diff --git a/lib/features/composer/presentation/widgets/web/local_file_drop_zone_widget.dart b/lib/features/composer/presentation/widgets/web/local_file_drop_zone_widget.dart index 058e4a7a9..dc5111a19 100644 --- a/lib/features/composer/presentation/widgets/web/local_file_drop_zone_widget.dart +++ b/lib/features/composer/presentation/widgets/web/local_file_drop_zone_widget.dart @@ -14,6 +14,7 @@ class LocalFileDropZoneWidget extends StatelessWidget { final ImagePaths imagePaths; final double? width; final double? height; + final EdgeInsetsGeometry margin; final OnLocalFileDropZoneListener? onLocalFileDropZoneListener; const LocalFileDropZoneWidget({ @@ -21,6 +22,7 @@ class LocalFileDropZoneWidget extends StatelessWidget { required this.imagePaths, this.width, this.height, + this.margin = DropZoneWidgetStyle.margin, this.onLocalFileDropZoneListener }); @@ -32,7 +34,7 @@ class LocalFileDropZoneWidget extends StatelessWidget { width: width, height: height, child: Padding( - padding: DropZoneWidgetStyle.margin, + padding: margin, child: DottedBorder( borderType: BorderType.RRect, radius: const Radius.circular(DropZoneWidgetStyle.radius), @@ -52,7 +54,7 @@ class LocalFileDropZoneWidget extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - SvgPicture.asset(imagePaths.icDropZoneIcon), + Flexible(child: SvgPicture.asset(imagePaths.icDropZoneIcon)), const SizedBox(height: DropZoneWidgetStyle.space), Text( AppLocalizations.of(context).dropFileHereToAttachThem, diff --git a/lib/features/identity_creator/presentation/identity_creator_controller.dart b/lib/features/identity_creator/presentation/identity_creator_controller.dart index b24dea4a4..e36b6dacc 100644 --- a/lib/features/identity_creator/presentation/identity_creator_controller.dart +++ b/lib/features/identity_creator/presentation/identity_creator_controller.dart @@ -3,12 +3,15 @@ import 'dart:io'; import 'dart:math' as math; import 'dart:typed_data'; +import 'package:collection/collection.dart'; +import 'package:core/presentation/extensions/list_nullable_extensions.dart'; import 'package:core/presentation/state/failure.dart'; import 'package:core/presentation/state/success.dart'; import 'package:core/presentation/utils/keyboard_utils.dart'; import 'package:core/utils/app_logger.dart'; import 'package:core/utils/file_utils.dart'; import 'package:core/utils/platform_info.dart'; +import 'package:desktop_drop/desktop_drop.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/scheduler.dart'; @@ -32,7 +35,7 @@ import 'package:rich_text_composer/views/commons/constants.dart'; import 'package:tmail_ui_user/features/base/base_controller.dart'; import 'package:tmail_ui_user/features/composer/presentation/controller/rich_text_mobile_tablet_controller.dart'; import 'package:tmail_ui_user/features/composer/presentation/controller/rich_text_web_controller.dart'; -import 'package:tmail_ui_user/features/identity_creator/presentation/extesions/size_extension.dart'; +import 'package:tmail_ui_user/features/composer/presentation/mixin/drag_drog_file_mixin.dart'; import 'package:tmail_ui_user/features/identity_creator/presentation/model/identity_creator_arguments.dart'; import 'package:tmail_ui_user/features/identity_creator/presentation/utils/identity_creator_constants.dart'; import 'package:tmail_ui_user/features/mailbox_creator/domain/model/verification/email_address_validator.dart'; @@ -40,6 +43,7 @@ import 'package:tmail_ui_user/features/mailbox_creator/domain/model/verification import 'package:tmail_ui_user/features/mailbox_creator/domain/state/verify_name_view_state.dart'; import 'package:tmail_ui_user/features/mailbox_creator/domain/usecases/verify_name_interactor.dart'; import 'package:tmail_ui_user/features/mailbox_creator/presentation/extensions/validator_failure_extension.dart'; +import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/model/draggable_app_state.dart'; import 'package:tmail_ui_user/features/manage_account/domain/model/create_new_identity_request.dart'; import 'package:tmail_ui_user/features/manage_account/domain/model/edit_identity_request.dart'; import 'package:tmail_ui_user/features/manage_account/domain/state/get_all_identities_state.dart'; @@ -51,12 +55,16 @@ import 'package:tmail_ui_user/features/public_asset/domain/model/public_assets_i import 'package:tmail_ui_user/features/public_asset/presentation/model/public_asset_arguments.dart'; import 'package:tmail_ui_user/features/public_asset/presentation/public_asset_bindings.dart'; import 'package:tmail_ui_user/features/public_asset/presentation/public_asset_controller.dart'; +import 'package:tmail_ui_user/features/upload/domain/extensions/file_info_extension.dart'; +import 'package:tmail_ui_user/features/upload/domain/extensions/list_file_info_extension.dart'; +import 'package:tmail_ui_user/features/upload/domain/extensions/list_platform_file_extensions.dart'; import 'package:tmail_ui_user/main/bindings/network/binding_tag.dart'; import 'package:tmail_ui_user/main/error/capability_validator.dart'; import 'package:tmail_ui_user/main/localizations/app_localizations.dart'; import 'package:tmail_ui_user/main/routes/route_navigation.dart'; +import 'package:tmail_ui_user/main/universal_import/html_stub.dart' as html hide File; -class IdentityCreatorController extends BaseController { +class IdentityCreatorController extends BaseController with DragDropFileMixin { final VerifyNameInteractor _verifyNameInteractor; final GetAllIdentitiesInteractor _getAllIdentitiesInteractor; @@ -74,6 +82,7 @@ class IdentityCreatorController extends BaseController { final isDefaultIdentity = RxBool(false); final isDefaultIdentitySupported = RxBool(false); final isCompressingInlineImage = RxBool(false); + final draggableAppState = DraggableAppState.inActive.obs; final TextEditingController inputNameIdentityController = TextEditingController(); final TextEditingController inputBccIdentityController = TextEditingController(); @@ -83,6 +92,10 @@ class IdentityCreatorController extends BaseController { RichTextMobileTabletController? richTextMobileTabletController; RichTextWebController? richTextWebController; + StreamSubscription? _subscriptionOnDragEnter; + StreamSubscription? _subscriptionOnDragOver; + StreamSubscription? _subscriptionOnDragLeave; + StreamSubscription? _subscriptionOnDrop; String? _nameIdentity; String? _contentHtmlEditor; @@ -103,14 +116,23 @@ class IdentityCreatorController extends BaseController { void updateContentHtmlEditor(String? text) => _contentHtmlEditor = text; - String? get contentHtmlEditor { + String get contentHtmlEditor { if (_contentHtmlEditor != null) { - return _contentHtmlEditor; + return _contentHtmlEditor ?? ''; } else { - return arguments?.identity?.signatureAsString; + return arguments?.identity?.signatureAsString ?? ''; } } + int get maxSizeUploadByBytes { + if (session == null || accountId == null) return 0; + + return session!.getCapabilityProperties( + accountId!, + CapabilityIdentifier.jmapCore + )?.maxSizeUpload?.value.toInt() ?? 0; + } + IdentityCreatorController( this._verifyNameInteractor, this._getAllIdentitiesInteractor, @@ -138,10 +160,14 @@ class IdentityCreatorController extends BaseController { session = arguments!.session; identity = arguments!.identity; actionType.value = arguments!.actionType; + if (actionType.value == IdentityActionType.create) { + isLoadSignatureCompleted = true; + } _checkDefaultIdentityIsSupported(); _checkPublicAssetCapability(); _setUpValueFromIdentity(); _getAllIdentities(); + _triggerBrowserEventListener(); } } @@ -152,6 +178,46 @@ class IdentityCreatorController extends BaseController { } } + void _triggerBrowserEventListener() { + _subscriptionOnDragEnter = html.window.onDragEnter.listen((event) { + event.preventDefault(); + + if (event.dataTransfer.types.validateFilesTransfer) { + draggableAppState.value = DraggableAppState.active; + } + }); + + _subscriptionOnDragOver = html.window.onDragOver.listen((event) { + event.preventDefault(); + + if (event.dataTransfer.types.validateFilesTransfer) { + draggableAppState.value = DraggableAppState.active; + } + }); + + _subscriptionOnDragLeave = html.window.onDragLeave.listen((event) { + event.preventDefault(); + + if (event.dataTransfer.types.validateFilesTransfer) { + draggableAppState.value = DraggableAppState.inActive; + } + }); + + _subscriptionOnDrop = html.window.onDrop.listen((event) { + event.preventDefault(); + + if (event.dataTransfer.types.validateFilesTransfer) { + draggableAppState.value = DraggableAppState.inActive; + } + }); + } + + void handleOnDragEnterSignatureEditorWeb(List? types) { + if (types.validateFilesTransfer) { + draggableAppState.value = DraggableAppState.active; + } + } + @override void onClose() { log('IdentityCreatorController::onClose():'); @@ -170,6 +236,10 @@ class IdentityCreatorController extends BaseController { } Get.delete(tag: BindingTag.publicAssetBindingsTag); publicAssetController = null; + _subscriptionOnDragEnter?.cancel(); + _subscriptionOnDragOver?.cancel(); + _subscriptionOnDragLeave?.cancel(); + _subscriptionOnDrop?.cancel(); super.onClose(); } @@ -177,7 +247,7 @@ class IdentityCreatorController extends BaseController { void handleSuccessViewState(Success success) { super.handleSuccessViewState(success); if (success is GetAllIdentitiesSuccess) { - _getALlIdentitiesSuccess(success); + _getAllIdentitiesSuccess(success); } } @@ -185,7 +255,7 @@ class IdentityCreatorController extends BaseController { void handleFailureViewState(Failure failure) { super.handleFailureViewState(failure); if (failure is GetAllIdentitiesFailure) { - _getALlIdentitiesFailure(failure); + _getAllIdentitiesFailure(failure); } } @@ -218,7 +288,7 @@ class IdentityCreatorController extends BaseController { } } - void _getALlIdentitiesSuccess(GetAllIdentitiesSuccess success) { + void _getAllIdentitiesSuccess(GetAllIdentitiesSuccess success) { if (success.identities?.isNotEmpty == true) { listEmailAddressDefault.value = success.identities! .map((identity) => identity.toEmailAddressNoName()) @@ -236,7 +306,7 @@ class IdentityCreatorController extends BaseController { } } - void _getALlIdentitiesFailure(GetAllIdentitiesFailure failure) { + void _getAllIdentitiesFailure(GetAllIdentitiesFailure failure) { _setDefaultEmailAddressList(); } @@ -339,11 +409,11 @@ class IdentityCreatorController extends BaseController { bccOfIdentity.value = newEmailAddress; } - Future _getSignatureHtmlText() async { + Future _getSignatureHtmlText() async { if (PlatformInfo.isWeb) { - return richTextWebController?.editorController.getText(); + return (await richTextWebController?.editorController.getText()) ?? ''; } else { - return richTextMobileTabletController?.richTextController.htmlEditorApi?.getText(); + return (await richTextMobileTabletController?.richTextController.htmlEditorApi?.getText()) ?? ''; } } @@ -380,7 +450,7 @@ class IdentityCreatorController extends BaseController { : null; final publicAssetsInIdentityArguments = PublicAssetsInIdentityArguments( - htmlSignature: signatureHtmlText ?? '', + htmlSignature: signatureHtmlText, preExistingPublicAssetIds: List.from(publicAssetController?.preExistingPublicAssetIds ?? []), newlyPickedPublicAssetIds: List.from(publicAssetController?.newlyPickedPublicAssetIds ?? []), ); @@ -390,7 +460,7 @@ class IdentityCreatorController extends BaseController { email: emailOfIdentity.value?.email, replyTo: replyToAddress, bcc: bccAddress, - htmlSignature: Signature(signatureHtmlText ?? ''), + htmlSignature: Signature(signatureHtmlText), sortOrder: sortOrder); final generateCreateId = Id(uuid.v1()); @@ -470,6 +540,8 @@ class IdentityCreatorController extends BaseController { } void clearFocusEditor(BuildContext context) { + inputNameIdentityFocusNode.unfocus(); + inputBccIdentityFocusNode.unfocus(); if (PlatformInfo.isMobile) { richTextMobileTabletController?.richTextController.htmlEditorApi?.unfocus(); } @@ -549,47 +621,31 @@ class IdentityCreatorController extends BaseController { logError("IdentityCreatorController::pickImage: context is unmounted"); } } - - bool _isExceedMaxSizeInlineImage(int fileSize) => - fileSize > IdentityCreatorConstants.maxKBSizeIdentityInlineImage.toBytes; bool _isExceedMaxUploadSize(int fileSize) { - final coreCapability = session?.getCapabilityProperties( - accountId!, - CapabilityIdentifier.jmapCore - ); - - int maxUploadSize = coreCapability?.maxSizeUpload?.value.toInt() ?? 0; - - return fileSize > maxUploadSize; + return fileSize > maxSizeUploadByBytes; } - void _insertInlineImage( + Future _insertInlineImage( BuildContext context, PlatformFile platformFile, - int maxWidth + int maxWidth, + {PlatformFile? compressedFile} ) async { - final PlatformFile file; - try { - isCompressingInlineImage.value = true; - file = await _compressImage(platformFile, maxWidth); - isCompressingInlineImage.value = false; - } catch (e) { - logError("IdentityCreatorController::_insertInlineImage: compress image error: $e"); - isCompressingInlineImage.value = false; - if (context.mounted) { - appToast.showToastErrorMessage( - context, - AppLocalizations.of(context).cannotCompressInlineImage); - } - return; + final PlatformFile? file; + if (compressedFile != null) { + file = compressedFile; + } else { + file = await _compressFileAction(context, originalFile: platformFile, maxWidth: maxWidth); } - if (_isExceedMaxSizeInlineImage(file.size) || _isExceedMaxUploadSize(file.size)) { + if (file == null) return; + + if (_isExceedMaxUploadSize(file.size)) { if (context.mounted) { appToast.showToastErrorMessage( context, AppLocalizations.of(context).pleaseChooseAnImageSizeCorrectly( - IdentityCreatorConstants.maxKBSizeIdentityInlineImage + maxSizeUploadByBytes ) ); } else { @@ -613,8 +669,32 @@ class IdentityCreatorController extends BaseController { } } + Future _compressFileAction( + BuildContext context, + { + required PlatformFile originalFile, + required int maxWidth + } + ) async { + try { + isCompressingInlineImage.value = true; + final compressedFile = await _compressImage(originalFile, maxWidth); + isCompressingInlineImage.value = false; + return compressedFile; + } catch (e) { + logError("$runtimeType::_compressFileAction: compress image error: $e"); + isCompressingInlineImage.value = false; + if (context.mounted) { + appToast.showToastErrorMessage( + context, + AppLocalizations.of(context).cannotCompressInlineImage); + } + return null; + } + } + Future _compressImage(PlatformFile originalFile, int maxWidthCompressedImage) async { - if (originalFile.size <= IdentityCreatorConstants.maxKBSizeIdentityInlineImage.toBytes) { + if (originalFile.size <= maxSizeUploadByBytes) { return originalFile; } @@ -701,4 +781,58 @@ class IdentityCreatorController extends BaseController { } }); } + + void onLocalFileDropZoneListener({ + required BuildContext context, + required DropDoneDetails details, + required double maxWidth + }) async { + try { + clearFocusEditor(context); + + final listFileInfo = await onDragDone(context: context, details: details); + final listImages = listFileInfo.listInlineFiles; + + if (listImages.isEmpty && listFileInfo.isNotEmpty && context.mounted) { + appToast.showToastErrorMessage( + context, + AppLocalizations.of(context).canNotUploadFileToSignature + ); + return; + } + + final listCompressedImages = await Future.wait( + listImages.map((fileInfo) => _compressFileAction( + context, + originalFile: fileInfo.toPlatformFile(), + maxWidth: maxWidth.toInt())) + ).then((listPlatformFiles) => listPlatformFiles.whereNotNull().toList()); + + if (_isExceedMaxUploadSize(listCompressedImages.totalFilesSize)) { + if (context.mounted) { + appToast.showToastErrorMessage( + context, + AppLocalizations.of(context).pleaseChooseAnImageSizeCorrectly( + maxSizeUploadByBytes + ) + ); + } else { + logError("IdentityCreatorController::onLocalFileDropZoneListener: context is unmounted"); + } + return; + } + + await Future.forEach( + listCompressedImages, + (platformFile) => _insertInlineImage( + context, + platformFile, + maxWidth.toInt(), + compressedFile: platformFile + ) + ); + } catch (e) { + logError("IdentityCreatorController::onLocalFileDropZoneListener: error: $e"); + } + } } \ No newline at end of file diff --git a/lib/features/identity_creator/presentation/identity_creator_view.dart b/lib/features/identity_creator/presentation/identity_creator_view.dart index fde22bad2..132782401 100644 --- a/lib/features/identity_creator/presentation/identity_creator_view.dart +++ b/lib/features/identity_creator/presentation/identity_creator_view.dart @@ -17,14 +17,16 @@ import 'package:pointer_interceptor/pointer_interceptor.dart'; import 'package:rich_text_composer/rich_text_composer.dart'; import 'package:rich_text_composer/views/widgets/rich_text_keyboard_toolbar.dart'; import 'package:tmail_ui_user/features/composer/presentation/mixin/rich_text_button_mixin.dart'; +import 'package:tmail_ui_user/features/composer/presentation/widgets/web/local_file_drop_zone_widget.dart'; import 'package:tmail_ui_user/features/composer/presentation/widgets/web/toolbar_rich_text_builder.dart'; import 'package:tmail_ui_user/features/identity_creator/presentation/identity_creator_controller.dart'; -import 'package:tmail_ui_user/features/identity_creator/presentation/widgets/compress_image_loading_bar_widget.dart'; import 'package:tmail_ui_user/features/identity_creator/presentation/widgets/identity_drop_list_field_builder.dart'; import 'package:tmail_ui_user/features/identity_creator/presentation/widgets/identity_field_no_editable_builder.dart'; import 'package:tmail_ui_user/features/identity_creator/presentation/widgets/identity_input_field_builder.dart'; import 'package:tmail_ui_user/features/identity_creator/presentation/widgets/identity_input_with_drop_list_field_builder.dart'; +import 'package:tmail_ui_user/features/identity_creator/presentation/widgets/insert_image_loading_indicator.dart'; import 'package:tmail_ui_user/features/identity_creator/presentation/widgets/set_default_identity_checkbox_builder.dart'; +import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/model/draggable_app_state.dart'; import 'package:tmail_ui_user/features/manage_account/domain/state/get_all_identities_state.dart'; import 'package:tmail_ui_user/features/manage_account/presentation/model/identity_action_type.dart'; import 'package:tmail_ui_user/main/localizations/app_localizations.dart'; @@ -40,87 +42,126 @@ class IdentityCreatorView extends GetWidget @override Widget build(BuildContext context) { - Widget bodyCreatorView = SingleChildScrollView( - controller: controller.scrollController, - physics: const ClampingScrollPhysics(), - child: Padding( - padding: const EdgeInsetsDirectional.symmetric(vertical: 12, horizontal: 24), - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Obx(() => IdentityInputFieldBuilder( - AppLocalizations.of(context).name, - controller.errorNameIdentity.value, - AppLocalizations.of(context).required, - editingController: controller.inputNameIdentityController, - focusNode: controller.inputNameIdentityFocusNode, - isMandatory: true, - onChangeInputNameAction: (value) => controller.updateNameIdentity(context, value) - )), - const SizedBox(height: 24), - Obx(() { - if (controller.actionType.value == IdentityActionType.create) { - return IdentityDropListFieldBuilder( - controller.imagePaths, - AppLocalizations.of(context).email.inCaps, - controller.emailOfIdentity.value, - controller.listEmailAddressDefault, - onSelectItemDropList: (emailAddress) => controller.updateEmailOfIdentity(context, emailAddress) - ); - } else { - return IdentityFieldNoEditableBuilder( - AppLocalizations.of(context).email.inCaps, - controller.emailOfIdentity.value - ); - } - }), - const SizedBox(height: 24), - Obx(() => IdentityDropListFieldBuilder( - controller.imagePaths, - AppLocalizations.of(context).reply_to, - controller.replyToOfIdentity.value, - controller.listEmailAddressOfReplyTo, - onSelectItemDropList: (emailAddress) => controller.updaterReplyToOfIdentity(context, emailAddress) - )), - const SizedBox(height: 24), - Obx(() => IdentityInputWithDropListFieldBuilder( - AppLocalizations.of(context).bcc_to, - controller.errorBccIdentity.value, - controller.inputBccIdentityController, - focusNode: controller.inputBccIdentityFocusNode, - onSelectedSuggestionAction: (newEmailAddress) { - controller.inputBccIdentityController.text = newEmailAddress?.email ?? ''; - controller.updateBccOfIdentity(newEmailAddress); - }, - onChangeInputSuggestionAction: (pattern) { - controller.validateInputBccAddress(context, pattern); - if (pattern == null || pattern.trim().isEmpty) { - controller.updateBccOfIdentity(null); + Widget bodyCreatorView = NotificationListener( + onNotification: (scrollInfo) { + if (scrollInfo is ScrollEndNotification && + scrollInfo.metrics.pixels == scrollInfo.metrics.maxScrollExtent) { + controller.clearFocusEditor(context); + } + return false; + }, + child: SingleChildScrollView( + controller: controller.scrollController, + physics: const ClampingScrollPhysics(), + child: Padding( + padding: const EdgeInsetsDirectional.symmetric(vertical: 12, horizontal: 24), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Obx(() => IdentityInputFieldBuilder( + AppLocalizations.of(context).name, + controller.errorNameIdentity.value, + AppLocalizations.of(context).required, + editingController: controller.inputNameIdentityController, + focusNode: controller.inputNameIdentityFocusNode, + isMandatory: true, + onChangeInputNameAction: (value) => controller.updateNameIdentity(context, value) + )), + const SizedBox(height: 24), + Obx(() { + if (controller.actionType.value == IdentityActionType.create) { + return IdentityDropListFieldBuilder( + controller.imagePaths, + AppLocalizations.of(context).email.inCaps, + controller.emailOfIdentity.value, + controller.listEmailAddressDefault, + onSelectItemDropList: (emailAddress) => controller.updateEmailOfIdentity(context, emailAddress) + ); } else { - controller.updateBccOfIdentity(EmailAddress(null, pattern)); + return IdentityFieldNoEditableBuilder( + AppLocalizations.of(context).email.inCaps, + controller.emailOfIdentity.value + ); } - }, - onSuggestionCallbackAction: controller.getSuggestionEmailAddress - )), - const SizedBox(height: 32), - Text(AppLocalizations.of(context).signature, - style: const TextStyle( - fontWeight: FontWeight.normal, - fontSize: 14, - color: AppColor.colorContentEmail, + }), + const SizedBox(height: 24), + Obx(() => IdentityDropListFieldBuilder( + controller.imagePaths, + AppLocalizations.of(context).reply_to, + controller.replyToOfIdentity.value, + controller.listEmailAddressOfReplyTo, + onSelectItemDropList: (emailAddress) => controller.updaterReplyToOfIdentity(context, emailAddress) + )), + const SizedBox(height: 24), + Obx(() => IdentityInputWithDropListFieldBuilder( + AppLocalizations.of(context).bcc_to, + controller.errorBccIdentity.value, + controller.inputBccIdentityController, + focusNode: controller.inputBccIdentityFocusNode, + onSelectedSuggestionAction: (newEmailAddress) { + controller.inputBccIdentityController.text = newEmailAddress?.email ?? ''; + controller.updateBccOfIdentity(newEmailAddress); + }, + onChangeInputSuggestionAction: (pattern) { + controller.validateInputBccAddress(context, pattern); + if (pattern == null || pattern.trim().isEmpty) { + controller.updateBccOfIdentity(null); + } else { + controller.updateBccOfIdentity(EmailAddress(null, pattern)); + } + }, + onSuggestionCallbackAction: controller.getSuggestionEmailAddress + )), + const SizedBox(height: 32), + Text(AppLocalizations.of(context).signature, + style: const TextStyle( + fontWeight: FontWeight.normal, + fontSize: 14, + color: AppColor.colorContentEmail, + ), ), - ), - const SizedBox(height: 8), - Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - border: Border.all(color: AppColor.colorInputBorderCreateMailbox), + const SizedBox(height: 8), + LayoutBuilder( + builder: (context, constraintsEditor) { + return Stack( + children: [ + Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColor.colorInputBorderCreateMailbox), + ), + padding: const EdgeInsetsDirectional.all(16), + child: _buildSignatureHtmlTemplate(context), + ), + Obx(() { + if (controller.draggableAppState.value == DraggableAppState.inActive) { + return const SizedBox.shrink(); + } + + return Positioned.fill( + child: PointerInterceptor( + child: LocalFileDropZoneWidget( + imagePaths: controller.imagePaths, + width: constraintsEditor.maxWidth, + height: constraintsEditor.maxHeight, + margin: EdgeInsets.zero, + onLocalFileDropZoneListener: (details) => + controller.onLocalFileDropZoneListener( + context: context, + details: details, + maxWidth: constraintsEditor.maxWidth, + ), + ) + ), + ); + }) + ], + ); + } ), - padding: const EdgeInsetsDirectional.all(16), - child: _buildSignatureHtmlTemplate(context), - ), - const SizedBox(height: 12), - if (controller.isMobile(context)) - _buildActionButtonMobile(context) - ]), + const SizedBox(height: 12), + if (controller.isMobile(context)) + _buildActionButtonMobile(context) + ]), + ), ), ); @@ -400,13 +441,9 @@ class IdentityCreatorView extends GetWidget } Widget _buildSignatureHtmlTemplate(BuildContext context) { - final htmlEditor = PlatformInfo.isWeb - ? _buildHtmlEditorWeb(context, controller.contentHtmlEditor ?? '') - : _buildHtmlEditor(context, initialContent: controller.contentHtmlEditor ?? ''); - - return Column( - children: [ - if (PlatformInfo.isWeb) + if (PlatformInfo.isWeb) { + return Column( + children: [ ToolbarRichTextWebBuilder( richTextWebController: controller.richTextWebController!, padding: const EdgeInsets.only(bottom: 12), @@ -424,18 +461,34 @@ class IdentityCreatorView extends GetWidget ), ] ), - Stack( - children: [ - htmlEditor, - Obx(() => Center( - child: CompressImageLoadingBarWidget( - isCompressing: controller.isCompressingInlineImage.value, - ), - )) - ] - ), - ], - ); + Stack( + children: [ + _buildHtmlEditorWeb( + context, + controller.contentHtmlEditor), + Obx(() { + bool isInserting = controller.publicAssetController?.isUploading.isTrue == true + || controller.isCompressingInlineImage.isTrue; + return InsertImageLoadingIndicator(isInserting: isInserting); + }) + ] + ), + ], + ); + } else { + return Stack( + children: [ + _buildHtmlEditor( + context, + initialContent: controller.contentHtmlEditor), + Obx(() { + bool isInserting = controller.publicAssetController?.isUploading.isTrue == true + || controller.isCompressingInlineImage.isTrue; + return InsertImageLoadingIndicator(isInserting: isInserting); + }) + ] + ); + } } Widget _buildHtmlEditorWeb(BuildContext context, String initContent) { @@ -447,6 +500,7 @@ class IdentityCreatorView extends GetWidget hint: '', darkMode: false, initialText: initContent.isEmpty ? null : initContent, + disableDragAndDrop: true, customBodyCssStyle: HtmlUtils.customCssStyleHtmlEditor(direction: AppUtils.getCurrentDirection(context)), ), htmlToolbarOptions: const html_editor_browser.HtmlToolbarOptions( @@ -463,9 +517,11 @@ class IdentityCreatorView extends GetWidget } }, onInit: () { + controller.richTextWebController?.editorController.setOnDragDropEvent(); controller.richTextWebController?.editorController.setFullScreen(); controller.updateContentHtmlEditor(initContent); - }, onFocus: () { + }, + onFocus: () { FocusManager.instance.primaryFocus?.unfocus(); Future.delayed(const Duration(milliseconds: 500), () { controller.richTextWebController?.editorController.setFocus(); @@ -473,7 +529,9 @@ class IdentityCreatorView extends GetWidget controller.richTextWebController?.closeAllMenuPopup(); }, onChangeSelection: controller.richTextWebController?.onEditorSettingsChange, - onChangeCodeview: controller.updateContentHtmlEditor + onChangeCodeview: controller.updateContentHtmlEditor, + onDragEnter: controller.handleOnDragEnterSignatureEditorWeb, + onDragLeave: (_) {} ), ); } diff --git a/lib/features/identity_creator/presentation/utils/identity_creator_constants.dart b/lib/features/identity_creator/presentation/utils/identity_creator_constants.dart index f9e2ad4ac..4000a884b 100644 --- a/lib/features/identity_creator/presentation/utils/identity_creator_constants.dart +++ b/lib/features/identity_creator/presentation/utils/identity_creator_constants.dart @@ -1,8 +1,7 @@ class IdentityCreatorConstants { - static const int maxKBSizeIdentityInlineImage = 128; // Kilobyte static const double maxWidthInlineImageDesktop = 800; // Pixel static const double maxWidthInlineImageOther = 700; // Pixel static const String prefixCompressedInlineImageTemp = 'compressed_'; - static const int qualityCompressedInlineImage = 50; // 0 - 100 + static const int qualityCompressedInlineImage = 80; // 0 - 100 } \ No newline at end of file diff --git a/lib/features/identity_creator/presentation/widgets/compress_image_loading_bar_widget.dart b/lib/features/identity_creator/presentation/widgets/compress_image_loading_bar_widget.dart deleted file mode 100644 index 72e0a6a2a..000000000 --- a/lib/features/identity_creator/presentation/widgets/compress_image_loading_bar_widget.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:tmail_ui_user/features/base/widget/circle_loading_widget.dart'; - -class CompressImageLoadingBarWidget extends StatelessWidget { - - final bool isCompressing; - final EdgeInsetsGeometry? padding; - - const CompressImageLoadingBarWidget({ - super.key, - required this.isCompressing, - this.padding, - }); - - @override - Widget build(BuildContext context) { - if (isCompressing) { - return CircleLoadingWidget(padding: padding); - } else { - return const SizedBox.shrink(); - } - } -} \ No newline at end of file diff --git a/lib/features/identity_creator/presentation/widgets/insert_image_loading_indicator.dart b/lib/features/identity_creator/presentation/widgets/insert_image_loading_indicator.dart new file mode 100644 index 000000000..e3da96102 --- /dev/null +++ b/lib/features/identity_creator/presentation/widgets/insert_image_loading_indicator.dart @@ -0,0 +1,27 @@ + +import 'package:core/presentation/extensions/color_extension.dart'; +import 'package:flutter/material.dart'; + +class InsertImageLoadingIndicator extends StatelessWidget { + + final bool isInserting; + + const InsertImageLoadingIndicator({ + super.key, + required this.isInserting + }); + + @override + Widget build(BuildContext context) { + if (isInserting) { + return const Align( + alignment: Alignment.topCenter, + child: CircularProgressIndicator( + color: AppColor.primaryColor + ) + ); + } else { + return const SizedBox.shrink(); + } + } +} diff --git a/lib/features/public_asset/domain/state/create_public_asset_state.dart b/lib/features/public_asset/domain/state/create_public_asset_state.dart index 0746a108c..1a5864858 100644 --- a/lib/features/public_asset/domain/state/create_public_asset_state.dart +++ b/lib/features/public_asset/domain/state/create_public_asset_state.dart @@ -8,6 +8,9 @@ class CreatePublicAssetSuccessState extends UIState { CreatePublicAssetSuccessState(this.publicAsset); final PublicAsset publicAsset; + + @override + List get props => [publicAsset]; } class CreatePublicAssetFailureState extends FeatureFailure { diff --git a/lib/features/public_asset/presentation/public_asset_controller.dart b/lib/features/public_asset/presentation/public_asset_controller.dart index 4a000d895..a96859aab 100644 --- a/lib/features/public_asset/presentation/public_asset_controller.dart +++ b/lib/features/public_asset/presentation/public_asset_controller.dart @@ -47,9 +47,8 @@ class PublicAssetController extends BaseController { List newlyPickedPublicAssetIds = []; final _publicAssetStreamGroup = StreamGroup>(); StreamSubscription>? _publicAssetStreamSubscription; + final isUploading = false.obs; - StreamSubscription>? get publicAssetStreamSubscription - => _publicAssetStreamSubscription; Session? get session => arguments?.session; AccountId? get accountId => arguments?.accountId; Identity? get identity => arguments?.identity; @@ -57,7 +56,7 @@ class PublicAssetController extends BaseController { void _handleUploadState(Either uploadState) { log('PublicAssetController::handleUploadState::${uploadState.runtimeType}'); uploadState.fold( - (failure) {}, + (failure) => consumeState(Stream.value(Left(CreatePublicAssetFailureState()))), (success) { log('PublicAssetController::handleUploadState::success::$success'); if (success is SuccessAttachmentUploadState) { @@ -71,7 +70,10 @@ class PublicAssetController extends BaseController { final blobId = success.attachment.blobId; if (session == null || accountId == null - || blobId == null) return; + || blobId == null) { + consumeState(Stream.value(Left(CreatePublicAssetFailureState()))); + return; + } consumeState(_createPublicAssetInteractor.execute( session!, @@ -108,17 +110,27 @@ class PublicAssetController extends BaseController { } } + @override + void handleFailureViewState(Failure failure) { + super.handleFailureViewState(failure); + if (failure is CreatePublicAssetFailureState) { + isUploading.value = false; + } + } + Future _handleUploadAttachmentSuccess(UploadAttachmentSuccess success) async { await _publicAssetStreamGroup.add(success.uploadAttachment.progressState); } void _handleCreatePublicAssetSuccessState(CreatePublicAssetSuccessState success) { + isUploading.value = false; final publicAsset = success.publicAsset; if (publicAsset.id == null || publicAsset.publicURI == null) return; newlyPickedPublicAssetIds.add(publicAsset.id!); final imageTag = ''; if (PlatformInfo.isWeb) { Get @@ -140,6 +152,7 @@ class PublicAssetController extends BaseController { } void uploadFileToBlob(PlatformFile platformFile) { + isUploading.value = true; _uploadFileToBlobAction(platformFile); } diff --git a/lib/features/upload/domain/extensions/file_info_extension.dart b/lib/features/upload/domain/extensions/file_info_extension.dart index ce079bacd..45ab539d1 100644 --- a/lib/features/upload/domain/extensions/file_info_extension.dart +++ b/lib/features/upload/domain/extensions/file_info_extension.dart @@ -1,4 +1,5 @@ +import 'package:file_picker/file_picker.dart'; import 'package:model/upload/file_info.dart'; import 'package:tmail_ui_user/features/upload/domain/model/mobile_file_upload.dart'; @@ -14,4 +15,12 @@ extension FileInfoExtension on FileInfo { type: type, isInline: true, isShared: isShared); + + PlatformFile toPlatformFile() => PlatformFile( + name: fileName, + path: filePath, + size: fileSize, + bytes: bytes, + readStream: readStream, + ); } \ No newline at end of file diff --git a/lib/features/upload/domain/extensions/list_file_info_extension.dart b/lib/features/upload/domain/extensions/list_file_info_extension.dart index 3934f631d..f8c193f88 100644 --- a/lib/features/upload/domain/extensions/list_file_info_extension.dart +++ b/lib/features/upload/domain/extensions/list_file_info_extension.dart @@ -1,7 +1,10 @@ + import 'package:model/upload/file_info.dart'; extension ListFileInfoExtension on List { List get listSize => map((file) => file.fileSize).toList(); num get totalSize => listSize.isEmpty ? 0 : listSize.reduce((sum, size) => sum + size); + + List get listInlineFiles => where((fileInfo) => fileInfo.isInline == true).toList(); } \ No newline at end of file diff --git a/lib/features/upload/domain/extensions/list_platform_file_extensions.dart b/lib/features/upload/domain/extensions/list_platform_file_extensions.dart new file mode 100644 index 000000000..eecfef472 --- /dev/null +++ b/lib/features/upload/domain/extensions/list_platform_file_extensions.dart @@ -0,0 +1,6 @@ + +import 'package:file_picker/file_picker.dart'; + +extension ListPlatformFileExtensions on List { + int get totalFilesSize => fold(0, (sum, file) => sum + file.size); +} \ No newline at end of file diff --git a/lib/main/localizations/app_localizations.dart b/lib/main/localizations/app_localizations.dart index ea922deda..f09d6534b 100644 --- a/lib/main/localizations/app_localizations.dart +++ b/lib/main/localizations/app_localizations.dart @@ -4180,4 +4180,11 @@ class AppLocalizations { 'Mark as spam failed', name: 'markAsSpamFailed'); } + + String get canNotUploadFileToSignature { + return Intl.message( + 'Can not upload this file to signature', + name: 'canNotUploadFileToSignature' + ); + } } \ No newline at end of file