diff --git a/core/lib/utils/app_logger.dart b/core/lib/utils/app_logger.dart index 4c691ae26..7664a65b1 100644 --- a/core/lib/utils/app_logger.dart +++ b/core/lib/utils/app_logger.dart @@ -7,7 +7,7 @@ import 'package:flutter/material.dart'; final logHistory = _Dispatcher(''); void log(String? value, {Level level = Level.info}) { - // if (!kDebugMode) return; + if (!kDebugMode) return; String logsStr = value ?? ''; logHistory.value = '$logsStr\n${logHistory.value}'; diff --git a/lib/features/base/reloadable/reloadable_controller.dart b/lib/features/base/reloadable/reloadable_controller.dart index f0750cec7..c51ac2b23 100644 --- a/lib/features/base/reloadable/reloadable_controller.dart +++ b/lib/features/base/reloadable/reloadable_controller.dart @@ -23,8 +23,8 @@ import 'package:tmail_ui_user/main/error/capability_validator.dart'; import 'package:tmail_ui_user/main/exceptions/remote_exception.dart'; abstract class ReloadableController extends BaseController { - final GetSessionInteractor _getSessionInteractor = Get.find(); - final GetAuthenticatedAccountInteractor _getAuthenticatedAccountInteractor = Get.find(); + final GetSessionInteractor getSessionInteractor = Get.find(); + final GetAuthenticatedAccountInteractor getAuthenticatedAccountInteractor = Get.find(); final UpdateAccountCacheInteractor _updateAccountCacheInteractor = Get.find(); @override @@ -78,11 +78,11 @@ abstract class ReloadableController extends BaseController { } void getAuthenticatedAccountAction() { - consumeState(_getAuthenticatedAccountInteractor.execute()); + consumeState(getAuthenticatedAccountInteractor.execute()); } void _handleGetCredentialSuccess(GetCredentialViewState success) { - _setDataToInterceptors( + setDataToInterceptors( baseUrl: success.baseUrl.toString(), userName: success.userName, password: success.password); @@ -90,7 +90,7 @@ abstract class ReloadableController extends BaseController { } void _handleGetStoredTokenOidcSuccess(GetStoredTokenOidcSuccess success) { - _setDataToInterceptors( + setDataToInterceptors( baseUrl: success.baseUrl.toString(), tokenOIDC: success.tokenOidc, oidcConfiguration: success.oidcConfiguration); @@ -102,7 +102,7 @@ abstract class ReloadableController extends BaseController { handleReloaded(session); } - void _setDataToInterceptors({ + void setDataToInterceptors({ required String baseUrl, UserName? userName, Password? password, @@ -124,7 +124,7 @@ abstract class ReloadableController extends BaseController { } void getSessionAction() { - consumeState(_getSessionInteractor.execute()); + consumeState(getSessionInteractor.execute()); } void handleGetSessionFailure(GetSessionFailure failure) { diff --git a/lib/features/email/presentation/extensions/attachment_extension.dart b/lib/features/email/presentation/extensions/attachment_extension.dart index 584494c8e..784c04b01 100644 --- a/lib/features/email/presentation/extensions/attachment_extension.dart +++ b/lib/features/email/presentation/extensions/attachment_extension.dart @@ -2,6 +2,7 @@ import 'package:core/data/constants/constant.dart'; import 'package:core/presentation/extensions/media_type_extension.dart'; import 'package:core/presentation/resources/image_paths.dart'; import 'package:core/utils/platform_info.dart'; +import 'package:model/download/download_task_id.dart'; import 'package:model/email/attachment.dart'; import 'package:tmail_ui_user/main/routes/app_routes.dart'; import 'package:tmail_ui_user/main/routes/route_utils.dart'; @@ -35,4 +36,6 @@ extension AttachmentExtension on Attachment { return '${Constant.attachmentScheme}:${blobId!.value}?name=${name ?? ''}&size=${size?.value ?? ''}&type=${type?.mimeType ?? ''}'; } + + DownloadTaskId get downloadTaskId => DownloadTaskId(blobId!.value); } \ No newline at end of file diff --git a/lib/features/email_previewer/download_attachment_loading_bar.dart b/lib/features/email_previewer/download_attachment_loading_bar.dart new file mode 100644 index 000000000..3f582dac2 --- /dev/null +++ b/lib/features/email_previewer/download_attachment_loading_bar.dart @@ -0,0 +1,32 @@ + +import 'package:core/presentation/extensions/color_extension.dart'; +import 'package:flutter/material.dart'; +import 'package:percent_indicator/linear_percent_indicator.dart'; +import 'package:tmail_ui_user/features/email/domain/state/download_attachment_for_web_state.dart'; + +class DownloadAttachmentLoadingBar extends StatelessWidget { + + final dynamic viewState; + + const DownloadAttachmentLoadingBar({super.key, this.viewState}); + + @override + Widget build(BuildContext context) { + if (viewState is StartDownloadAttachmentForWeb) { + return const LinearProgressIndicator( + color: AppColor.primaryColor, + minHeight: 5, + backgroundColor: AppColor.colorProgressLoadingBackground); + } if (viewState is DownloadingAttachmentForWeb) { + return LinearPercentIndicator( + padding: EdgeInsets.zero, + lineHeight: 5, + percent: viewState.progress / 100, + barRadius: const Radius.circular(1), + backgroundColor: AppColor.colorProgressLoadingBackground, + progressColor: AppColor.primaryColor); + } else { + return const SizedBox.shrink(); + } + } +} diff --git a/lib/features/email_previewer/email_previewer_bindings.dart b/lib/features/email_previewer/email_previewer_bindings.dart index 41dea591b..1712d4602 100644 --- a/lib/features/email_previewer/email_previewer_bindings.dart +++ b/lib/features/email_previewer/email_previewer_bindings.dart @@ -1,5 +1,6 @@ import 'package:core/data/model/source_type/data_source_type.dart'; +import 'package:core/data/network/download/download_manager.dart'; import 'package:core/presentation/resources/image_paths.dart'; import 'package:core/utils/file_utils.dart'; import 'package:core/utils/print_utils.dart'; @@ -20,6 +21,7 @@ import 'package:tmail_ui_user/features/email/data/local/html_analyzer.dart'; import 'package:tmail_ui_user/features/email/data/network/email_api.dart'; import 'package:tmail_ui_user/features/email/data/repository/email_repository_impl.dart'; import 'package:tmail_ui_user/features/email/domain/repository/email_repository.dart'; +import 'package:tmail_ui_user/features/email/domain/usecases/download_attachment_for_web_interactor.dart'; import 'package:tmail_ui_user/features/email/domain/usecases/get_preview_email_eml_content_shared_interactor.dart'; import 'package:tmail_ui_user/features/email/domain/usecases/get_preview_eml_content_in_memory_interactor.dart'; import 'package:tmail_ui_user/features/email/domain/usecases/move_preview_eml_content_from_persistent_to_memory_interactor.dart'; @@ -27,6 +29,9 @@ import 'package:tmail_ui_user/features/email/domain/usecases/parse_email_by_blob import 'package:tmail_ui_user/features/email/domain/usecases/preview_email_from_eml_file_interactor.dart'; import 'package:tmail_ui_user/features/email/domain/usecases/remove_preview_email_eml_content_shared_interactor.dart'; import 'package:tmail_ui_user/features/email_previewer/email_previewer_controller.dart'; +import 'package:tmail_ui_user/features/login/domain/repository/account_repository.dart'; +import 'package:tmail_ui_user/features/login/domain/repository/authentication_oidc_repository.dart'; +import 'package:tmail_ui_user/features/login/domain/repository/credential_repository.dart'; import 'package:tmail_ui_user/features/mailbox/data/datasource/state_datasource.dart'; import 'package:tmail_ui_user/features/mailbox/data/datasource_impl/state_datasource_impl.dart'; import 'package:tmail_ui_user/features/mailbox/data/local/state_cache_manager.dart'; @@ -50,7 +55,9 @@ class EmailPreviewerBindings extends BaseBindings { Get.find(), Get.find(), Get.find(), - Get.find())); + Get.find(), + Get.find(), + Get.find())); } @override @@ -110,6 +117,11 @@ class EmailPreviewerBindings extends BaseBindings { Get.find())); Get.lazyPut(() => PreviewEmailFromEmlFileInteractor( Get.find())); + Get.lazyPut(() => DownloadAttachmentForWebInteractor( + Get.find(), + Get.find(), + Get.find(), + Get.find())); } @override diff --git a/lib/features/email_previewer/email_previewer_controller.dart b/lib/features/email_previewer/email_previewer_controller.dart index bb8ac0832..fcd12721c 100644 --- a/lib/features/email_previewer/email_previewer_controller.dart +++ b/lib/features/email_previewer/email_previewer_controller.dart @@ -1,29 +1,40 @@ +import 'dart:async'; + +import 'package:core/data/constants/constant.dart'; +import 'package:core/data/network/download/download_manager.dart'; import 'package:core/presentation/state/failure.dart'; import 'package:core/presentation/state/success.dart'; +import 'package:core/utils/app_logger.dart'; import 'package:core/utils/html/html_utils.dart'; import 'package:dartz/dartz.dart'; +import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:jmap_dart_client/jmap/account_id.dart'; import 'package:jmap_dart_client/jmap/core/id.dart'; import 'package:jmap_dart_client/jmap/core/session/session.dart'; -import 'package:model/model.dart'; +import 'package:model/email/attachment.dart'; +import 'package:model/extensions/session_extension.dart'; import 'package:tmail_ui_user/features/base/reloadable/reloadable_controller.dart'; import 'package:tmail_ui_user/features/caching/exceptions/local_storage_exception.dart'; import 'package:tmail_ui_user/features/email/domain/exceptions/email_exceptions.dart'; import 'package:tmail_ui_user/features/email/domain/model/preview_email_eml_request.dart'; +import 'package:tmail_ui_user/features/email/domain/state/download_attachment_for_web_state.dart'; import 'package:tmail_ui_user/features/email/domain/state/get_preview_email_eml_content_shared_state.dart'; import 'package:tmail_ui_user/features/email/domain/state/get_preview_eml_content_in_memory_state.dart'; import 'package:tmail_ui_user/features/email/domain/state/parse_email_by_blob_id_state.dart'; import 'package:tmail_ui_user/features/email/domain/state/preview_email_from_eml_file_state.dart'; +import 'package:tmail_ui_user/features/email/domain/usecases/download_attachment_for_web_interactor.dart'; import 'package:tmail_ui_user/features/email/domain/usecases/get_preview_email_eml_content_shared_interactor.dart'; import 'package:tmail_ui_user/features/email/domain/usecases/get_preview_eml_content_in_memory_interactor.dart'; import 'package:tmail_ui_user/features/email/domain/usecases/move_preview_eml_content_from_persistent_to_memory_interactor.dart'; import 'package:tmail_ui_user/features/email/domain/usecases/parse_email_by_blob_id_interactor.dart'; import 'package:tmail_ui_user/features/email/domain/usecases/preview_email_from_eml_file_interactor.dart'; import 'package:tmail_ui_user/features/email/domain/usecases/remove_preview_email_eml_content_shared_interactor.dart'; +import 'package:tmail_ui_user/features/email/presentation/extensions/attachment_extension.dart'; import 'package:tmail_ui_user/features/email/presentation/model/eml_previewer.dart'; +import 'package:tmail_ui_user/features/email/presentation/utils/email_utils.dart'; import 'package:tmail_ui_user/features/home/data/exceptions/session_exceptions.dart'; import 'package:tmail_ui_user/features/home/domain/state/get_session_state.dart'; import 'package:tmail_ui_user/features/login/domain/state/get_authenticated_account_state.dart'; @@ -41,12 +52,20 @@ class EmailPreviewerController extends ReloadableController { final GetPreviewEmlContentInMemoryInteractor _getPreviewEmlContentInMemoryInteractor; final ParseEmailByBlobIdInteractor _parseEmailByBlobIdInteractor; final PreviewEmailFromEmlFileInteractor _previewEmailFromEmlFileInteractor; + final DownloadAttachmentForWebInteractor _downloadAttachmentForWebInteractor; + final DownloadManager _downloadManager; final emlContentViewState = Rx>(Right(UIState.idle)); + final downloadAttachmentState = Rx(null); Session? _session; AccountId? _accountId; String? _keyStored; + bool _isDownloadingAttachment = false; + CancelToken? _downloadAttachmentCancelToken; + StreamController>? _downloadAttachmentStreamController; + StreamSubscription>? _downloadAttachmentStreamSubscription; + StreamSubscription>? _downloadInteractorStreamSubscription; EmailPreviewerController( this._getPreviewEmailEMLContentSharedInteractor, @@ -55,11 +74,14 @@ class EmailPreviewerController extends ReloadableController { this._getPreviewEmlContentInMemoryInteractor, this._parseEmailByBlobIdInteractor, this._previewEmailFromEmlFileInteractor, + this._downloadAttachmentForWebInteractor, + this._downloadManager, ); @override void onInit() { consumeState(Stream.value(Right(GettingPreviewEmailEMLContentShared()))); + _initialDownloadAttachmentStreamListener(); super.onInit(); } @@ -74,6 +96,15 @@ class EmailPreviewerController extends ReloadableController { _accountId = null; _session = null; _keyStored = null; + _isDownloadingAttachment = false; + _downloadAttachmentCancelToken?.cancel(); + _downloadAttachmentCancelToken = null; + _downloadAttachmentStreamSubscription?.cancel(); + _downloadAttachmentStreamController?.close(); + _downloadAttachmentStreamSubscription = null; + _downloadAttachmentStreamController = null; + _downloadInteractorStreamSubscription?.cancel(); + _downloadInteractorStreamSubscription = null; super.onClose(); } @@ -175,10 +206,23 @@ class EmailPreviewerController extends ReloadableController { } Future onClickHyperLink(Uri? uri) async { + log('EmailPreviewerController::onClickHyperLink:Uri = $uri'); if (uri == null) return; + if (uri.scheme == Constant.attachmentScheme) { + final attachment = EmailUtils.parsingAttachmentByUri(uri); + if (attachment == null) return; + + _downloadAttachment(attachment); + } else { + _openNewWindowByHyperLink(uri); + } + } + + void _openNewWindowByHyperLink(Uri uri) { bool isEMlPreview = uri.toString().startsWith(RouteUtils.emailEMLPreviewerRoutePath); - final url = _standardizeURL(uri.toString()); + final url = _standardizeURL(uri); + log('EmailPreviewerController::_openNewWindowByHyperLink: url = $url'); bool isOpen = HtmlUtils.openNewWindowByUrl( url, @@ -188,17 +232,16 @@ class EmailPreviewerController extends ReloadableController { if (!isOpen && currentOverlayContext != null && currentContext != null) { appToast.showToastErrorMessage( - currentOverlayContext!, - AppLocalizations.of(currentContext!).cannotOpenNewWindow); + currentOverlayContext!, + AppLocalizations.of(currentContext!).cannotOpenNewWindow); } } - String _standardizeURL(String url) { - if (url.startsWith('${RouteUtils.mailtoPrefix}:')) { - final mailtoLink = '${RouteUtils.baseOriginUrl}/mailto?uri=$url'; - return mailtoLink; + String _standardizeURL(Uri uri) { + if (uri.scheme == RouteUtils.mailtoPrefix) { + return '${RouteUtils.baseOriginUrl}/mailto?uri=${uri.toString()}'; } - return url; + return uri.toString(); } void _parseEmailByBlobId(AccountId accountId, String blobId) { @@ -241,4 +284,118 @@ class EmailPreviewerController extends ReloadableController { void _updateWindowBrowserTitle(String title) { HtmlUtils.setWindowBrowserTitle(title); } + + void _downloadAttachment(Attachment attachment) { + if (_isDownloadingAttachment) { + if (currentContext == null || currentOverlayContext == null) return; + + appToast.showToastWarningMessage( + currentOverlayContext!, + AppLocalizations.of(currentContext!).downloadAttachmentInEMLPreviewWarningMessage); + return; + } + + _isDownloadingAttachment = true; + downloadAttachmentState.value = Right(StartDownloadAttachmentForWeb( + attachment.downloadTaskId, + attachment)); + + if (_accountId != null && _session != null) { + _startDownloadAttachment(attachment); + } else { + _getAuthenticatedAccountAction(attachment); + } + } + + void _startDownloadAttachment(Attachment attachment) { + _downloadInteractorStreamSubscription = _downloadAttachmentForWebInteractor + .execute( + attachment.downloadTaskId, + attachment, + _accountId!, + _session!.getDownloadUrl(jmapUrl: dynamicUrlInterceptors.jmapUrl), + _downloadAttachmentStreamController!) + .listen(_handleDownloadAttachmentViewState); + } + + void _initialDownloadAttachmentStreamListener() { + _downloadAttachmentCancelToken = CancelToken(); + _downloadAttachmentStreamController = StreamController>(); + _downloadAttachmentStreamSubscription = _downloadAttachmentStreamController + ?.stream + .listen(_handleDownloadAttachmentViewState); + } + + void _handleDownloadAttachmentViewState(Either viewState) { + viewState.fold( + (failure) { + downloadAttachmentState.value = failure; + if (failure is DownloadAttachmentForWebFailure) { + _isDownloadingAttachment = false; + } + }, + (success) { + downloadAttachmentState.value = success; + if (success is DownloadAttachmentForWebSuccess) { + _isDownloadingAttachment = false; + _handleDownloadAttachmentSuccess(success); + } + } + ); + } + + Future _getAuthenticatedAccountAction(Attachment attachment) async { + final authenticatedAccountViewState = await getAuthenticatedAccountInteractor + .execute() + .last; + + final authenticatedSuccessState = authenticatedAccountViewState + .getOrElse(() => UIState.idle); + + if (authenticatedSuccessState is! GetStoredTokenOidcSuccess && + authenticatedSuccessState is! GetCredentialViewState) { + _isDownloadingAttachment = false; + + if (currentContext == null || currentOverlayContext == null) return; + + appToast.showToastErrorMessage( + currentOverlayContext!, + AppLocalizations.of(currentContext!).attachment_download_failed); + return; + } + + if (authenticatedSuccessState is GetStoredTokenOidcSuccess) { + setDataToInterceptors( + baseUrl: authenticatedSuccessState.baseUrl.toString(), + tokenOIDC: authenticatedSuccessState.tokenOidc, + oidcConfiguration: authenticatedSuccessState.oidcConfiguration); + } else if (authenticatedSuccessState is GetCredentialViewState) { + setDataToInterceptors( + baseUrl: authenticatedSuccessState.baseUrl.toString(), + userName: authenticatedSuccessState.userName, + password: authenticatedSuccessState.password); + } + + final sessionViewState = await getSessionInteractor.execute().last; + final sessionSuccessState = sessionViewState.getOrElse(() => UIState.idle); + + if (sessionSuccessState is GetSessionSuccess) { + _session = sessionSuccessState.session; + _accountId = _session?.accountId; + + _startDownloadAttachment(attachment); + } else { + _isDownloadingAttachment = false; + + appToast.showToastErrorMessage( + currentOverlayContext!, + AppLocalizations.of(currentContext!).attachment_download_failed); + } + } + + void _handleDownloadAttachmentSuccess(DownloadAttachmentForWebSuccess success) { + _downloadManager.createAnchorElementDownloadFileWeb( + success.bytes, + success.attachment.generateFileName()); + } } \ No newline at end of file diff --git a/lib/features/email_previewer/email_previewer_view.dart b/lib/features/email_previewer/email_previewer_view.dart index d5df2aa68..85e7951b1 100644 --- a/lib/features/email_previewer/email_previewer_view.dart +++ b/lib/features/email_previewer/email_previewer_view.dart @@ -7,6 +7,7 @@ import 'package:tmail_ui_user/features/email/domain/state/get_preview_email_eml_ import 'package:tmail_ui_user/features/email/domain/state/get_preview_eml_content_in_memory_state.dart'; import 'package:tmail_ui_user/features/email/domain/state/preview_email_from_eml_file_state.dart'; import 'package:tmail_ui_user/features/email/presentation/model/eml_previewer.dart'; +import 'package:tmail_ui_user/features/email_previewer/download_attachment_loading_bar.dart'; import 'package:tmail_ui_user/features/email_previewer/email_previewer_controller.dart'; import 'package:tmail_ui_user/main/localizations/app_localizations.dart'; import 'package:tmail_ui_user/main/utils/app_utils.dart'; @@ -19,26 +20,36 @@ class EmailPreviewerView extends GetWidget { Widget build(BuildContext context) { return Scaffold( backgroundColor: Colors.white, - body: Obx(() { - final viewState = controller.emlContentViewState.value; - return viewState.fold( - (failure) => Center( - child: Text(AppLocalizations.of(context).previewEmailFromEMLFileFailed), - ), - (success) { - if (success is GetPreviewEmailEMLContentSharedSuccess) { - return _buildEMLPreviewerWidget(context, success.emlPreviewer); - } else if (success is GetPreviewEMLContentInMemorySuccess) { - return _buildEMLPreviewerWidget(context, success.emlPreviewer); - } else if (success is PreviewEmailFromEmlFileSuccess) { - return _buildEMLPreviewerWidget(context, success.emlPreviewer); - } else { - return const Center( - child: CupertinoLoadingWidget(), - ); - } - }); - }), + body: Stack( + children: [ + Obx(() { + final viewState = controller.emlContentViewState.value; + return viewState.fold( + (failure) => Center( + child: Text(AppLocalizations.of(context).previewEmailFromEMLFileFailed), + ), + (success) { + if (success is GetPreviewEmailEMLContentSharedSuccess) { + return _buildEMLPreviewerWidget(context, success.emlPreviewer); + } else if (success is GetPreviewEMLContentInMemorySuccess) { + return _buildEMLPreviewerWidget(context, success.emlPreviewer); + } else if (success is PreviewEmailFromEmlFileSuccess) { + return _buildEMLPreviewerWidget(context, success.emlPreviewer); + } else { + return const Center( + child: CupertinoLoadingWidget(), + ); + } + }); + }), + Align( + alignment: AlignmentDirectional.topCenter, + child: Obx(() => DownloadAttachmentLoadingBar( + viewState: controller.downloadAttachmentState.value, + )), + ) + ], + ), ); } diff --git a/lib/features/identity_creator/presentation/identity_creator_controller.dart b/lib/features/identity_creator/presentation/identity_creator_controller.dart index c3e1d3b79..49320f003 100644 --- a/lib/features/identity_creator/presentation/identity_creator_controller.dart +++ b/lib/features/identity_creator/presentation/identity_creator_controller.dart @@ -886,6 +886,9 @@ class IdentityCreatorController extends BaseController with DragDropFileMixin im clearFocusEditor(context); final listFileInfo = await onDragDone(context: context, details: details); + + if (!context.mounted) return; + await _uploadMultipleFilesToPublicAsset( context, listFileInfo, diff --git a/lib/features/push_notification/data/repository/fcm_repository_impl.dart b/lib/features/push_notification/data/repository/fcm_repository_impl.dart index 216d7fb33..aa62dbb30 100644 --- a/lib/features/push_notification/data/repository/fcm_repository_impl.dart +++ b/lib/features/push_notification/data/repository/fcm_repository_impl.dart @@ -80,7 +80,7 @@ class FCMRepositoryImpl extends FCMRepository { if (listEmails.isNotEmpty) { return EmailsResponse(emailList: listEmails); } else { - return EmailsResponse(); + return const EmailsResponse(); } } diff --git a/lib/l10n/intl_messages.arb b/lib/l10n/intl_messages.arb index cefbf8b58..e21f38562 100644 --- a/lib/l10n/intl_messages.arb +++ b/lib/l10n/intl_messages.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2025-01-13T15:53:39.843822", + "@@last_modified": "2025-01-14T12:52:43.828907", "initializing_data": "Initializing data...", "@initializing_data": { "type": "text", @@ -4107,5 +4107,11 @@ "type": "text", "placeholders_order": [], "placeholders": {} + }, + "downloadAttachmentInEMLPreviewWarningMessage": "Downloading attachment. You can only download one file at a time.", + "@downloadAttachmentInEMLPreviewWarningMessage": { + "type": "text", + "placeholders_order": [], + "placeholders": {} } } \ No newline at end of file diff --git a/lib/main/localizations/app_localizations.dart b/lib/main/localizations/app_localizations.dart index 1796a4c42..40c84b2f0 100644 --- a/lib/main/localizations/app_localizations.dart +++ b/lib/main/localizations/app_localizations.dart @@ -4314,4 +4314,11 @@ class AppLocalizations { name: 'cannotOpenNewWindow', ); } + + String get downloadAttachmentInEMLPreviewWarningMessage { + return Intl.message( + 'Downloading attachment. You can only download one file at a time.', + name: 'downloadAttachmentInEMLPreviewWarningMessage', + ); + } }