diff --git a/assets/images/ic_event_canceled.svg b/assets/images/ic_event_canceled.svg new file mode 100644 index 0000000000..7b011b5a95 --- /dev/null +++ b/assets/images/ic_event_canceled.svg @@ -0,0 +1,5 @@ + + + diff --git a/assets/images/ic_event_invited.svg b/assets/images/ic_event_invited.svg new file mode 100644 index 0000000000..4497cc4d99 --- /dev/null +++ b/assets/images/ic_event_invited.svg @@ -0,0 +1,5 @@ + + + diff --git a/assets/images/ic_event_updated.svg b/assets/images/ic_event_updated.svg new file mode 100644 index 0000000000..c339cd6a42 --- /dev/null +++ b/assets/images/ic_event_updated.svg @@ -0,0 +1,5 @@ + + + diff --git a/core/lib/presentation/extensions/color_extension.dart b/core/lib/presentation/extensions/color_extension.dart index b2282e779c..be56651b36 100644 --- a/core/lib/presentation/extensions/color_extension.dart +++ b/core/lib/presentation/extensions/color_extension.dart @@ -186,6 +186,11 @@ extension AppColor on Color { static const colorNetworkConnectionLabel = Color(0xFF818C99); static const colorCalendarEventRead = Color(0xFF818C99); static const colorCalendarEventUnread = Color(0xFF1C1B1F); + static const colorMaybeEventActionText = Color(0xFFFFC107); + static const colorInvitedEventActionText = Color(0xFF007AFF); + static const colorUpdatedEventActionText = Color(0xFF4BB34B); + static const colorCanceledEventActionText = Color(0xFFFF3347); + static const colorSubTitleEventActionText = Color(0xFF939393); static const mapGradientColor = [ [Color(0xFF21D4FD), Color(0xFFB721FF)], diff --git a/core/lib/presentation/resources/image_paths.dart b/core/lib/presentation/resources/image_paths.dart index 4508fc577e..d5a010dd7c 100644 --- a/core/lib/presentation/resources/image_paths.dart +++ b/core/lib/presentation/resources/image_paths.dart @@ -195,6 +195,9 @@ class ImagePaths { String get icArrowRight => _getImagePath('ic_arrow_right.svg'); String get icAddPicture => _getImagePath('ic_add_picture.svg'); String get icCalendarEvent => _getImagePath('ic_calendar_event.svg'); + String get icEventInvited => _getImagePath('ic_event_invited.svg'); + String get icEventUpdated => _getImagePath('ic_event_updated.svg'); + String get icEventCanceled => _getImagePath('ic_event_canceled.svg'); String _getImagePath(String imageName) { return AssetsPaths.images + imageName; diff --git a/lib/features/base/base_controller.dart b/lib/features/base/base_controller.dart index 6ca202abc2..b3555b5b1d 100644 --- a/lib/features/base/base_controller.dart +++ b/lib/features/base/base_controller.dart @@ -22,7 +22,7 @@ import 'package:rule_filter/rule_filter/capability_rule_filter.dart'; import 'package:tmail_ui_user/features/base/mixin/message_dialog_action_mixin.dart'; import 'package:tmail_ui_user/features/base/mixin/popup_context_menu_action_mixin.dart'; import 'package:tmail_ui_user/features/caching/caching_manager.dart'; -import 'package:tmail_ui_user/features/email/presentation/mdn_interactor_bindings.dart'; +import 'package:tmail_ui_user/features/email/presentation/bindings/mdn_interactor_bindings.dart'; import 'package:tmail_ui_user/features/login/data/network/config/authorization_interceptors.dart'; import 'package:tmail_ui_user/features/login/domain/usecases/delete_authority_oidc_interactor.dart'; import 'package:tmail_ui_user/features/login/domain/usecases/delete_credential_interactor.dart'; diff --git a/lib/features/email/data/datasource/calendar_event_datasource.dart b/lib/features/email/data/datasource/calendar_event_datasource.dart new file mode 100644 index 0000000000..3f34688d1b --- /dev/null +++ b/lib/features/email/data/datasource/calendar_event_datasource.dart @@ -0,0 +1,8 @@ + +import 'package:jmap_dart_client/jmap/account_id.dart'; +import 'package:jmap_dart_client/jmap/core/id.dart'; +import 'package:jmap_dart_client/jmap/mail/calendar/calendar_event.dart'; + +abstract class CalendarEventDataSource { + Future> parse(AccountId accountId, Set blobIds); +} \ No newline at end of file diff --git a/lib/features/email/data/datasource_impl/calendar_event_datasource_impl.dart b/lib/features/email/data/datasource_impl/calendar_event_datasource_impl.dart new file mode 100644 index 0000000000..aabcc5193a --- /dev/null +++ b/lib/features/email/data/datasource_impl/calendar_event_datasource_impl.dart @@ -0,0 +1,22 @@ + +import 'package:jmap_dart_client/jmap/account_id.dart'; +import 'package:jmap_dart_client/jmap/core/id.dart'; +import 'package:jmap_dart_client/jmap/mail/calendar/calendar_event.dart'; +import 'package:tmail_ui_user/features/email/data/datasource/calendar_event_datasource.dart'; +import 'package:tmail_ui_user/features/email/data/network/calendar_event_api.dart'; +import 'package:tmail_ui_user/main/exceptions/exception_thrower.dart'; + +class CalendarEventDataSourceImpl extends CalendarEventDataSource { + + final CalendarEventAPI _calendarEventAPI; + final ExceptionThrower _exceptionThrower; + + CalendarEventDataSourceImpl(this._calendarEventAPI, this._exceptionThrower); + + @override + Future> parse(AccountId accountId, Set blobIds) { + return Future.sync(() async { + return await _calendarEventAPI.parse(accountId, blobIds); + }).catchError(_exceptionThrower.throwException); + } +} \ No newline at end of file diff --git a/lib/features/email/data/network/calendar_event_api.dart b/lib/features/email/data/network/calendar_event_api.dart new file mode 100644 index 0000000000..19ce720465 --- /dev/null +++ b/lib/features/email/data/network/calendar_event_api.dart @@ -0,0 +1,41 @@ +import 'dart:async'; + +import 'package:jmap_dart_client/http/http_client.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/jmap_request.dart'; +import 'package:jmap_dart_client/jmap/mail/calendar/calendar_event.dart'; +import 'package:jmap_dart_client/jmap/mail/calendar/parse/calendar_event_parse_method.dart'; +import 'package:jmap_dart_client/jmap/mail/calendar/parse/calendar_event_parse_response.dart'; +import 'package:tmail_ui_user/features/email/domain/exceptions/calendar_event_exceptions.dart'; + +class CalendarEventAPI { + + final HttpClient _httpClient; + + CalendarEventAPI(this._httpClient); + + Future> parse(AccountId accountId, Set blobIds) async { + final requestBuilder = JmapRequestBuilder(_httpClient, ProcessingInvocation()); + final calendarEventParseMethod = CalendarEventParseMethod(accountId, blobIds); + final calendarEventParseInvocation = requestBuilder.invocation(calendarEventParseMethod); + final response = await (requestBuilder + ..usings(calendarEventParseMethod.requiredCapabilities)) + .build() + .execute(); + + final calendarEventParseResponse = response.parse( + calendarEventParseInvocation.methodCallId, + CalendarEventParseResponse.deserialize); + + if (calendarEventParseResponse?.parsed?.isNotEmpty == true) { + return calendarEventParseResponse!.parsed!.values.toList(); + } else if (calendarEventParseResponse?.notParsable?.isNotEmpty == true) { + throw NotParsableCalendarEventException(); + } else if (calendarEventParseResponse?.notFound?.isNotEmpty == true) { + throw NotFoundCalendarEventException(); + } else { + throw NotParsableCalendarEventException(); + } + } +} \ No newline at end of file diff --git a/lib/features/email/data/repository/calendar_event_repository_impl.dart b/lib/features/email/data/repository/calendar_event_repository_impl.dart new file mode 100644 index 0000000000..9d94ecc749 --- /dev/null +++ b/lib/features/email/data/repository/calendar_event_repository_impl.dart @@ -0,0 +1,18 @@ + +import 'package:jmap_dart_client/jmap/account_id.dart'; +import 'package:jmap_dart_client/jmap/core/id.dart'; +import 'package:jmap_dart_client/jmap/mail/calendar/calendar_event.dart'; +import 'package:tmail_ui_user/features/email/data/datasource/calendar_event_datasource.dart'; +import 'package:tmail_ui_user/features/email/domain/repository/calendar_event_repository.dart'; + +class CalendarEventRepositoryImpl extends CalendarEventRepository { + + final CalendarEventDataSource _calendarEventDataSource; + + CalendarEventRepositoryImpl(this._calendarEventDataSource); + + @override + Future> parse(AccountId accountId, Set blobIds) { + return _calendarEventDataSource.parse(accountId, blobIds); + } +} \ No newline at end of file diff --git a/lib/features/email/domain/exceptions/calendar_event_exceptions.dart b/lib/features/email/domain/exceptions/calendar_event_exceptions.dart new file mode 100644 index 0000000000..1129d36f55 --- /dev/null +++ b/lib/features/email/domain/exceptions/calendar_event_exceptions.dart @@ -0,0 +1,4 @@ + +class NotFoundCalendarEventException implements Exception {} + +class NotParsableCalendarEventException implements Exception {} \ No newline at end of file diff --git a/lib/features/email/domain/extensions/list_attachments_extension.dart b/lib/features/email/domain/extensions/list_attachments_extension.dart index 1cbfc601a3..bd9a0fc08a 100644 --- a/lib/features/email/domain/extensions/list_attachments_extension.dart +++ b/lib/features/email/domain/extensions/list_attachments_extension.dart @@ -1,8 +1,17 @@ +import 'package:collection/collection.dart'; +import 'package:jmap_dart_client/jmap/core/id.dart'; import 'package:model/email/attachment.dart'; import 'package:tmail_ui_user/features/email/domain/extensions/attachment_extension.dart'; import 'package:tmail_ui_user/features/offline_mode/model/attachment_hive_cache.dart'; extension ListAttachmentsExtension on List { List toHiveCache() => map((attachment) => attachment.toHiveCache()).toList(); + + Set get calendarAttachments => where((attachment) => attachment.isCalendarEvent).toSet(); + + Set get calendarEventBlobIds => calendarAttachments + .map((attachment) => attachment.blobId) + .whereNotNull() + .toSet(); } \ No newline at end of file diff --git a/lib/features/email/domain/repository/calendar_event_repository.dart b/lib/features/email/domain/repository/calendar_event_repository.dart new file mode 100644 index 0000000000..809b12c7e3 --- /dev/null +++ b/lib/features/email/domain/repository/calendar_event_repository.dart @@ -0,0 +1,8 @@ + +import 'package:jmap_dart_client/jmap/account_id.dart'; +import 'package:jmap_dart_client/jmap/core/id.dart'; +import 'package:jmap_dart_client/jmap/mail/calendar/calendar_event.dart'; + +abstract class CalendarEventRepository { + Future> parse(AccountId accountId, Set blobIds); +} \ No newline at end of file diff --git a/lib/features/email/domain/state/parse_calendar_event_state.dart b/lib/features/email/domain/state/parse_calendar_event_state.dart new file mode 100644 index 0000000000..4d1be20264 --- /dev/null +++ b/lib/features/email/domain/state/parse_calendar_event_state.dart @@ -0,0 +1,19 @@ +import 'package:core/presentation/state/failure.dart'; +import 'package:core/presentation/state/success.dart'; +import 'package:jmap_dart_client/jmap/mail/calendar/calendar_event.dart'; + +class ParseCalendarEventLoading extends LoadingState {} + +class ParseCalendarEventSuccess extends UIState { + + final List calendarEventList; + + ParseCalendarEventSuccess(this.calendarEventList); + + @override + List get props => [calendarEventList]; +} + +class ParseCalendarEventFailure extends FeatureFailure { + ParseCalendarEventFailure(dynamic exception) : super(exception: exception); +} \ No newline at end of file diff --git a/lib/features/email/domain/usecases/parse_calendar_event_interactor.dart b/lib/features/email/domain/usecases/parse_calendar_event_interactor.dart new file mode 100644 index 0000000000..2c6c9adb13 --- /dev/null +++ b/lib/features/email/domain/usecases/parse_calendar_event_interactor.dart @@ -0,0 +1,23 @@ +import 'package:core/presentation/state/failure.dart'; +import 'package:core/presentation/state/success.dart'; +import 'package:dartz/dartz.dart'; +import 'package:jmap_dart_client/jmap/account_id.dart'; +import 'package:jmap_dart_client/jmap/core/id.dart'; +import 'package:tmail_ui_user/features/email/domain/repository/calendar_event_repository.dart'; +import 'package:tmail_ui_user/features/email/domain/state/parse_calendar_event_state.dart'; + +class ParseCalendarEventInteractor { + final CalendarEventRepository _calendarEventRepository; + + ParseCalendarEventInteractor(this._calendarEventRepository); + + Stream> execute(AccountId accountId, Set blobIds) async* { + try { + yield Right(ParseCalendarEventLoading()); + final calendarEventList = await _calendarEventRepository.parse(accountId, blobIds); + yield Right(ParseCalendarEventSuccess(calendarEventList)); + } catch (e) { + yield Left(ParseCalendarEventFailure(e)); + } + } +} \ No newline at end of file diff --git a/lib/features/email/presentation/bindings/calendar_event_interactor_bindings.dart b/lib/features/email/presentation/bindings/calendar_event_interactor_bindings.dart new file mode 100644 index 0000000000..dc1fa15f5a --- /dev/null +++ b/lib/features/email/presentation/bindings/calendar_event_interactor_bindings.dart @@ -0,0 +1,41 @@ +import 'package:get/get.dart'; +import 'package:jmap_dart_client/http/http_client.dart'; +import 'package:tmail_ui_user/features/base/interactors_bindings.dart'; +import 'package:tmail_ui_user/features/email/data/datasource/calendar_event_datasource.dart'; +import 'package:tmail_ui_user/features/email/data/datasource_impl/calendar_event_datasource_impl.dart'; +import 'package:tmail_ui_user/features/email/data/network/calendar_event_api.dart'; +import 'package:tmail_ui_user/features/email/data/repository/calendar_event_repository_impl.dart'; +import 'package:tmail_ui_user/features/email/domain/repository/calendar_event_repository.dart'; +import 'package:tmail_ui_user/features/email/domain/usecases/parse_calendar_event_interactor.dart'; +import 'package:tmail_ui_user/main/exceptions/remote_exception_thrower.dart'; + +class CalendarEventInteractorBindings extends InteractorsBindings { + + @override + void bindingsDataSource() { + Get.lazyPut(() => Get.find()); + } + + @override + void bindingsDataSourceImpl() { + Get.lazyPut(() => CalendarEventAPI(Get.find())); + Get.lazyPut(() => CalendarEventDataSourceImpl( + Get.find(), + Get.find())); + } + + @override + void bindingsInteractor() { + Get.lazyPut(() => ParseCalendarEventInteractor(Get.find())); + } + + @override + void bindingsRepository() { + Get.lazyPut(() => Get.find()); + } + + @override + void bindingsRepositoryImpl() { + Get.lazyPut(() => CalendarEventRepositoryImpl(Get.find())); + } +} \ No newline at end of file diff --git a/lib/features/email/presentation/email_bindings.dart b/lib/features/email/presentation/bindings/email_bindings.dart similarity index 100% rename from lib/features/email/presentation/email_bindings.dart rename to lib/features/email/presentation/bindings/email_bindings.dart diff --git a/lib/features/email/presentation/mdn_interactor_bindings.dart b/lib/features/email/presentation/bindings/mdn_interactor_bindings.dart similarity index 100% rename from lib/features/email/presentation/mdn_interactor_bindings.dart rename to lib/features/email/presentation/bindings/mdn_interactor_bindings.dart diff --git a/lib/features/email/presentation/controller/single_email_controller.dart b/lib/features/email/presentation/controller/single_email_controller.dart index cb7208ea46..8d75bf248e 100644 --- a/lib/features/email/presentation/controller/single_email_controller.dart +++ b/lib/features/email/presentation/controller/single_email_controller.dart @@ -6,13 +6,14 @@ import 'package:dartz/dartz.dart'; import 'package:dio/dio.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:get/get.dart'; import 'package:jmap_dart_client/jmap/account_id.dart'; +import 'package:jmap_dart_client/jmap/core/capability/capability_identifier.dart'; import 'package:jmap_dart_client/jmap/core/id.dart'; import 'package:jmap_dart_client/jmap/core/session/session.dart'; import 'package:jmap_dart_client/jmap/identities/identity.dart'; +import 'package:jmap_dart_client/jmap/mail/calendar/calendar_event.dart'; import 'package:jmap_dart_client/jmap/mail/email/email.dart'; import 'package:jmap_dart_client/jmap/mail/email/email_address.dart'; import 'package:jmap_dart_client/jmap/mdn/disposition.dart'; @@ -26,8 +27,12 @@ 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/extensions/list_attachments_extension.dart'; import 'package:tmail_ui_user/features/email/domain/model/detailed_email.dart'; +import 'package:tmail_ui_user/features/email/domain/state/parse_calendar_event_state.dart'; +import 'package:tmail_ui_user/features/email/domain/usecases/parse_calendar_event_interactor.dart'; import 'package:tmail_ui_user/features/email/domain/usecases/store_opened_email_interactor.dart'; +import 'package:tmail_ui_user/features/email/presentation/bindings/calendar_event_interactor_bindings.dart'; import 'package:tmail_ui_user/features/email/presentation/controller/email_supervisor_controller.dart'; import 'package:tmail_ui_user/features/email/presentation/model/email_loaded.dart'; import 'package:tmail_ui_user/features/email/domain/model/move_action.dart'; @@ -66,6 +71,7 @@ import 'package:tmail_ui_user/features/manage_account/presentation/extensions/da import 'package:tmail_ui_user/features/rules_filter_creator/presentation/model/rules_filter_creator_arguments.dart'; import 'package:tmail_ui_user/features/thread/domain/constants/thread_constants.dart'; import 'package:tmail_ui_user/features/thread/presentation/model/delete_action_type.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/app_routes.dart'; import 'package:tmail_ui_user/main/routes/dialog_router.dart'; @@ -99,11 +105,14 @@ class SingleEmailController extends BaseController with AppLoaderMixin { CreateNewEmailRuleFilterInteractor? _createNewEmailRuleFilterInteractor; SendReceiptToSenderInteractor? _sendReceiptToSenderInteractor; + ParseCalendarEventInteractor? _parseCalendarEventInteractor; final emailAddressExpandMode = ExpandMode.COLLAPSE.obs; final attachmentsExpandMode = ExpandMode.COLLAPSE.obs; final emailContents = RxnString(); final attachments = [].obs; + final calendarEvent = Rxn(); + EmailId? _currentEmailId; Identity? _identitySelected; String? initialEmailContents; @@ -170,6 +179,8 @@ class SingleEmailController extends BaseController with AppLoaderMixin { _sendReceiptToSenderSuccess(success); } else if (success is CreateNewRuleFilterSuccess) { _createNewRuleFilterSuccess(success); + } else if (success is ParseCalendarEventSuccess) { + _handleParseCalendarEventSuccess(success); } } @@ -291,12 +302,18 @@ class SingleEmailController extends BaseController with AppLoaderMixin { void _injectAndGetInteractorBindings(Session? session, AccountId accountId) { injectRuleFilterBindings(session, accountId); injectMdnBindings(session, accountId); + _injectCalendarEventBindings(session, accountId); - if (Get.isRegistered()) { - _createNewEmailRuleFilterInteractor = Get.find(); - } - if (Get.isRegistered()) { - _sendReceiptToSenderInteractor = Get.find(); + _createNewEmailRuleFilterInteractor = getBinding(); + _sendReceiptToSenderInteractor = getBinding(); + _parseCalendarEventInteractor = getBinding(); + } + + void _injectCalendarEventBindings(Session? session, AccountId? accountId) { + if (session != null && accountId != null) { + if (CapabilityIdentifier.jamesCalendarEvent.isSupported(session, accountId)) { + CalendarEventInteractorBindings().dependencies(); + } } } @@ -380,6 +397,8 @@ class SingleEmailController extends BaseController with AppLoaderMixin { initialEmailContents = success.emailContent; attachments.value = success.attachments; + _loadCalendarEventAction(success.attachments.calendarEventBlobIds); + final isShowMessageReadReceipt = success.emailCurrent?.hasReadReceipt(mailboxDashBoardController.mapMailboxById) == true; if (isShowMessageReadReceipt) { _handleReadReceipt(); @@ -405,6 +424,8 @@ class SingleEmailController extends BaseController with AppLoaderMixin { initialEmailContents = success.emailContent; attachments.value = success.attachments; + _loadCalendarEventAction(success.attachments.calendarEventBlobIds); + if (PlatformInfo.isMobile) { final detailedEmail = DetailedEmail( emailId: currentEmail!.id!, @@ -450,6 +471,7 @@ class SingleEmailController extends BaseController with AppLoaderMixin { emailContents.value = null; initialEmailContents = null; attachments.clear(); + calendarEvent.value = null; } PresentationMailbox? getMailboxContain(PresentationEmail email) { @@ -1218,4 +1240,45 @@ class SingleEmailController extends BaseController with AppLoaderMixin { consumeState(_storeOpenedEmailInteractor.execute(session, accountId, detailedEmail)); } } + + void _loadCalendarEventAction(Set blobIds) { + log('SingleEmailController::_loadCalendarEventAction:blobIds: $blobIds'); + if (_isCalendarEventSupported) { + if (currentEmail?.hasCalendarEvent == true && + blobIds.isNotEmpty && + mailboxDashBoardController.accountId.value != null) { + _parseCalendarEventAction( + mailboxDashBoardController.accountId.value!, + blobIds + ); + } else { + logError('SingleEmailController::_loadCalendarEventAction: not found calendar event header'); + } + } else { + logError('SingleEmailController::_loadCalendarEventAction: calendar event not supported'); + } + } + + bool get _isCalendarEventSupported { + final accountId = mailboxDashBoardController.accountId.value; + final session = mailboxDashBoardController.sessionCurrent; + return session != null && + accountId != null && + CapabilityIdentifier.jamesCalendarEvent.isSupported(session, accountId); + } + + void _parseCalendarEventAction(AccountId accountId, Set blobIds) { + log("SingleEmailController::_parseCalendarEventAction:blobIds: $blobIds"); + if (_parseCalendarEventInteractor != null) { + consumeState(_parseCalendarEventInteractor!.execute(accountId, blobIds)); + } else { + logError("SingleEmailController::_parseCalendarEventAction: _parseCalendarEventInteractor is NULL"); + } + } + + void _handleParseCalendarEventSuccess(ParseCalendarEventSuccess success) { + if (success.calendarEventList.isNotEmpty) { + calendarEvent.value = success.calendarEventList.first; + } + } } \ No newline at end of file diff --git a/lib/features/email/presentation/email_view.dart b/lib/features/email/presentation/email_view.dart index c862183658..efe6113bfd 100644 --- a/lib/features/email/presentation/email_view.dart +++ b/lib/features/email/presentation/email_view.dart @@ -1,6 +1,5 @@ import 'package:core/presentation/extensions/color_extension.dart'; import 'package:core/presentation/resources/image_paths.dart'; -import 'package:core/presentation/state/success.dart'; import 'package:core/presentation/utils/icon_utils.dart'; import 'package:core/presentation/utils/responsive_utils.dart'; import 'package:core/presentation/views/button/icon_button_web.dart'; @@ -11,7 +10,6 @@ import 'package:core/utils/app_logger.dart'; import 'package:core/utils/direction_utils.dart'; import 'package:core/utils/platform_info.dart'; import 'package:filesize/filesize.dart'; -import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:get/get.dart'; @@ -21,12 +19,16 @@ import 'package:model/email/presentation_email.dart'; import 'package:model/extensions/list_attachment_extension.dart'; import 'package:model/extensions/presentation_email_extension.dart'; import 'package:model/mailbox/presentation_mailbox.dart'; +import 'package:tmail_ui_user/features/base/mixin/app_loader_mixin.dart'; import 'package:tmail_ui_user/features/base/widget/custom_scroll_behavior.dart'; import 'package:tmail_ui_user/features/base/widget/popup_item_widget.dart'; +import 'package:tmail_ui_user/features/email/domain/state/get_email_content_state.dart'; import 'package:tmail_ui_user/features/email/presentation/controller/single_email_controller.dart'; +import 'package:tmail_ui_user/features/email/presentation/styles/email_view_styles.dart'; import 'package:tmail_ui_user/features/email/presentation/widgets/app_bar_mail_widget_builder.dart'; import 'package:tmail_ui_user/features/email/presentation/widgets/attachment_file_tile_builder.dart'; import 'package:tmail_ui_user/features/email/presentation/widgets/bottom_bar_mail_widget_builder.dart'; +import 'package:tmail_ui_user/features/email/presentation/widgets/calendar_event_action_banner_widget.dart'; import 'package:tmail_ui_user/features/email/presentation/widgets/email_action_cupertino_action_sheet_action_builder.dart'; import 'package:tmail_ui_user/features/email/presentation/widgets/information_sender_and_receiver_builder.dart'; import 'package:tmail_ui_user/features/manage_account/presentation/extensions/vacation_response_extension.dart'; @@ -34,7 +36,7 @@ import 'package:tmail_ui_user/features/manage_account/presentation/vacation/widg import 'package:tmail_ui_user/main/localizations/app_localizations.dart'; import 'package:tmail_ui_user/main/utils/app_utils.dart'; -class EmailView extends GetWidget { +class EmailView extends GetWidget with AppLoaderMixin { final responsiveUtils = Get.find(); final imagePaths = Get.find(); @@ -278,38 +280,43 @@ class EmailView extends GetWidget { imagePaths: imagePaths, responsiveUtils: responsiveUtils, ), - _buildLoadingView(), + _buildLoadingContentView(), _buildAttachments(context), + Obx(() { + if (controller.calendarEvent.value != null) { + return CalendarEventActionBannerWidget( + calendarEvent: controller.calendarEvent.value!, + listFromEmailAddress: controller.currentEmail?.from + ); + } else { + return const SizedBox.shrink(); + } + }), if (PlatformInfo.isWeb) Expanded(child: Padding( - padding: EdgeInsets.only( - left: AppUtils.isDirectionRTL(context) ? 0 : 16, - right: AppUtils.isDirectionRTL(context) ? 16 : 0, - bottom: 16 - ), + padding: const EdgeInsetsDirectional.only(start: 16, bottom: 16), child: _buildEmailContent(context, constraints, email) )) else Padding( - padding: const EdgeInsets.all(16), - child: _buildEmailContent(context, constraints, email)) + padding: const EdgeInsetsDirectional.symmetric( + vertical: EmailViewStyles.mobileContentVerticalMargin, + horizontal: EmailViewStyles.mobileContentHorizontalMargin + ), + child: _buildEmailContent(context, constraints, email) + ) ], ); }); } - Widget _buildLoadingView() { + Widget _buildLoadingContentView() { return Obx(() { return controller.viewState.value.fold( (failure) => const SizedBox.shrink(), (success) { - if (success is LoadingState) { - return const Align(alignment: Alignment.topCenter, child: Padding( - padding: EdgeInsets.all(16), - child: SizedBox( - width: 30, - height: 30, - child: CupertinoActivityIndicator(color: AppColor.colorLoading)))); + if (success is GetEmailContentLoading) { + return loadingWidget; } else { return const SizedBox.shrink(); } @@ -329,7 +336,7 @@ class EmailView extends GetWidget { Widget _buildAttachmentsBody(BuildContext context, List attachments) { return Container( color: Colors.white, - padding: const EdgeInsets.only(left: 16, right: 16, bottom: 12, top: 10), + padding: const EdgeInsetsDirectional.symmetric(vertical: 12, horizontal: 16), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildAttachmentsHeader(context, attachments), diff --git a/lib/features/email/presentation/extensions/calendar_event_extension.dart b/lib/features/email/presentation/extensions/calendar_event_extension.dart new file mode 100644 index 0000000000..92f1428f95 --- /dev/null +++ b/lib/features/email/presentation/extensions/calendar_event_extension.dart @@ -0,0 +1,178 @@ + +import 'package:collection/collection.dart'; +import 'package:core/presentation/extensions/color_extension.dart'; +import 'package:core/presentation/resources/image_paths.dart'; +import 'package:core/utils/app_logger.dart'; +import 'package:flutter/material.dart'; +import 'package:jmap_dart_client/jmap/mail/calendar/calendar_event.dart'; +import 'package:jmap_dart_client/jmap/mail/calendar/properties/attendee/calendar_attendee.dart'; +import 'package:jmap_dart_client/jmap/mail/calendar/properties/attendee/calendar_attendee_participation_status.dart'; +import 'package:jmap_dart_client/jmap/mail/calendar/properties/event_method.dart'; +import 'package:tmail_ui_user/main/localizations/app_localizations.dart'; + +extension CalendarEventExtension on CalendarEvent { + + Color getColorEventActionBanner(String senderEmailAddress) { + switch(method) { + case EventMethod.request: + case EventMethod.add: + return AppColor.colorInvitedEventActionText; + case EventMethod.refresh: + case EventMethod.counter: + return AppColor.colorUpdatedEventActionText; + case EventMethod.cancel: + case EventMethod.declineCounter: + return AppColor.colorCanceledEventActionText; + case EventMethod.reply: + final matchedAttendee = findAttendeeHasUpdatedStatus(senderEmailAddress); + if (matchedAttendee != null) { + return getAttendeeMessageTextColor(matchedAttendee.participationStatus); + } else { + return Colors.transparent; + } + default: + return Colors.transparent; + } + } + + Color getColorEventActionText(String senderEmailAddress) { + switch(method) { + case EventMethod.request: + case EventMethod.add: + return AppColor.colorInvitedEventActionText; + case EventMethod.refresh: + case EventMethod.counter: + return AppColor.colorUpdatedEventActionText; + case EventMethod.cancel: + case EventMethod.declineCounter: + return AppColor.colorCanceledEventActionText; + case EventMethod.reply: + final matchedAttendee = findAttendeeHasUpdatedStatus(senderEmailAddress); + if (matchedAttendee != null) { + return getAttendeeMessageTextColor(matchedAttendee.participationStatus); + } else { + return Colors.transparent; + } + default: + return Colors.transparent; + } + } + + String getIconEventAction(ImagePaths imagePaths) { + switch(method) { + case EventMethod.request: + case EventMethod.add: + return imagePaths.icEventInvited; + case EventMethod.refresh: + return imagePaths.icEventUpdated; + case EventMethod.cancel: + return imagePaths.icEventCanceled; + default: + return ''; + } + } + + String getTitleEventAction(BuildContext context, String senderEmailAddress) { + switch(method) { + case EventMethod.request: + case EventMethod.add: + return AppLocalizations.of(context).messageEventActionBannerOrganizerInvited; + case EventMethod.refresh: + return AppLocalizations.of(context).messageEventActionBannerOrganizerUpdated; + case EventMethod.cancel: + return AppLocalizations.of(context).messageEventActionBannerOrganizerCanceled; + case EventMethod.reply: + final matchedAttendee = findAttendeeHasUpdatedStatus(senderEmailAddress); + if (matchedAttendee != null) { + return getAttendeeMessageStatus(context, matchedAttendee.participationStatus); + } else { + return ''; + } + case EventMethod.counter: + return AppLocalizations.of(context).messageEventActionBannerAttendeeCounter; + case EventMethod.declineCounter: + return AppLocalizations.of(context).messageEventActionBannerAttendeeCounterDeclined; + default: + return ''; + } + } + + String getSubTitleEventAction(BuildContext context) { + switch(method) { + case EventMethod.refresh: + return AppLocalizations.of(context).subMessageEventActionBannerUpdated; + case EventMethod.cancel: + return AppLocalizations.of(context).subMessageEventActionBannerCanceled; + default: + return ''; + } + } + + String getUserNameEventAction({ + required BuildContext context, + required ImagePaths imagePaths, + required String senderEmailAddress + }) { + switch(method) { + case EventMethod.request: + case EventMethod.add: + case EventMethod.refresh: + case EventMethod.cancel: + case EventMethod.declineCounter: + return getOrganizerName(context); + case EventMethod.reply: + case EventMethod.counter: + return getAttendeeName(context, senderEmailAddress); + default: + return ''; + } + } + + String getOrganizerName(BuildContext context) => organizer?.name ?? AppLocalizations.of(context).you; + + String getAttendeeName(BuildContext context, String senderEmailAddress) { + final matchedAttendee = findAttendeeHasUpdatedStatus(senderEmailAddress); + if (matchedAttendee != null) { + return matchedAttendee.name?.name ?? AppLocalizations.of(context).anAttendee; + } else { + return AppLocalizations.of(context).anAttendee; + } + } + + CalendarAttendee? findAttendeeHasUpdatedStatus(String senderEmailAddress) { + if (participants?.isNotEmpty == true) { + final listMatchedAttendee = participants + !.where((attendee) => attendee.mailto?.mailAddress.value == senderEmailAddress) + .whereNotNull(); + log('CalendarEventExtension::findAttendeeHasUpdatedStatus:listMatchedAttendee: $listMatchedAttendee'); + if (listMatchedAttendee.isNotEmpty) { + return listMatchedAttendee.first; + } + } + return null; + } + + String getAttendeeMessageStatus(BuildContext context, CalendarAttendeeParticipationStatus? status) { + if (status == CalendarAttendeeParticipationStatus('ACCEPTED')) { + return AppLocalizations.of(context).messageEventActionBannerAttendeeAccepted; + } else if (status == CalendarAttendeeParticipationStatus('TENTATIVE')) { + return AppLocalizations.of(context).messageEventActionBannerAttendeeTentative; + } else if (status == CalendarAttendeeParticipationStatus('DECLINED')) { + return AppLocalizations.of(context).messageEventActionBannerAttendeeDeclined; + } else { + return ''; + } + } + + Color getAttendeeMessageTextColor(CalendarAttendeeParticipationStatus? status) { + if (status == CalendarAttendeeParticipationStatus('ACCEPTED')) { + return AppColor.colorUpdatedEventActionText; + } else if (status == CalendarAttendeeParticipationStatus('TENTATIVE')) { + return AppColor.colorMaybeEventActionText; + } else if (status == CalendarAttendeeParticipationStatus('DECLINED')) { + return AppColor.colorCanceledEventActionText; + } else { + return Colors.transparent; + } + } +} \ No newline at end of file diff --git a/lib/features/email/presentation/styles/calendar_event_action_banner_styles.dart b/lib/features/email/presentation/styles/calendar_event_action_banner_styles.dart new file mode 100644 index 0000000000..dd23222d54 --- /dev/null +++ b/lib/features/email/presentation/styles/calendar_event_action_banner_styles.dart @@ -0,0 +1,9 @@ + +class CalendarEventActionBannerStyles { + static const double borderRadius = 12; + static const double contentPadding = 12; + static const double viewHorizontalMargin = 16; + static const double titleTextSize = 16; + static const double subTileTextSize = 13; + static const double iconSize = 20; +} \ No newline at end of file diff --git a/lib/features/email/presentation/styles/email_view_styles.dart b/lib/features/email/presentation/styles/email_view_styles.dart new file mode 100644 index 0000000000..1f331a7560 --- /dev/null +++ b/lib/features/email/presentation/styles/email_view_styles.dart @@ -0,0 +1,5 @@ + +class EmailViewStyles { + static const double mobileContentHorizontalMargin = 16; + static const double mobileContentVerticalMargin = 12; +} \ No newline at end of file diff --git a/lib/features/email/presentation/widgets/calendar_event_action_banner_widget.dart b/lib/features/email/presentation/widgets/calendar_event_action_banner_widget.dart new file mode 100644 index 0000000000..7d60888388 --- /dev/null +++ b/lib/features/email/presentation/widgets/calendar_event_action_banner_widget.dart @@ -0,0 +1,103 @@ + +import 'package:core/presentation/extensions/color_extension.dart'; +import 'package:core/presentation/resources/image_paths.dart'; +import 'package:core/utils/app_logger.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:get/get.dart'; +import 'package:jmap_dart_client/jmap/mail/calendar/calendar_event.dart'; +import 'package:jmap_dart_client/jmap/mail/email/email_address.dart'; +import 'package:model/extensions/email_address_extension.dart'; +import 'package:tmail_ui_user/features/email/presentation/extensions/calendar_event_extension.dart'; +import 'package:tmail_ui_user/features/email/presentation/styles/calendar_event_action_banner_styles.dart'; + +class CalendarEventActionBannerWidget extends StatelessWidget { + + final CalendarEvent calendarEvent; + final Set? listFromEmailAddress; + + const CalendarEventActionBannerWidget({ + super.key, + required this.calendarEvent, + required this.listFromEmailAddress, + }); + + @override + Widget build(BuildContext context) { + final imagePaths = Get.find(); + return Container( + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(CalendarEventActionBannerStyles.borderRadius)), + color: calendarEvent.getColorEventActionBanner(_getSenderEmailAddress()).withOpacity(0.12) + ), + padding: const EdgeInsets.all(CalendarEventActionBannerStyles.contentPadding), + margin: const EdgeInsets.symmetric( + horizontal: CalendarEventActionBannerStyles.viewHorizontalMargin, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (calendarEvent.getIconEventAction(imagePaths).isNotEmpty) + Padding( + padding: const EdgeInsetsDirectional.only(end: 8), + child: SvgPicture.asset( + calendarEvent.getIconEventAction(imagePaths), + width: CalendarEventActionBannerStyles.iconSize, + height: CalendarEventActionBannerStyles.iconSize, + fit: BoxFit.fill, + ), + ), + Expanded(child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + RichText( + text: TextSpan( + style: TextStyle( + fontSize: CalendarEventActionBannerStyles.titleTextSize, + fontWeight: FontWeight.w400, + color: calendarEvent.getColorEventActionText(_getSenderEmailAddress()) + ), + children: [ + TextSpan( + text: calendarEvent.getUserNameEventAction( + context: context, + imagePaths: imagePaths, + senderEmailAddress: _getSenderEmailAddress() + ), + style: TextStyle( + color: calendarEvent.getColorEventActionText(_getSenderEmailAddress()), + fontSize: CalendarEventActionBannerStyles.titleTextSize, + fontWeight: FontWeight.w700 + ), + ), + TextSpan(text: calendarEvent.getTitleEventAction(context, _getSenderEmailAddress())) + ] + ) + ), + if (calendarEvent.getSubTitleEventAction(context).isNotEmpty) + Text( + calendarEvent.getSubTitleEventAction(context), + style: const TextStyle( + color: AppColor.colorSubTitleEventActionText, + fontSize: CalendarEventActionBannerStyles.subTileTextSize, + fontWeight: FontWeight.w400 + ), + ) + ] + )) + ] + ), + ); + } + + String _getSenderEmailAddress() { + if (listFromEmailAddress?.isNotEmpty == true) { + final senderEmailAddress = listFromEmailAddress!.first.emailAddress; + log('CalendarEventActionBannerWidget::getSenderEmailAddress: $senderEmailAddress'); + return senderEmailAddress; + } else { + return ''; + } + } +} \ No newline at end of file diff --git a/lib/features/mailbox_dashboard/presentation/bindings/mailbox_dashboard_bindings.dart b/lib/features/mailbox_dashboard/presentation/bindings/mailbox_dashboard_bindings.dart index 49f8b85dda..fc8f47a963 100644 --- a/lib/features/mailbox_dashboard/presentation/bindings/mailbox_dashboard_bindings.dart +++ b/lib/features/mailbox_dashboard/presentation/bindings/mailbox_dashboard_bindings.dart @@ -25,7 +25,7 @@ import 'package:tmail_ui_user/features/email/domain/usecases/mark_as_star_email_ import 'package:tmail_ui_user/features/email/domain/usecases/move_to_mailbox_interactor.dart'; import 'package:tmail_ui_user/features/email/presentation/controller/email_supervisor_controller.dart'; import 'package:tmail_ui_user/features/email/presentation/controller/single_email_controller.dart'; -import 'package:tmail_ui_user/features/email/presentation/email_bindings.dart'; +import 'package:tmail_ui_user/features/email/presentation/bindings/email_bindings.dart'; import 'package:tmail_ui_user/features/login/data/datasource/account_datasource.dart'; import 'package:tmail_ui_user/features/login/data/datasource/authentication_oidc_datasource.dart'; import 'package:tmail_ui_user/features/login/data/datasource_impl/authentication_oidc_datasource_impl.dart'; diff --git a/lib/l10n/intl_messages.arb b/lib/l10n/intl_messages.arb index b5af622736..0e2cb5f62f 100644 --- a/lib/l10n/intl_messages.arb +++ b/lib/l10n/intl_messages.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2023-07-12T18:53:48.611802", + "@@last_modified": "2023-07-24T18:44:17.206723", "initializing_data": "Initializing data...", "@initializing_data": { "type": "text", @@ -2975,5 +2975,77 @@ "placeholders": { "maxSize": {} } + }, + "messageEventActionBannerOrganizerInvited": " has invited you in to a meeting", + "@messageEventActionBannerOrganizerInvited": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "messageEventActionBannerOrganizerUpdated": " has updated a meeting", + "@messageEventActionBannerOrganizerUpdated": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "messageEventActionBannerOrganizerCanceled": " has canceled a meeting", + "@messageEventActionBannerOrganizerCanceled": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "subMessageEventActionBannerUpdated": "\"The time has been updated to better suit all of you\"", + "@subMessageEventActionBannerUpdated": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "subMessageEventActionBannerCanceled": "\"We are canceling the event due to bad weather.\"", + "@subMessageEventActionBannerCanceled": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "anAttendee": "An attendee", + "@anAttendee": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "you": "You", + "@you": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "messageEventActionBannerAttendeeAccepted": " has accepted this invitation", + "@messageEventActionBannerAttendeeAccepted": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "messageEventActionBannerAttendeeTentative": " has replied \"Maybe\" to this invitation", + "@messageEventActionBannerAttendeeTentative": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "messageEventActionBannerAttendeeDeclined": " has declined this invitation", + "@messageEventActionBannerAttendeeDeclined": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "messageEventActionBannerAttendeeCounter": " has proposed changes to the event", + "@messageEventActionBannerAttendeeCounter": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "messageEventActionBannerAttendeeCounterDeclined": "Your counter proposal was declined", + "@messageEventActionBannerAttendeeCounterDeclined": { + "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 98215ff52f..4065c58cb5 100644 --- a/lib/main/localizations/app_localizations.dart +++ b/lib/main/localizations/app_localizations.dart @@ -3071,4 +3071,76 @@ class AppLocalizations { name: 'pleaseChooseAnImageSizeCorrectly', args: [maxSize]); } + + String get messageEventActionBannerOrganizerInvited { + return Intl.message( + ' has invited you in to a meeting', + name: 'messageEventActionBannerOrganizerInvited'); + } + + String get messageEventActionBannerOrganizerUpdated { + return Intl.message( + ' has updated a meeting', + name: 'messageEventActionBannerOrganizerUpdated'); + } + + String get messageEventActionBannerOrganizerCanceled { + return Intl.message( + ' has canceled a meeting', + name: 'messageEventActionBannerOrganizerCanceled'); + } + + String get subMessageEventActionBannerUpdated { + return Intl.message( + '"The time has been updated to better suit all of you"', + name: 'subMessageEventActionBannerUpdated'); + } + + String get subMessageEventActionBannerCanceled { + return Intl.message( + '"We are canceling the event due to bad weather."', + name: 'subMessageEventActionBannerCanceled'); + } + + String get anAttendee { + return Intl.message( + 'An attendee', + name: 'anAttendee'); + } + + String get you { + return Intl.message( + 'You', + name: 'you'); + } + + String get messageEventActionBannerAttendeeAccepted { + return Intl.message( + ' has accepted this invitation', + name: 'messageEventActionBannerAttendeeAccepted'); + } + + String get messageEventActionBannerAttendeeTentative { + return Intl.message( + ' has replied "Maybe" to this invitation', + name: 'messageEventActionBannerAttendeeTentative'); + } + + String get messageEventActionBannerAttendeeDeclined { + return Intl.message( + ' has declined this invitation', + name: 'messageEventActionBannerAttendeeDeclined'); + } + + String get messageEventActionBannerAttendeeCounter { + return Intl.message( + ' has proposed changes to the event', + name: 'messageEventActionBannerAttendeeCounter'); + } + + String get messageEventActionBannerAttendeeCounterDeclined { + return Intl.message( + 'Your counter proposal was declined', + name: 'messageEventActionBannerAttendeeCounterDeclined'); + } } \ No newline at end of file diff --git a/model/lib/email/attachment.dart b/model/lib/email/attachment.dart index 2d56989d2a..2e78df7719 100644 --- a/model/lib/email/attachment.dart +++ b/model/lib/email/attachment.dart @@ -50,6 +50,8 @@ class Attachment with EquatableMixin { } } + bool get isCalendarEvent => type?.subtype == 'ics' || type?.subtype == 'calendar'; + @override List get props => [partId, blobId, size, name, type, cid, disposition]; }