diff --git a/assets/images/ic_expand_arrows.svg b/assets/images/ic_expand_arrows.svg new file mode 100644 index 000000000..b9ac554f7 --- /dev/null +++ b/assets/images/ic_expand_arrows.svg @@ -0,0 +1,4 @@ + + + + diff --git a/core/lib/presentation/extensions/color_extension.dart b/core/lib/presentation/extensions/color_extension.dart index 7f5ef0877..272a1e5b5 100644 --- a/core/lib/presentation/extensions/color_extension.dart +++ b/core/lib/presentation/extensions/color_extension.dart @@ -245,6 +245,7 @@ extension AppColor on Color { static const blue400 = Color(0xFF80BDFF); static const blue900 = Color(0xFF0F76E7); static const m3Tertiary = Color(0xFF8C9CAF); + static const lightIconTertiary = Color(0xFFB8C1CC); static const m3Neutral70 = Color(0xFFAEAAAE); static const m3Neutral90 = Color(0xFFE6E1E5); static const grayBackgroundColor = Color(0xFFF3F6F9); diff --git a/core/lib/presentation/extensions/list_extensions.dart b/core/lib/presentation/extensions/list_extensions.dart index beb45f971..57d37bf91 100644 --- a/core/lib/presentation/extensions/list_extensions.dart +++ b/core/lib/presentation/extensions/list_extensions.dart @@ -20,4 +20,21 @@ extension ListExtensions on List { int countOccurrences(T value) { return where((element) => element == value).length; } + + List> chunks(int chunkSize) { + if (chunkSize <= 0) { + throw ArgumentError('Chunk size must be greater than 0', 'chunkSize'); + } + if (isEmpty) { + return []; + } + final result = >[]; + final totalChunks = (length / chunkSize).ceil(); + for (var i = 0; i < totalChunks; i++) { + final start = i * chunkSize; + final end = (start + chunkSize).clamp(0, length); + result.add(sublist(start, end)); + } + return result; + } } \ No newline at end of file diff --git a/core/lib/presentation/resources/image_paths.dart b/core/lib/presentation/resources/image_paths.dart index 379370f16..3bf6f4e14 100644 --- a/core/lib/presentation/resources/image_paths.dart +++ b/core/lib/presentation/resources/image_paths.dart @@ -236,6 +236,7 @@ class ImagePaths { String get icTwakeWorkplace => _getIconPath('icon_twp.png'); String get animLottieTmail => _getAnimationPath('lottie-tmail.json'); + String get icExpandArrows => _getImagePath('ic_expand_arrows.svg'); String _getImagePath(String imageName) { return AssetsPaths.images + imageName; diff --git a/core/lib/presentation/views/html_viewer/html_content_viewer_on_web_widget.dart b/core/lib/presentation/views/html_viewer/html_content_viewer_on_web_widget.dart index 7b4ab27cf..4a7b6246f 100644 --- a/core/lib/presentation/views/html_viewer/html_content_viewer_on_web_widget.dart +++ b/core/lib/presentation/views/html_viewer/html_content_viewer_on_web_widget.dart @@ -193,6 +193,11 @@ class _HtmlContentViewerOnWebState extends State { } } } + + const resizeObserver = new ResizeObserver((entries) => { + var height = document.body.scrollHeight; + window.parent.postMessage(JSON.stringify({"view": "$_createdViewId", "type": "toDart: htmlHeight", "height": height}), "*"); + }); ${widget.mailtoDelegate != null ? ''' @@ -238,6 +243,8 @@ class _HtmlContentViewerOnWebState extends State { } ''' : ''} + + resizeObserver.observe(document.body); } '''; @@ -303,7 +310,7 @@ class _HtmlContentViewerOnWebState extends State { @override Widget build(BuildContext context) { return LayoutBuilder(builder: (context, constraint) { - minHeight = math.max(constraint.maxHeight, minHeight); + minHeight = math.min(constraint.maxHeight, minHeight); final child = Stack( children: [ if (_htmlData?.isNotEmpty == false) @@ -317,7 +324,7 @@ class _HtmlContentViewerOnWebState extends State { height: _actualHeight, width: _actualWidth, child: HtmlElementView( - key: ValueKey(_htmlData), + key: ValueKey('$_htmlData-${widget.key}'), viewType: _createdViewId, ), ); diff --git a/core/lib/presentation/views/html_viewer/html_content_viewer_widget.dart b/core/lib/presentation/views/html_viewer/html_content_viewer_widget.dart index be1565850..a3fe9fcdd 100644 --- a/core/lib/presentation/views/html_viewer/html_content_viewer_widget.dart +++ b/core/lib/presentation/views/html_viewer/html_content_viewer_widget.dart @@ -32,6 +32,7 @@ class HtmlContentViewer extends StatefulWidget { final double? maxHtmlContentHeight; final double minHtmlContentHeight; final double offsetHtmlContentHeight; + final bool keepAlive; final OnLoadWidthHtmlViewerAction? onLoadWidthHtmlViewer; final OnMailtoDelegateAction? onMailtoDelegateAction; @@ -47,6 +48,7 @@ class HtmlContentViewer extends StatefulWidget { this.direction, this.minHtmlContentHeight = ConstantsUI.htmlContentMinHeight, this.offsetHtmlContentHeight = ConstantsUI.htmlContentOffsetHeight, + this.keepAlive = false, this.keepWidthWhileLoading = false, this.contentPadding, this.useDefaultFont = false, @@ -63,7 +65,7 @@ class HtmlContentViewer extends StatefulWidget { State createState() => HtmlContentViewState(); } -class HtmlContentViewState extends State { +class HtmlContentViewState extends State with AutomaticKeepAliveClientMixin { late InAppWebViewController _webViewController; late double _actualHeight; @@ -130,6 +132,7 @@ class HtmlContentViewState extends State { @override Widget build(BuildContext context) { + super.build(context); final child = Stack(children: [ if (_htmlData == null) const SizedBox.shrink() @@ -352,4 +355,7 @@ class HtmlContentViewState extends State { _htmlData = null; super.dispose(); } + + @override + bool get wantKeepAlive => widget.keepAlive; } \ No newline at end of file diff --git a/core/test/presentation/extensions/list_extensions_test.dart b/core/test/presentation/extensions/list_extensions_test.dart new file mode 100644 index 000000000..4dd8e7cac --- /dev/null +++ b/core/test/presentation/extensions/list_extensions_test.dart @@ -0,0 +1,137 @@ +import 'package:core/presentation/extensions/list_extensions.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('list extensions test:', () { + group('chunks test:', () { + test('should return evenly sized chunks when length is divisible by chunkSize', () { + // Arrange + final sourceList = [1, 2, 3, 4, 5, 6]; + const chunkSize = 3; + final expectedOutput = [ + [1, 2, 3], + [4, 5, 6] + ]; + + // Act + final result = sourceList.chunks(chunkSize); + + // Assert + expect(result, equals(expectedOutput)); + }); + + test('should return chunks with the last chunk smaller when length is not divisible', () { + // Arrange + final sourceList = [1, 2, 3, 4, 5]; + const chunkSize = 3; + final expectedOutput = [ + [1, 2, 3], + [4, 5] + ]; + + // Act + final result = sourceList.chunks(chunkSize); + + // Assert + expect(result, equals(expectedOutput)); + }); + + test('should return chunks of size 1 when chunkSize is 1', () { + // Arrange + final sourceList = [1, 2, 3]; + const chunkSize = 1; + final expectedOutput = [ + [1], + [2], + [3] + ]; + + // Act + final result = sourceList.chunks(chunkSize); + + // Assert + expect(result, equals(expectedOutput)); + }); + + test('should return a single chunk when chunkSize equals list length', () { + // Arrange + final sourceList = [1, 2, 3, 4]; + const chunkSize = 4; + final expectedOutput = [ + [1, 2, 3, 4] + ]; + + // Act + final result = sourceList.chunks(chunkSize); + + // Assert + expect(result, equals(expectedOutput)); + }); + + test('should return a single chunk when chunkSize is greater than list length', () { + // Arrange + final sourceList = [1, 2, 3]; + const chunkSize = 5; + final expectedOutput = [ + [1, 2, 3] + ]; + + // Act + final result = sourceList.chunks(chunkSize); + + // Assert + expect(result, equals(expectedOutput)); + }); + + test('should return an empty list when the source list is empty', () { + // Arrange + final sourceList = []; // Explicitly typed empty list + const chunkSize = 3; + final expectedOutput = >[]; // Expected empty list of lists + + // Act + final result = sourceList.chunks(chunkSize); + + // Assert + expect(result, isEmpty); // Check if it's empty + expect(result, equals(expectedOutput)); // Also check for structural equality + }); + + test('should work with different list types (e.g., String)', () { + // Arrange + final sourceList = ['a', 'b', 'c', 'd', 'e']; + const chunkSize = 2; + final expectedOutput = [ + ['a', 'b'], + ['c', 'd'], + ['e'] + ]; + + // Act + final result = sourceList.chunks(chunkSize); + + // Assert + expect(result, equals(expectedOutput)); + }); + + test('should throw ArgumentError when chunkSize is 0', () { + // Arrange + final sourceList = [1, 2, 3]; + const chunkSize = 0; + + // Act & Assert + // We wrap the call that is expected to throw in a zero-argument function. + expect(() => sourceList.chunks(chunkSize), throwsArgumentError); + }); + + test('should throw ArgumentError when chunkSize is negative', () { + // Arrange + final sourceList = [1, 2, 3]; + const chunkSize = -1; + + // Act & Assert + expect(() => sourceList.chunks(chunkSize), throwsArgumentError); + }); + }); + }); +} \ No newline at end of file diff --git a/lib/features/base/upgradeable/upgrade_hive_database_steps_v18.dart b/lib/features/base/upgradeable/upgrade_hive_database_steps_v18.dart new file mode 100644 index 000000000..ebcf9fe43 --- /dev/null +++ b/lib/features/base/upgradeable/upgrade_hive_database_steps_v18.dart @@ -0,0 +1,17 @@ + +import 'package:tmail_ui_user/features/base/upgradeable/upgrade_database_steps.dart'; +import 'package:tmail_ui_user/features/caching/caching_manager.dart'; + +class UpgradeHiveDatabaseStepsV18 extends UpgradeDatabaseSteps { + + final CachingManager _cachingManager; + + UpgradeHiveDatabaseStepsV18(this._cachingManager); + + @override + Future onUpgrade(int oldVersion, int newVersion) async { + if (oldVersion > 0 && oldVersion < newVersion && newVersion == 18) { + await _cachingManager.clearAllEmailAndStateCache(); + } + } +} \ No newline at end of file diff --git a/lib/features/base/widget/email_avatar_builder.dart b/lib/features/base/widget/email_avatar_builder.dart index cddbfc6ed..67612181f 100644 --- a/lib/features/base/widget/email_avatar_builder.dart +++ b/lib/features/base/widget/email_avatar_builder.dart @@ -9,10 +9,12 @@ import 'package:model/extensions/presentation_email_extension.dart'; class EmailAvatarBuilder extends StatelessWidget { final PresentationEmail emailSelected; + final OnTapAvatarActionClick? onTapAvatarActionClick; const EmailAvatarBuilder({ Key? key, - required this.emailSelected + required this.emailSelected, + this.onTapAvatarActionClick, }) : super(key: key); @override @@ -22,6 +24,7 @@ class EmailAvatarBuilder extends StatelessWidget { ..size(50) ..addTextStyle(ThemeUtils.textStyleHeadingH4(color: Colors.white)) ..backgroundColor(AppColor.colorAvatar) + ..addOnTapActionClick(onTapAvatarActionClick ?? () {}) ..avatarColor(emailSelected.avatarColors)) .build(); } diff --git a/lib/features/base/widget/optional_expanded.dart b/lib/features/base/widget/optional_expanded.dart new file mode 100644 index 000000000..211992421 --- /dev/null +++ b/lib/features/base/widget/optional_expanded.dart @@ -0,0 +1,17 @@ +import 'package:flutter/material.dart'; + +class OptionalExpanded extends StatelessWidget { + const OptionalExpanded({ + super.key, + required this.expandedEnabled, + required this.child, + }); + + final bool expandedEnabled; + final Widget child; + + @override + Widget build(BuildContext context) { + return expandedEnabled ? Expanded(child: child) : child; + } +} \ No newline at end of file diff --git a/lib/features/base/widget/optional_scroll.dart b/lib/features/base/widget/optional_scroll.dart new file mode 100644 index 000000000..a57aa0533 --- /dev/null +++ b/lib/features/base/widget/optional_scroll.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; + +class OptionalScroll extends StatelessWidget { + const OptionalScroll({ + super.key, + required this.scrollEnabled, + required this.child, + this.scrollPhysics = const ClampingScrollPhysics(), + }); + + final bool scrollEnabled; + final Widget child; + final ScrollPhysics scrollPhysics; + + @override + Widget build(BuildContext context) { + return scrollEnabled + ? SingleChildScrollView( + physics : scrollPhysics, + child: child, + ) + : child; + } +} \ No newline at end of file diff --git a/lib/features/caching/config/cache_version.dart b/lib/features/caching/config/cache_version.dart index c79ad831a..394cff579 100644 --- a/lib/features/caching/config/cache_version.dart +++ b/lib/features/caching/config/cache_version.dart @@ -1,4 +1,4 @@ class CacheVersion { - static const int hiveDBVersion = 17; + static const int hiveDBVersion = 18; } \ No newline at end of file diff --git a/lib/features/caching/config/hive_cache_config.dart b/lib/features/caching/config/hive_cache_config.dart index ce988a31d..4b990bc1d 100644 --- a/lib/features/caching/config/hive_cache_config.dart +++ b/lib/features/caching/config/hive_cache_config.dart @@ -14,6 +14,7 @@ import 'package:tmail_ui_user/features/base/upgradeable/upgrade_hive_database_st import 'package:tmail_ui_user/features/base/upgradeable/upgrade_hive_database_steps_v15.dart'; import 'package:tmail_ui_user/features/base/upgradeable/upgrade_hive_database_steps_v16.dart'; import 'package:tmail_ui_user/features/base/upgradeable/upgrade_hive_database_steps_v17.dart'; +import 'package:tmail_ui_user/features/base/upgradeable/upgrade_hive_database_steps_v18.dart'; import 'package:tmail_ui_user/features/base/upgradeable/upgrade_hive_database_steps_v7.dart'; import 'package:tmail_ui_user/features/caching/caching_manager.dart'; import 'package:tmail_ui_user/features/caching/config/cache_version.dart'; @@ -91,6 +92,7 @@ class HiveCacheConfig { await UpgradeHiveDatabaseStepsV15(cachingManager).onUpgrade(oldVersion, newVersion); await UpgradeHiveDatabaseStepsV16(cachingManager).onUpgrade(oldVersion, newVersion); await UpgradeHiveDatabaseStepsV17(cachingManager).onUpgrade(oldVersion, newVersion); + await UpgradeHiveDatabaseStepsV18(cachingManager).onUpgrade(oldVersion, newVersion); if (oldVersion != newVersion) { await cachingManager.storeCacheVersion(newVersion); diff --git a/lib/features/composer/presentation/extensions/email_action_type_extension.dart b/lib/features/composer/presentation/extensions/email_action_type_extension.dart index f52162778..55c79e95f 100644 --- a/lib/features/composer/presentation/extensions/email_action_type_extension.dart +++ b/lib/features/composer/presentation/extensions/email_action_type_extension.dart @@ -153,6 +153,23 @@ extension EmailActionTypeExtension on EmailActionType { return imagePaths.icEdit; case EmailActionType.openInNewTab: return imagePaths.icOpenInNewTab; + case EmailActionType.printAll: + return imagePaths.icPrinter; + case EmailActionType.forward: + return imagePaths.icForward; + case EmailActionType.replyAll: + return imagePaths.icReplyAll; + case EmailActionType.replyToList: + return imagePaths.icReply; + case EmailActionType.moveToMailbox: + return imagePaths.icMoveEmail; + case EmailActionType.markAsStarred: + return imagePaths.icStar; + case EmailActionType.unMarkAsStarred: + return imagePaths.icUnStar; + case EmailActionType.moveToTrash: + case EmailActionType.deletePermanently: + return imagePaths.icDeleteComposer; default: return ''; } @@ -178,6 +195,24 @@ extension EmailActionTypeExtension on EmailActionType { return appLocalizations.editAsNewEmail; case EmailActionType.openInNewTab: return appLocalizations.openInNewTab; + case EmailActionType.printAll: + return appLocalizations.printAll; + case EmailActionType.forward: + return appLocalizations.forward; + case EmailActionType.replyAll: + return appLocalizations.reply_all; + case EmailActionType.replyToList: + return appLocalizations.replyToList; + case EmailActionType.moveToMailbox: + return appLocalizations.move_message; + case EmailActionType.markAsStarred: + return appLocalizations.mark_as_starred; + case EmailActionType.unMarkAsStarred: + return appLocalizations.not_starred; + case EmailActionType.moveToTrash: + return appLocalizations.move_to_trash; + case EmailActionType.deletePermanently: + return appLocalizations.delete_permanently; default: return ''; } diff --git a/lib/features/email/presentation/action/email_ui_action.dart b/lib/features/email/presentation/action/email_ui_action.dart index ab063f13e..4aee77e06 100644 --- a/lib/features/email/presentation/action/email_ui_action.dart +++ b/lib/features/email/presentation/action/email_ui_action.dart @@ -1,5 +1,8 @@ import 'package:jmap_dart_client/jmap/core/state.dart' as jmap; +import 'package:jmap_dart_client/jmap/mail/email/email.dart'; +import 'package:model/email/email_action_type.dart'; +import 'package:model/email/presentation_email.dart'; import 'package:tmail_ui_user/features/base/action/ui_action.dart'; class EmailUIAction extends UIAction { @@ -28,4 +31,28 @@ class HideEmailContentViewAction extends EmailUIAction {} class ShowEmailContentViewAction extends EmailUIAction {} -class RefreshAllEmailAction extends EmailUIAction {} \ No newline at end of file +class RefreshAllEmailAction extends EmailUIAction {} + +class CloseEmailInThreadDetailAction extends EmailUIAction { + final EmailId emailId; + + CloseEmailInThreadDetailAction(this.emailId); + + @override + List get props => [emailId]; +} +class PerformEmailActionInThreadDetailAction extends EmailUIAction { + PerformEmailActionInThreadDetailAction({ + required this.emailActionType, + required this.presentationEmail, + }); + + final EmailActionType emailActionType; + final PresentationEmail presentationEmail; + + @override + List get props => [ + emailActionType, + presentationEmail, + ]; +} \ No newline at end of file diff --git a/lib/features/email/presentation/bindings/email_bindings.dart b/lib/features/email/presentation/bindings/email_bindings.dart index dfc18ff45..7851a978b 100644 --- a/lib/features/email/presentation/bindings/email_bindings.dart +++ b/lib/features/email/presentation/bindings/email_bindings.dart @@ -1,5 +1,6 @@ import 'package:core/core.dart'; import 'package:get/get.dart'; +import 'package:jmap_dart_client/jmap/mail/email/email.dart'; import 'package:tmail_ui_user/features/base/base_bindings.dart'; import 'package:tmail_ui_user/features/caching/utils/local_storage_manager.dart'; import 'package:tmail_ui_user/features/caching/utils/session_storage_manager.dart'; @@ -32,7 +33,6 @@ import 'package:tmail_ui_user/features/email/domain/usecases/preview_email_from_ import 'package:tmail_ui_user/features/email/domain/usecases/print_email_interactor.dart'; import 'package:tmail_ui_user/features/email/domain/usecases/get_html_content_from_attachment_interactor.dart'; import 'package:tmail_ui_user/features/email/domain/usecases/store_opened_email_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/login/data/network/interceptors/authorization_interceptors.dart'; import 'package:tmail_ui_user/features/login/domain/repository/account_repository.dart'; @@ -62,10 +62,14 @@ import 'package:tmail_ui_user/main/exceptions/remote_exception_thrower.dart'; import 'package:tmail_ui_user/main/utils/ios_sharing_manager.dart'; class EmailBindings extends BaseBindings { + final EmailId? currentEmailId; + + EmailBindings({this.currentEmailId}); + + String? get tag => currentEmailId?.id.value; @override void bindingsController() { - Get.put(EmailSupervisorController()); Get.put(SingleEmailController( Get.find(), Get.find(), @@ -83,7 +87,8 @@ class EmailBindings extends BaseBindings { Get.find(), Get.find(), Get.find(), - )); + currentEmailId: currentEmailId, + ), tag: tag); } @override diff --git a/lib/features/email/presentation/controller/single_email_controller.dart b/lib/features/email/presentation/controller/single_email_controller.dart index ec59e1a8e..a70aa5614 100644 --- a/lib/features/email/presentation/controller/single_email_controller.dart +++ b/lib/features/email/presentation/controller/single_email_controller.dart @@ -101,7 +101,6 @@ import 'package:tmail_ui_user/features/email/domain/usecases/send_receipt_to_sen import 'package:tmail_ui_user/features/email/domain/usecases/store_opened_email_interactor.dart'; import 'package:tmail_ui_user/features/email/presentation/action/email_ui_action.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/extensions/attachment_extension.dart'; import 'package:tmail_ui_user/features/email/presentation/extensions/calendar_attendee_extension.dart'; import 'package:tmail_ui_user/features/email/presentation/extensions/calendar_organizer_extension.dart'; @@ -135,8 +134,10 @@ import 'package:tmail_ui_user/features/manage_account/domain/usecases/create_new import 'package:tmail_ui_user/features/manage_account/domain/usecases/get_all_identities_interactor.dart'; import 'package:tmail_ui_user/features/manage_account/presentation/extensions/datetime_extension.dart'; 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/search/email/presentation/search_email_controller.dart'; import 'package:tmail_ui_user/features/thread/presentation/model/delete_action_type.dart'; +import 'package:tmail_ui_user/features/thread_detail/presentation/extension/focus_thread_detail_expanded_email.dart'; +import 'package:tmail_ui_user/features/thread_detail/presentation/thread_detail_controller.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/localizations/localization_service.dart'; @@ -156,7 +157,6 @@ import 'package:twake_previewer_flutter/twake_plain_text_previewer/twake_plain_t class SingleEmailController extends BaseController with AppLoaderMixin { final mailboxDashBoardController = Get.find(); - final emailSupervisorController = Get.find(); final _downloadManager = Get.find(); final _printUtils = Get.find(); final _attachmentListScrollController = ScrollController(); @@ -177,6 +177,7 @@ class SingleEmailController extends BaseController with AppLoaderMixin { final GetHtmlContentFromAttachmentInteractor _getHtmlContentFromAttachmentInteractor; final DownloadAllAttachmentsForWebInteractor _downloadAllAttachmentsForWebInteractor; final ExportAllAttachmentsInteractor _exportAllAttachmentsInteractor; + final EmailId? _currentEmailId; CreateNewEmailRuleFilterInteractor? _createNewEmailRuleFilterInteractor; SendReceiptToSenderInteractor? _sendReceiptToSenderInteractor; @@ -185,6 +186,7 @@ class SingleEmailController extends BaseController with AppLoaderMixin { MaybeCalendarEventInteractor? _maybeCalendarEventInteractor; RejectCalendarEventInteractor? _rejectCalendarEventInteractor; AcceptCounterCalendarEventInteractor? _acceptCounterCalendarEventInteractor; + ThreadDetailController? _threadDetailController; final emailContents = RxnString(); final attachments = [].obs; @@ -198,15 +200,19 @@ class SingleEmailController extends BaseController with AppLoaderMixin { final attendanceStatus = Rxn(); final htmlContentViewKey = GlobalKey(); - EmailId? _currentEmailId; Identity? _identitySelected; ButtonState? _printEmailButtonState; + final obxListeners = []; final StreamController> _downloadProgressStateController = StreamController>.broadcast(); Stream> get downloadProgressState => _downloadProgressStateController.stream; - PresentationEmail? get currentEmail => mailboxDashBoardController.selectedEmail.value; + PresentationEmail? get currentEmail { + return _currentEmailId == null + ? null + : _threadDetailController?.emailIdsPresentation[_currentEmailId]; + } bool get calendarEventProcessing => viewState.value.fold( (failure) => false, @@ -238,11 +244,14 @@ class SingleEmailController extends BaseController with AppLoaderMixin { this._previewEmailFromEmlFileInteractor, this._getHtmlContentFromAttachmentInteractor, this._downloadAllAttachmentsForWebInteractor, - this._exportAllAttachmentsInteractor, - ); + this._exportAllAttachmentsInteractor, { + EmailId? currentEmailId, + }) : _currentEmailId = currentEmailId; @override void onInit() { + _threadDetailController = getBinding(); + _injectCalendarEventBindings(session, accountId); _registerObxStreamListener(); _listenDownloadAttachmentProgressState(); super.onInit(); @@ -250,6 +259,7 @@ class SingleEmailController extends BaseController with AppLoaderMixin { @override void onClose() { + _threadDetailController = null; _downloadProgressStateController.close(); _attachmentListScrollController.dispose(); super.onClose(); @@ -357,28 +367,15 @@ class SingleEmailController extends BaseController with AppLoaderMixin { } void _registerObxStreamListener() { - ever(mailboxDashBoardController.accountId, (accountId) { - if (accountId is AccountId) { - _injectAndGetInteractorBindings( - session, - accountId - ); - } + if (accountId != null) { + _injectAndGetInteractorBindings(session, accountId!); + } + WidgetsBinding.instance.addPostFrameCallback((_) { + _handleOpenEmailDetailedView(); }); - ever( - mailboxDashBoardController.selectedEmail, - _handleOpenEmailDetailedView - ); - - ever(mailboxDashBoardController.emailUIAction, (action) { + obxListeners.add(ever(mailboxDashBoardController.emailUIAction, (action) { if (action is CloseEmailDetailedViewToRedirectToTheInboxAction) { - if (emailSupervisorController.supportedPageView.isTrue) { - emailSupervisorController.popEmailQueue(_currentEmailId); - emailSupervisorController.setCurrentEmailIndex(-1); - emailSupervisorController.disposePageViewController(); - } - _updateCurrentEmailId(null); _resetToOriginalValue(isEmailClosing: true); mailboxDashBoardController.clearSelectedEmail(); mailboxDashBoardController.dispatchRoute(DashboardRoutes.thread); @@ -392,42 +389,54 @@ class SingleEmailController extends BaseController with AppLoaderMixin { } else if (action is ShowEmailContentViewAction) { isEmailContentHidden.value = false; mailboxDashBoardController.clearEmailUIAction(); + } else if (action is PerformEmailActionInThreadDetailAction) { + if (action.presentationEmail.id != _currentEmailId) return; + pressEmailAction( + action.emailActionType, + action.presentationEmail, + ); + } else if (action is CloseEmailInThreadDetailAction) { + if (action.emailId != _currentEmailId) return; + closeEmailView(context: currentContext); + for (var worker in obxListeners) { + worker.dispose(); + } + Get.delete(tag: _currentEmailId?.id.value); } - }); + })); - ever(mailboxDashBoardController.viewState, (viewState) { + obxListeners.add(ever(mailboxDashBoardController.viewState, (viewState) { viewState.map((success) { if (success is UnsubscribeEmailSuccess) { emailUnsubscribe.value = null; } }); - }); + })); + if (_threadDetailController != null) { + obxListeners.add(ever( + _threadDetailController!.currentExpandedEmailId, + (emailId) { + if (emailId == null || emailId != _currentEmailId) return; + + _threadDetailController!.currentEmailLoaded.value = currentEmailLoaded.value; + }, + )); + } } - bool isListEmailContainSelectedEmail(PresentationEmail selectedEmail) { - return emailSupervisorController.currentListEmail.isNotEmpty - && emailSupervisorController.currentListEmail.listEmailIds.contains(selectedEmail.id); - } - - void _handleOpenEmailDetailedView(PresentationEmail? selectedEmail) { - if (selectedEmail == null || _currentEmailId == selectedEmail.id) { + void _handleOpenEmailDetailedView() { + if (currentEmail == null) { log('SingleEmailController::_handleOpenEmailDetailedView(): email unselected'); return; } emailLoadedViewState.value = Right(GetEmailContentLoading()); - emailSupervisorController.updateNewCurrentListEmail(); - _updateCurrentEmailId(selectedEmail.id); _resetToOriginalValue(); - if (isListEmailContainSelectedEmail(selectedEmail)) { - _createMultipleEmailViewAsPageView(selectedEmail.id!); - } else { - _createSingleEmailView(selectedEmail.id!); - } + _createSingleEmailView(currentEmail!.id!); - if (!selectedEmail.hasRead) { - markAsEmailRead(selectedEmail, ReadActions.markAsRead, MarkReadAction.tap); + if (!currentEmail!.hasRead) { + markAsEmailRead(currentEmail!, ReadActions.markAsRead, MarkReadAction.tap); } if (mailboxDashBoardController.listIdentities.isEmpty) { @@ -437,20 +446,8 @@ class SingleEmailController extends BaseController with AppLoaderMixin { } } - void _updateCurrentEmailId(EmailId? emailId) { - _currentEmailId = emailId; - } - - void _createMultipleEmailViewAsPageView(EmailId emailId) { - log('SingleEmailController::_createMultipleEmailViewAsPageView():'); - emailSupervisorController.supportedPageView.value = true; - emailSupervisorController.createPageControllerAndJumpToEmailById(emailId); - _getEmailContentAction(emailId); - } - void _createSingleEmailView(EmailId emailId) { log('SingleEmailController::_createSingleEmailView():'); - emailSupervisorController.supportedPageView.value = false; _getEmailContentAction(emailId); } @@ -461,7 +458,7 @@ class SingleEmailController extends BaseController with AppLoaderMixin { (failure) => null, (success) { if (success is StartDownloadAttachmentForWeb && !success.previewerSupported) { - emailSupervisorController.mailboxDashBoardController.addDownloadTask( + mailboxDashBoardController.addDownloadTask( DownloadTaskState( taskId: success.taskId, attachment: success.attachment, @@ -480,7 +477,7 @@ class SingleEmailController extends BaseController with AppLoaderMixin { final percent = success.progress.round(); log('SingleEmailController::DownloadingAttachmentForWeb(): $percent%'); - emailSupervisorController.mailboxDashBoardController.updateDownloadTask( + mailboxDashBoardController.updateDownloadTask( success.taskId, (currentTask) { final newTask = currentTask.copyWith( @@ -491,7 +488,7 @@ class SingleEmailController extends BaseController with AppLoaderMixin { return newTask; }); } else if (success is StartDownloadAllAttachmentsForWeb) { - emailSupervisorController.mailboxDashBoardController.addDownloadTask( + mailboxDashBoardController.addDownloadTask( DownloadTaskState( taskId: success.taskId, attachment: success.attachment, @@ -510,7 +507,7 @@ class SingleEmailController extends BaseController with AppLoaderMixin { final percent = success.progress.round(); log('SingleEmailController::DownloadingAttachmentForWeb(): $percent%'); - emailSupervisorController.mailboxDashBoardController.updateDownloadTask( + mailboxDashBoardController.updateDownloadTask( success.taskId, (currentTask) { final newTask = currentTask.copyWith( @@ -590,59 +587,42 @@ class SingleEmailController extends BaseController with AppLoaderMixin { } void _getEmailContentAction(EmailId emailId) { - final emailLoaded = emailSupervisorController.getEmailInQueueByEmailId(emailId); + if (session != null && accountId != null) { + try { + final baseDownloadUrl = session!.getDownloadUrl(jmapUrl: dynamicUrlInterceptors.jmapUrl); + TransformConfiguration transformConfiguration = PlatformInfo.isWeb + ? TransformConfiguration.forPreviewEmailOnWeb() + : TransformConfiguration.forPreviewEmail(); - if (emailLoaded != null) { - consumeState(Stream.value(Right( - GetEmailContentSuccess( - htmlEmailContent: emailLoaded.htmlContent, - attachments: emailLoaded.attachments, - inlineImages: emailLoaded.inlineImages, - emailCurrent: emailLoaded.emailCurrent - ) - ))); - } else { - if (session != null && accountId != null) { - try { - final baseDownloadUrl = session!.getDownloadUrl(jmapUrl: dynamicUrlInterceptors.jmapUrl); - TransformConfiguration transformConfiguration = PlatformInfo.isWeb - ? TransformConfiguration.forPreviewEmailOnWeb() - : TransformConfiguration.forPreviewEmail(); - - consumeState(_getEmailContentInteractor.execute( - session!, - accountId!, - emailId, - baseDownloadUrl, - transformConfiguration - )); - } catch (e) { - logError('SingleEmailController::_getEmailContentAction(): $e'); - consumeState(Stream.value(Left(GetEmailContentFailure( - e, - onRetry: e is UnknownUriException - ? null - : _getEmailContentInteractor.execute( - session!, - accountId!, - emailId, - session!.getDownloadUrl(jmapUrl: dynamicUrlInterceptors.jmapUrl), - PlatformInfo.isWeb - ? TransformConfiguration.forPreviewEmailOnWeb() - : TransformConfiguration.forPreviewEmail(), - ), - )))); - } + consumeState(_getEmailContentInteractor.execute( + session!, + accountId!, + emailId, + baseDownloadUrl, + transformConfiguration + )); + } catch (e) { + logError('SingleEmailController::_getEmailContentAction(): $e'); + consumeState(Stream.value(Left(GetEmailContentFailure( + e, + onRetry: e is UnknownUriException + ? null + : _getEmailContentInteractor.execute( + session!, + accountId!, + emailId, + session!.getDownloadUrl(jmapUrl: dynamicUrlInterceptors.jmapUrl), + PlatformInfo.isWeb + ? TransformConfiguration.forPreviewEmailOnWeb() + : TransformConfiguration.forPreviewEmail(), + ), + )))); } } } void _getEmailContentOfflineSuccess(GetEmailContentFromCacheSuccess success) { emailLoadedViewState.value = Right(success); - if (emailSupervisorController.presentationEmailsLoaded.length > ThreadConstants.defaultLimit.value.toInt()) { - emailSupervisorController.popFirstEmailQueue(); - } - emailSupervisorController.popEmailQueue(success.emailCurrent.id); currentEmailLoaded.value = EmailLoaded( htmlContent: success.htmlEmailContent, @@ -650,7 +630,7 @@ class SingleEmailController extends BaseController with AppLoaderMixin { inlineImages: List.of(success.inlineImages ?? []), emailCurrent: success.emailCurrent, ); - emailSupervisorController.pushEmailQueue(currentEmailLoaded.value!); + _threadDetailController?.currentEmailLoaded.value = currentEmailLoaded.value; if (success.emailCurrent.id == currentEmail?.id) { attachments.value = success.attachments ?? []; @@ -683,10 +663,6 @@ class SingleEmailController extends BaseController with AppLoaderMixin { void _getEmailContentSuccess(GetEmailContentSuccess success) { emailLoadedViewState.value = Right(success); - if (emailSupervisorController.presentationEmailsLoaded.length > ThreadConstants.defaultLimit.value.toInt()) { - emailSupervisorController.popFirstEmailQueue(); - } - emailSupervisorController.popEmailQueue(success.emailCurrent?.id); currentEmailLoaded.value = EmailLoaded( htmlContent: success.htmlEmailContent, @@ -694,7 +670,7 @@ class SingleEmailController extends BaseController with AppLoaderMixin { inlineImages: List.of(success.inlineImages ?? []), emailCurrent: success.emailCurrent, ); - emailSupervisorController.pushEmailQueue(currentEmailLoaded.value!); + _threadDetailController?.currentEmailLoaded.value = currentEmailLoaded.value; if (success.emailCurrent?.id == currentEmail?.id) { attachments.value = success.attachments ?? []; @@ -745,6 +721,14 @@ class SingleEmailController extends BaseController with AppLoaderMixin { emailUnsubscribe.value = null; } } + if ((_threadDetailController?.emailIdsPresentation.keys.length ?? 0) > 1 == true) { + _jumpScrollViewToTopOfEmail(); + } + } + + void _jumpScrollViewToTopOfEmail() { + if (_currentEmailId == null) return; + _threadDetailController?.focusExpandedEmail(_currentEmailId!); } void _handleUnsubscribe(String listUnsubscribe) { @@ -809,9 +793,9 @@ class SingleEmailController extends BaseController with AppLoaderMixin { } void _handleMarkAsEmailReadCompleted(ReadActions readActions) { - if (_currentEmailId != null) { + if (currentEmail?.id != null) { mailboxDashBoardController.updateEmailFlagByEmailIds( - [_currentEmailId!], + [currentEmail!.id!], readAction: readActions, ); } @@ -952,7 +936,7 @@ class SingleEmailController extends BaseController with AppLoaderMixin { } final downloadAllSupported = session!.isDownloadAllSupported(accountId); - if (!downloadAllSupported || _currentEmailId == null) { + if (!downloadAllSupported) { consumeState(Stream.value(Left(ExportAllAttachmentsFailure()))); return; } @@ -960,7 +944,7 @@ class SingleEmailController extends BaseController with AppLoaderMixin { final baseDownloadAllUrl = session!.getDownloadAllCapability(accountId)!.endpoint!; consumeState(_exportAllAttachmentsInteractor.execute( accountId!, - _currentEmailId!, + currentEmail!.id!, baseDownloadAllUrl, outputFileName, cancelToken: cancelToken, @@ -1071,7 +1055,7 @@ class SingleEmailController extends BaseController with AppLoaderMixin { } final downloadAllSupported = session.isDownloadAllSupported(accountId); - final emailId = _currentEmailId; + final emailId = currentEmail?.id; if (!downloadAllSupported || emailId == null) { consumeState(Stream.value(Left(DownloadAllAttachmentsForWebFailure( @@ -1454,10 +1438,10 @@ class SingleEmailController extends BaseController with AppLoaderMixin { final newEmail = currentEmail?.updateKeywords({ KeyWordIdentifier.emailFlagged: success.markStarAction == MarkStarAction.markStar, }); - mailboxDashBoardController.setSelectedEmail(newEmail); final emailId = newEmail?.id; if (emailId == null) return; + _threadDetailController?.emailIdsPresentation[emailId] = newEmail; mailboxDashBoardController.updateEmailFlagByEmailIds( [emailId], markStarAction: success.markStarAction, @@ -1508,6 +1492,9 @@ class SingleEmailController extends BaseController with AppLoaderMixin { case EmailActionType.editAsNewEmail: _editAsNewEmail(presentationEmail); break; + case EmailActionType.reply: + _replyEmail(presentationEmail); + break; default: break; } @@ -1639,7 +1626,7 @@ class SingleEmailController extends BaseController with AppLoaderMixin { return; } - if (currentEmail == null || _currentEmailId == null) { + if (currentEmail == null) { appToast.showToastErrorMessage( currentOverlayContext!, AppLocalizations.of(context).toastMessageCannotFoundEmailIdWhenSendReceipt); @@ -1700,16 +1687,12 @@ class SingleEmailController extends BaseController with AppLoaderMixin { } void closeEmailView({BuildContext? context}) { - if (emailSupervisorController.supportedPageView.isTrue) { - emailSupervisorController.popEmailQueue(_currentEmailId); - emailSupervisorController.setCurrentEmailIndex(-1); - emailSupervisorController.disposePageViewController(); - } mailboxDashBoardController.clearSelectedEmail(); - _updateCurrentEmailId(null); _resetToOriginalValue(isEmailClosing: true); _replaceBrowserHistory(); - if (mailboxDashBoardController.searchController.isSearchEmailRunning) { + if (mailboxDashBoardController.searchController.isSearchEmailRunning + || getBinding()?.searchIsRunning.value == true + ) { if (context != null && responsiveUtils.isWebDesktop(context)) { mailboxDashBoardController.dispatchRoute(DashboardRoutes.thread); } else { @@ -1723,8 +1706,7 @@ class SingleEmailController extends BaseController with AppLoaderMixin { } } - bool get isOpenEmailNotMailboxFromRoute => emailSupervisorController.supportedPageView.isFalse - && mailboxDashBoardController.selectedMailbox.value == null; + bool get isOpenEmailNotMailboxFromRoute => mailboxDashBoardController.selectedMailbox.value == null; void _replaceBrowserHistory() { if (PlatformInfo.isWeb) { @@ -1761,17 +1743,7 @@ class SingleEmailController extends BaseController with AppLoaderMixin { mailboxDashBoardController.openComposer(ComposerArguments()); break; case EmailActionType.reply: - mailboxDashBoardController.openComposer( - ComposerArguments.replyEmail( - presentationEmail: presentationEmail, - content: currentEmailLoaded.value?.htmlContent ?? '', - inlineImages: currentEmailLoaded.value?.inlineImages ?? [], - mailboxRole: presentationEmail.mailboxContain?.role, - messageId: currentEmailLoaded.value?.emailCurrent?.messageId, - references: currentEmailLoaded.value?.emailCurrent?.references, - listPost: currentEmailLoaded.value?.emailCurrent?.listPost, - ) - ); + _replyEmail(presentationEmail); break; case EmailActionType.replyToList: log('SingleEmailController::pressEmailAction:replyToList'); @@ -1860,15 +1832,6 @@ class SingleEmailController extends BaseController with AppLoaderMixin { } } - void toggleScrollPhysicsPagerView(bool leftDirection) { - log('SingleEmailController::toggleScrollPhysicsPagerView():leftDirection: $leftDirection'); - if (leftDirection) { - emailSupervisorController.moveToNextEmail(); - } else { - emailSupervisorController.backToPreviousEmail(); - } - } - void _storeOpenedEmailAction(Session? session, AccountId? accountId, DetailedEmail detailedEmail) async { if (session != null && accountId != null) { consumeState(_storeOpenedEmailInteractor.execute(session, accountId, detailedEmail)); @@ -1913,9 +1876,6 @@ class SingleEmailController extends BaseController with AppLoaderMixin { void _handleParseCalendarEventSuccess(ParseCalendarEventSuccess success) { emailLoadedViewState.value = Right(success); blobCalendarEvent.value = success.blobCalendarEventList.firstOrNull; - if (PlatformInfo.isMobile) { - _enableScrollPageView(); - } updateAttendanceStatus(success); } @@ -1924,10 +1884,6 @@ class SingleEmailController extends BaseController with AppLoaderMixin { emailContents.value = currentEmailLoaded.value?.htmlContent; } - void _enableScrollPageView() { - emailSupervisorController.scrollPhysicsPageView.value = null; - } - void openNewTabAction(String link) { AppUtils.launchLink(link); } @@ -1938,8 +1894,9 @@ class SingleEmailController extends BaseController with AppLoaderMixin { } void openAttachmentList(BuildContext context, List attachments) { + final tag = _currentEmailId?.id.value; if (responsiveUtils.isMobile(context)) { - (AttachmentListBottomSheetBuilder(context, attachments, imagePaths, _attachmentListScrollController) + (AttachmentListBottomSheetBuilder(context, attachments, imagePaths, _attachmentListScrollController, tag) ..onCloseButtonAction(() => popBack()) ..onDownloadAttachmentFileAction((attachment) => handleDownloadAttachmentAction(context, attachment)) ..onViewAttachmentFileAction((attachment) => handleViewAttachmentAction(context, attachment)) @@ -1963,6 +1920,7 @@ class SingleEmailController extends BaseController with AppLoaderMixin { onDownloadAllButtonAction: isDownloadAllSupported() ? () => downloadAllAttachmentsForWeb('TwakeMail-${DateTime.now()}') : null, + singleEmailControllerTag: tag, ) ), barrierColor: AppColor.colorDefaultCupertinoActionSheet, @@ -2240,6 +2198,20 @@ class SingleEmailController extends BaseController with AppLoaderMixin { ); } + void _replyEmail(PresentationEmail presentationEmail) { + mailboxDashBoardController.openComposer( + ComposerArguments.replyEmail( + presentationEmail: presentationEmail, + content: currentEmailLoaded.value?.htmlContent ?? '', + inlineImages: currentEmailLoaded.value?.inlineImages ?? [], + mailboxRole: presentationEmail.mailboxContain?.role, + messageId: currentEmailLoaded.value?.emailCurrent?.messageId, + references: currentEmailLoaded.value?.emailCurrent?.references, + listPost: currentEmailLoaded.value?.emailCurrent?.listPost, + ) + ); + } + void handleDownloadAttachmentAction( BuildContext context, Attachment attachment, diff --git a/lib/features/email/presentation/email_view.dart b/lib/features/email/presentation/email_view.dart index 35ae13fd3..f842101e9 100644 --- a/lib/features/email/presentation/email_view.dart +++ b/lib/features/email/presentation/email_view.dart @@ -1,26 +1,32 @@ +import 'dart:math'; + import 'package:core/presentation/constants/constants_ui.dart'; import 'package:core/presentation/extensions/color_extension.dart'; -import 'package:core/presentation/views/button/tmail_button_widget.dart'; import 'package:core/presentation/views/html_viewer/html_content_viewer_on_web_widget.dart'; import 'package:core/presentation/views/html_viewer/html_content_viewer_widget.dart'; -import 'package:core/utils/direction_utils.dart'; import 'package:core/utils/platform_info.dart'; import 'package:flutter/material.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.dart'; import 'package:model/email/email_action_type.dart'; import 'package:model/email/presentation_email.dart'; +import 'package:model/extensions/email_extension.dart'; import 'package:model/extensions/list_email_address_extension.dart'; import 'package:model/extensions/presentation_email_extension.dart'; import 'package:model/extensions/presentation_mailbox_extension.dart'; import 'package:model/mailbox/presentation_mailbox.dart'; import 'package:pointer_interceptor/pointer_interceptor.dart'; +import 'package:tmail_ui_user/features/base/widget/optional_expanded.dart'; +import 'package:tmail_ui_user/features/base/widget/optional_scroll.dart'; import 'package:tmail_ui_user/features/base/widget/popup_menu/popup_menu_item_action_widget.dart'; import 'package:tmail_ui_user/features/email/presentation/controller/single_email_controller.dart'; import 'package:tmail_ui_user/features/email/presentation/extensions/calendar_event_extension.dart'; import 'package:tmail_ui_user/features/email/presentation/model/context_item_email_action.dart'; import 'package:tmail_ui_user/features/email/presentation/model/popup_menu_item_email_action.dart'; +import 'package:tmail_ui_user/features/email/presentation/model/email_loaded.dart'; import 'package:tmail_ui_user/features/email/presentation/styles/email_view_styles.dart'; +import 'package:tmail_ui_user/features/email/presentation/utils/email_utils.dart'; import 'package:tmail_ui_user/features/email/presentation/widgets/calendar_event/calendar_event_action_banner_widget.dart'; import 'package:tmail_ui_user/features/email/presentation/widgets/calendar_event/calendar_event_detail_widget.dart'; import 'package:tmail_ui_user/features/email/presentation/widgets/calendar_event/calendar_event_information_widget.dart'; @@ -43,56 +49,45 @@ import 'package:tmail_ui_user/main/utils/app_utils.dart'; class EmailView extends GetWidget { - const EmailView({Key? key}) : super(key: key); + const EmailView({ + super.key, + this.isInsideThreadDetailView = false, + this.emailId, + this.isFirstEmailInThreadDetail = false, + this.threadSubject, + this.onToggleThreadDetailCollapseExpand, + }); + + final bool isInsideThreadDetailView; + final EmailId? emailId; + final bool isFirstEmailInThreadDetail; + final String? threadSubject; + final VoidCallback? onToggleThreadDetailCollapseExpand; + + @override + String? get tag => emailId?.id.value; @override Widget build(BuildContext context) { return SelectionArea( - child: Scaffold( - backgroundColor: controller.responsiveUtils.isWebDesktop(context) + child: ColoredBox( + color: controller.responsiveUtils.isWebDesktop(context) ? AppColor.colorBgDesktop : Colors.white, - appBar: PlatformInfo.isIOS - ? PreferredSize( - preferredSize: const Size(double.infinity, 100), - child: Obx(() { - if (controller.currentEmail != null) { - return SafeArea( - top: false, - bottom: false, - child: EmailViewAppBarWidget( - key: const Key('email_view_app_bar_widget'), - presentationEmail: controller.currentEmail!, - mailboxContain: _getMailboxContain(controller.currentEmail!), - isSearchActivated: controller.mailboxDashBoardController.searchController.isSearchEmailRunning, - onBackAction: () => controller.closeEmailView(context: context), - onEmailActionClick: (email, action) => controller.handleEmailAction(context, email, action), - onMoreActionClick: (presentationEmail, position) => _handleMoreEmailAction(context: context, presentationEmail: presentationEmail, position: position) - ), - ); - } else { - return const SizedBox.shrink(); - } - }) - ) - : null, - body: SafeArea( + child: SafeArea( right: controller.responsiveUtils.isLandscapeMobile(context), left: controller.responsiveUtils.isLandscapeMobile(context), bottom: !PlatformInfo.isIOS, child: Container( clipBehavior: Clip.antiAlias, - decoration: controller.responsiveUtils.isWebDesktop(context) - ? const BoxDecoration( - borderRadius: BorderRadius.all(Radius.circular(20)), - color: Colors.white) - : const BoxDecoration(color: Colors.white), + decoration: _getDecorationEmailView(context), margin: _getMarginEmailView(context), child: Obx(() { - final currentEmail = controller.currentEmail; + final currentEmailListener = Rxn(controller.currentEmail); + final currentEmail = currentEmailListener.value; if (currentEmail != null) { return Column(children: [ - if (!PlatformInfo.isIOS) + if (!isInsideThreadDetailView) Obx(() => EmailViewAppBarWidget( key: const Key('email_view_app_bar_widget'), presentationEmail: currentEmail, @@ -100,10 +95,20 @@ class EmailView extends GetWidget { isSearchActivated: controller.mailboxDashBoardController.searchController.isSearchEmailRunning, onBackAction: () => controller.closeEmailView(context: context), onEmailActionClick: (email, action) => controller.handleEmailAction(context, email, action), - onMoreActionClick: (presentationEmail, position) => _handleMoreEmailAction(context: context, presentationEmail: presentationEmail, position: position), - optionsWidget: PlatformInfo.isWeb && controller.emailSupervisorController.supportedPageView.isTrue - ? _buildNavigatorPageViewWidgets(context) + onMoreActionClick: (presentationEmail, position) => _handleMoreEmailAction( + context: context, + presentationEmail: presentationEmail, + position: position, + emailLoaded: controller.currentEmailLoaded.value, + ), + supportBackAction: !isInsideThreadDetailView, + appBarDecoration: isInsideThreadDetailView + ? const BoxDecoration(border: Border(bottom: BorderSide( + color: AppColor.colorDividerEmailView, + ))) : null, + emailLoaded: controller.currentEmailLoaded.value, + isInsideThreadDetailView: isInsideThreadDetailView, )), Obx(() { final vacation = controller.mailboxDashBoardController.vacationResponse.value; @@ -124,123 +129,63 @@ class EmailView extends GetWidget { return const SizedBox.shrink(); } }), - Expanded( + OptionalExpanded( + expandedEnabled: !isInsideThreadDetailView, child: LayoutBuilder(builder: (context, constraints) { - return Obx(() { - bool supportedPageView = controller.emailSupervisorController.supportedPageView.isTrue && PlatformInfo.isMobile; - final currentListEmail = controller.emailSupervisorController.currentListEmail; - - if (supportedPageView) { - return PageView.builder( - physics: controller.emailSupervisorController.scrollPhysicsPageView.value, - itemCount: currentListEmail.length, - allowImplicitScrolling: true, - controller: controller.emailSupervisorController.pageController, - onPageChanged: controller.emailSupervisorController.onPageChanged, - itemBuilder: (context, index) { - final currentEmail = currentListEmail[index]; - if (PlatformInfo.isMobile) { - return SingleChildScrollView( - physics : const ClampingScrollPhysics(), - child: Container( - width: double.infinity, - alignment: Alignment.center, - color: Colors.white, - child: Obx(() => _buildEmailMessage( - context: context, - presentationEmail: currentEmail, - calendarEvent: controller.calendarEvent, - maxBodyHeight: constraints.maxHeight - )) + if (PlatformInfo.isMobile) { + return OptionalScroll( + scrollEnabled: !isInsideThreadDetailView, + child: Container( + width: double.infinity, + alignment: Alignment.center, + color: Colors.white, + child: Obx(() => _buildEmailMessage( + context: context, + presentationEmail: currentEmail, + calendarEvent: controller.calendarEvent, + maxBodyHeight: constraints.maxHeight + )) + ) + ); + } else { + return Obx(() { + final calendarEvent = controller.calendarEvent; + if (currentEmail.hasCalendarEvent && calendarEvent != null) { + return Padding( + padding: const EdgeInsetsDirectional.symmetric(horizontal: 4), + child: OptionalScroll( + scrollEnabled: !isInsideThreadDetailView, + child: Container( + width: double.infinity, + alignment: Alignment.center, + color: Colors.white, + child: _buildEmailMessage( + context: context, + presentationEmail: currentEmail, + calendarEvent: calendarEvent, + emailAddressSender: currentEmail.listEmailAddressSender.getListAddress(), + maxBodyHeight: constraints.maxHeight ) - ); - } else { - return Obx(() { - final calendarEvent = controller.calendarEvent; - if (currentEmail.hasCalendarEvent && calendarEvent != null) { - return Padding( - padding: const EdgeInsetsDirectional.symmetric(horizontal: 4), - child: SingleChildScrollView( - physics : const ClampingScrollPhysics(), - child: Container( - width: double.infinity, - alignment: Alignment.center, - color: Colors.white, - child: _buildEmailMessage( - context: context, - presentationEmail: currentEmail, - calendarEvent: calendarEvent, - emailAddressSender: currentEmail.listEmailAddressSender.getListAddress(), - ) - ) - ), - ); - } else { - return _buildEmailMessage( - context: context, - presentationEmail: currentEmail, - maxBodyHeight: constraints.maxHeight - ); - } - }); - } - } - ); - } else { - if (PlatformInfo.isMobile) { - return SingleChildScrollView( - physics : const ClampingScrollPhysics(), - child: Container( - width: double.infinity, - alignment: Alignment.center, - color: Colors.white, - child: Obx(() => _buildEmailMessage( - context: context, - presentationEmail: currentEmail, - calendarEvent: controller.calendarEvent, - maxBodyHeight: constraints.maxHeight - )) - ) + ) + ), ); } else { - return Obx(() { - final calendarEvent = controller.calendarEvent; - if (currentEmail.hasCalendarEvent && calendarEvent != null) { - return Padding( - padding: const EdgeInsetsDirectional.symmetric(horizontal: 4), - child: SingleChildScrollView( - physics : const ClampingScrollPhysics(), - child: Container( - width: double.infinity, - alignment: Alignment.center, - color: Colors.white, - child: _buildEmailMessage( - context: context, - presentationEmail: currentEmail, - calendarEvent: calendarEvent, - emailAddressSender: currentEmail.listEmailAddressSender.getListAddress(), - maxBodyHeight: constraints.maxHeight - ) - ) - ), - ); - } else { - return _buildEmailMessage( - context: context, - presentationEmail: currentEmail, - maxBodyHeight: constraints.maxHeight - ); - } - }); + return _buildEmailMessage( + context: context, + presentationEmail: currentEmail, + maxBodyHeight: constraints.maxHeight + ); } - } - }); + }); + } }), ), Obx(() { final emailLoaded = controller.currentEmailLoaded.value; - if (emailLoaded == null) return const SizedBox.shrink(); + if (emailLoaded == null || isInsideThreadDetailView) { + return const SizedBox.shrink(); + } return EmailViewBottomBarWidget( key: const Key('email_view_button_bar'), @@ -263,51 +208,47 @@ class EmailView extends GetWidget { ); } - EdgeInsetsGeometry? _getMarginEmailView(BuildContext context) { - if (PlatformInfo.isWeb) { - if (controller.responsiveUtils.isDesktop(context)) { - return const EdgeInsetsDirectional.only( - end: 16, - bottom: 16 - ); - } else { - return const EdgeInsets.symmetric(vertical: 16); - } - } else { - return null; + BoxDecoration _getDecorationEmailView(BuildContext context) { + if (controller.responsiveUtils.isWebDesktop(context) && !isInsideThreadDetailView) { + return const BoxDecoration( + borderRadius: BorderRadius.all(Radius.circular(20)), + color: Colors.white, + ); } + + if (controller.currentEmail == null || !isInsideThreadDetailView) { + return const BoxDecoration(color: Colors.white); + } + + return const BoxDecoration( + color: Colors.white, + border: Border(top: BorderSide( + color: AppColor.colorDividerEmailView, + width: 0.5, + )), + ); + } + + EdgeInsetsGeometry? _getMarginEmailView(BuildContext context) { + if (PlatformInfo.isMobile) return null; + + if (isInsideThreadDetailView) { + return EdgeInsets.zero; + } + + if (!controller.responsiveUtils.isDesktop(context)) { + return EdgeInsets.zero; + } + + return const EdgeInsetsDirectional.only( + end: 16, + bottom: 16 + ); } PresentationMailbox? _getMailboxContain(PresentationEmail currentEmail) { return currentEmail.findMailboxContain(controller.mailboxDashBoardController.mapMailboxById); } - - List _buildNavigatorPageViewWidgets(BuildContext context) { - return [ - if (controller.emailSupervisorController.nextEmailActivated) - TMailButtonWidget.fromIcon( - icon: DirectionUtils.isDirectionRTLByLanguage(context) - ? controller.imagePaths.icOlder - : controller.imagePaths.icNewer, - iconColor: EmailViewStyles.iconColor, - iconSize: EmailViewStyles.pageViewIconSize, - backgroundColor: Colors.transparent, - tooltipMessage: AppLocalizations.of(context).newer, - onTapActionCallback: controller.emailSupervisorController.moveToNextEmail - ), - if (controller.emailSupervisorController.previousEmailActivated) - TMailButtonWidget.fromIcon( - icon: DirectionUtils.isDirectionRTLByLanguage(context) - ? controller.imagePaths.icNewer - : controller.imagePaths.icOlder, - iconColor: EmailViewStyles.iconColor, - iconSize: EmailViewStyles.pageViewIconSize, - backgroundColor: Colors.transparent, - tooltipMessage: AppLocalizations.of(context).older, - onTapActionCallback: controller.emailSupervisorController.backToPreviousEmail - ), - ]; - } Widget _buildEmailMessage({ required BuildContext context, @@ -318,8 +259,16 @@ class EmailView extends GetWidget { }) { return Column( crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, children: [ - EmailSubjectWidget(presentationEmail: presentationEmail), + if (!isInsideThreadDetailView || isFirstEmailInThreadDetail) + EmailSubjectWidget( + presentationEmail: presentationEmail.copyWith( + subject: threadSubject, + ), + ) + else + const SizedBox(height: 16), Obx(() => InformationSenderAndReceiverBuilder( emailSelected: presentationEmail, imagePaths: controller.imagePaths, @@ -329,6 +278,16 @@ class EmailView extends GetWidget { maxBodyHeight: maxBodyHeight, openEmailAddressDetailAction: controller.openEmailAddressDialog, onEmailActionClick: (presentationEmail, actionType) => controller.handleEmailAction(context, presentationEmail, actionType), + isInsideThreadDetailView: isInsideThreadDetailView, + emailLoaded: controller.currentEmailLoaded.value, + onMoreActionClick: (email, position) => _handleMoreEmailAction( + context: context, + presentationEmail: email, + position: position, + emailLoaded: controller.currentEmailLoaded.value, + ), + onToggleThreadDetailCollapseExpand: onToggleThreadDetailCollapseExpand, + onTapAvatarActionClick: onToggleThreadDetailCollapseExpand, )), Obx(() => MailUnsubscribedBanner( presentationEmail: controller.currentEmail, @@ -349,6 +308,7 @@ class EmailView extends GetWidget { onTapShowAllAttachmentFile: () => controller.openAttachmentList(context, controller.attachments), showDownloadAllAttachmentsButton: controller.downloadAllButtonIsEnabled(), onTapDownloadAllButton: () => controller.handleDownloadAllAttachmentsAction(context, 'TwakeMail-${DateTime.now()}'), + singleEmailControllerTag: tag, ); } else { return const SizedBox.shrink(); @@ -408,7 +368,8 @@ class EmailView extends GetWidget { final allEmailContents = controller.emailContents.value ?? ''; if (PlatformInfo.isWeb) { - return Expanded( + return OptionalExpanded( + expandedEnabled: !isInsideThreadDetailView, child: Padding( padding: EmailViewStyles.emailContentPadding, child: LayoutBuilder(builder: (context, constraints) { @@ -416,8 +377,12 @@ class EmailView extends GetWidget { return Stack( children: [ HtmlContentViewerOnWeb( + key: ValueKey(tag), widthContent: constraints.maxWidth, - heightContent: constraints.maxHeight, + heightContent: min( + constraints.maxHeight, + EmailViewStyles.initialHtmlViewHeight, + ), contentHtml: allEmailContents, mailtoDelegate: controller.openMailToLink, direction: AppUtils.getCurrentDirection(context), @@ -462,9 +427,8 @@ class EmailView extends GetWidget { useDefaultFont: true, maxHtmlContentHeight: ConstantsUI.htmlContentMaxHeight, onMailtoDelegateAction: controller.openMailToLink, - onScrollHorizontalEnd: controller.toggleScrollPhysicsPagerView, - onLoadWidthHtmlViewer: controller.emailSupervisorController.updateScrollPhysicPageView, onHtmlContentClippedAction: controller.onHtmlContentClippedAction, + keepAlive: isInsideThreadDetailView, ); }), ), @@ -503,8 +467,7 @@ class EmailView extends GetWidget { contentPadding: 0, useDefaultFont: true, onMailtoDelegateAction: controller.openMailToLink, - onScrollHorizontalEnd: controller.toggleScrollPhysicsPagerView, - onLoadWidthHtmlViewer: controller.emailSupervisorController.updateScrollPhysicPageView, + keepAlive: isInsideThreadDetailView, ); }) ); @@ -531,10 +494,17 @@ class EmailView extends GetWidget { return usernameEvent.isNotEmpty && titleEvent.isNotEmpty; } + bool canDeletePermanently(PresentationEmail email) { + return email.mailboxContain?.isTrash + ?? email.mailboxContain?.isSpam + ?? false; + } + void _handleMoreEmailAction({ required BuildContext context, required PresentationEmail presentationEmail, - RelativeRect? position + RelativeRect? position, + EmailLoaded? emailLoaded, }) { final mailboxContain = controller.getMailboxContain(presentationEmail); final isChildOfTeamMailboxes = mailboxContain?.isChildOfTeamMailboxes ?? false; @@ -548,6 +518,25 @@ class EmailView extends GetWidget { PlatformInfo.isWeb && PlatformInfo.isCanvasKit; final emailActions = [ + if (isInsideThreadDetailView) ...[ + EmailActionType.forward, + if (presentationEmail.getCountMailAddressWithoutMe(controller.session?.username.value ?? '') > 1) + EmailActionType.replyAll, + if (EmailUtils.isReplyToListEnabled(emailLoaded?.emailCurrent?.listPost ?? '')) + EmailActionType.replyToList, + if (PlatformInfo.isWeb && PlatformInfo.isCanvasKit) + EmailActionType.printAll, + if (controller.responsiveUtils.isMobile(context)) + EmailActionType.moveToMailbox, + if (!controller.responsiveUtils.isDesktop(context)) ...[ + presentationEmail.hasStarred + ? EmailActionType.unMarkAsStarred + : EmailActionType.markAsStarred, + canDeletePermanently(presentationEmail) + ? EmailActionType.deletePermanently + : EmailActionType.moveToTrash, + ], + ], EmailActionType.markAsUnread, if (!isChildOfTeamMailboxes) isSpam ? EmailActionType.unSpam : EmailActionType.moveToSpam, diff --git a/lib/features/email/presentation/extensions/email_extension.dart b/lib/features/email/presentation/extensions/email_extension.dart index 219dad94a..aef423c14 100644 --- a/lib/features/email/presentation/extensions/email_extension.dart +++ b/lib/features/email/presentation/extensions/email_extension.dart @@ -1,6 +1,7 @@ import 'package:core/utils/app_logger.dart'; import 'package:jmap_dart_client/jmap/mail/email/email.dart'; +import 'package:jmap_dart_client/jmap/mail/mailbox/mailbox.dart'; import 'package:model/extensions/email_extension.dart'; import 'package:model/extensions/list_email_header_extension.dart'; import 'package:tmail_ui_user/features/email/presentation/model/smime_signature_status.dart'; @@ -21,4 +22,25 @@ extension EmailExtension on Email { return SMimeSignatureStatus.notSigned; } } + + bool inSentMailbox(MailboxId sentMailboxId) { + return mailboxIds?[sentMailboxId] == true; + } + + bool fromMe(String ownEmailAddress) { + return from?.any( + (emailAdress) => emailAdress.email == ownEmailAddress + ) == true; + } + + bool recipientsHasMe(String ownEmailAddress) { + final recipients = { + ...to ?? {}, + ...cc ?? {}, + ...bcc ?? {}, + }; + return recipients.any( + (emailAdress) => emailAdress.email == ownEmailAddress + ) == true; + } } \ No newline at end of file diff --git a/lib/features/email/presentation/styles/email_view_app_bar_widget_styles.dart b/lib/features/email/presentation/styles/email_view_app_bar_widget_styles.dart index d021d0f7b..37bd6a585 100644 --- a/lib/features/email/presentation/styles/email_view_app_bar_widget_styles.dart +++ b/lib/features/email/presentation/styles/email_view_app_bar_widget_styles.dart @@ -17,7 +17,7 @@ class EmailViewAppBarWidgetStyles { return null; } } - static const Color bottomBorderColor = AppColor.colorDividerHorizontal; + static const Color bottomBorderColor = AppColor.colorDividerEmailView; static const Color backgroundColor = Colors.white; static const Color iconColor = AppColor.steelGrayA540; @@ -27,7 +27,7 @@ class EmailViewAppBarWidgetStyles { } else if (responsiveUtils.isPortraitTablet(context) || responsiveUtils.isLandscapeTablet(context)) { return const EdgeInsetsDirectional.only(top: 40, start: 16, end: 16, bottom: 4); } else { - return const EdgeInsetsDirectional.only(top: 60, start: 16, end: 16, bottom: 4); + return const EdgeInsetsDirectional.only(start: 16, end: 16, bottom: 4); } } static const EdgeInsetsGeometry padding = EdgeInsetsDirectional.symmetric(horizontal: 16); diff --git a/lib/features/email/presentation/styles/email_view_styles.dart b/lib/features/email/presentation/styles/email_view_styles.dart index f976d48f7..0ee77fa00 100644 --- a/lib/features/email/presentation/styles/email_view_styles.dart +++ b/lib/features/email/presentation/styles/email_view_styles.dart @@ -7,6 +7,7 @@ class EmailViewStyles { static const double mobileContentHorizontalMargin = 16; static const double mobileContentVerticalMargin = 12; static double pageViewIconSize = IconUtils.defaultIconSize; + static const double initialHtmlViewHeight = 200; static const Color iconColor = AppColor.steelGrayA540; diff --git a/lib/features/email/presentation/widgets/attachment_item_widget.dart b/lib/features/email/presentation/widgets/attachment_item_widget.dart index 6c8afef37..fb0346e39 100644 --- a/lib/features/email/presentation/widgets/attachment_item_widget.dart +++ b/lib/features/email/presentation/widgets/attachment_item_widget.dart @@ -21,6 +21,7 @@ class AttachmentItemWidget extends StatelessWidget { final Attachment attachment; final OnDownloadAttachmentFileAction? downloadAttachmentAction; final OnViewAttachmentFileAction? viewAttachmentAction; + final String? singleEmailControllerTag; final _imagePaths = Get.find(); final _responsiveUtils = Get.find(); @@ -30,13 +31,14 @@ class AttachmentItemWidget extends StatelessWidget { required this.attachment, this.downloadAttachmentAction, this.viewAttachmentAction, + this.singleEmailControllerTag, }) : super(key: key); @override Widget build(BuildContext context) { return Obx( () { - final controller = Get.find(); + final controller = Get.find(tag: singleEmailControllerTag); final attachmentsViewState = controller.attachmentsViewState; bool isLoading = false; if (attachment.blobId != null) { diff --git a/lib/features/email/presentation/widgets/attachment_list/attachment_list_bottom_sheet_body_builder.dart b/lib/features/email/presentation/widgets/attachment_list/attachment_list_bottom_sheet_body_builder.dart index b545e7ca3..61a97fb3a 100644 --- a/lib/features/email/presentation/widgets/attachment_list/attachment_list_bottom_sheet_body_builder.dart +++ b/lib/features/email/presentation/widgets/attachment_list/attachment_list_bottom_sheet_body_builder.dart @@ -21,6 +21,7 @@ class AttachmentListBottomSheetBodyBuilder extends StatelessWidget { final OnViewAttachmentFileAction? onViewAttachmentFileAction; final OnCancelButtonAction? onCancelButtonAction; final OnCloseButtonAction? onCloseButtonAction; + final String? singleEmailControllerTag; const AttachmentListBottomSheetBodyBuilder({ super.key, @@ -33,6 +34,7 @@ class AttachmentListBottomSheetBodyBuilder extends StatelessWidget { this.onViewAttachmentFileAction, this.onCancelButtonAction, this.onCloseButtonAction, + this.singleEmailControllerTag, }); @override @@ -105,6 +107,7 @@ class AttachmentListBottomSheetBodyBuilder extends StatelessWidget { attachment: attachments[index], downloadAttachmentAction: onDownloadAttachmentFileAction, viewAttachmentAction: onViewAttachmentFileAction, + singleEmailControllerTag: singleEmailControllerTag, ); }, separatorBuilder: (context, index) { diff --git a/lib/features/email/presentation/widgets/attachment_list/attachment_list_bottom_sheet_builder.dart b/lib/features/email/presentation/widgets/attachment_list/attachment_list_bottom_sheet_builder.dart index 2720f6f5b..b30447f3d 100644 --- a/lib/features/email/presentation/widgets/attachment_list/attachment_list_bottom_sheet_builder.dart +++ b/lib/features/email/presentation/widgets/attachment_list/attachment_list_bottom_sheet_builder.dart @@ -15,6 +15,7 @@ class AttachmentListBottomSheetBuilder { final List _attachments; final ImagePaths _imagePaths; final ScrollController _scrollController; + final String? _singleEmailControllerTag; late double _statusBarHeight; @@ -29,6 +30,7 @@ class AttachmentListBottomSheetBuilder { this._attachments, this._imagePaths, this._scrollController, + this._singleEmailControllerTag, ) { _statusBarHeight = Get.statusBarHeight / MediaQuery.of(_context).devicePixelRatio; } @@ -70,6 +72,7 @@ class AttachmentListBottomSheetBuilder { onViewAttachmentFileAction: _onViewAttachmentFileAction, onCancelButtonAction: _onCancelButtonAction, onCloseButtonAction: _onCloseButtonAction, + singleEmailControllerTag: _singleEmailControllerTag, ), ); } diff --git a/lib/features/email/presentation/widgets/attachment_list/attachment_list_dialog_body_builder.dart b/lib/features/email/presentation/widgets/attachment_list/attachment_list_dialog_body_builder.dart index 505baa831..d94776254 100644 --- a/lib/features/email/presentation/widgets/attachment_list/attachment_list_dialog_body_builder.dart +++ b/lib/features/email/presentation/widgets/attachment_list/attachment_list_dialog_body_builder.dart @@ -22,6 +22,7 @@ class AttachmentListDialogBodyBuilder extends StatelessWidget { final OnViewAttachmentFileAction? onViewAttachmentFileAction; final OnCancelButtonAction? onCancelButtonAction; final OnCloseButtonAction? onCloseButtonAction; + final String? singleEmailControllerTag; const AttachmentListDialogBodyBuilder({ super.key, @@ -35,7 +36,8 @@ class AttachmentListDialogBodyBuilder extends StatelessWidget { this.onDownloadAttachmentFileAction, this.onViewAttachmentFileAction, this.onCancelButtonAction, - this.onCloseButtonAction + this.onCloseButtonAction, + this.singleEmailControllerTag, }); @override @@ -113,6 +115,7 @@ class AttachmentListDialogBodyBuilder extends StatelessWidget { attachment: attachments[index], downloadAttachmentAction: onDownloadAttachmentFileAction, viewAttachmentAction: onViewAttachmentFileAction, + singleEmailControllerTag: singleEmailControllerTag, ); }, separatorBuilder: (context, index) { diff --git a/lib/features/email/presentation/widgets/attachment_list/attachment_list_dialog_builder.dart b/lib/features/email/presentation/widgets/attachment_list/attachment_list_dialog_builder.dart index a0b2e9836..83188b73d 100644 --- a/lib/features/email/presentation/widgets/attachment_list/attachment_list_dialog_builder.dart +++ b/lib/features/email/presentation/widgets/attachment_list/attachment_list_dialog_builder.dart @@ -22,6 +22,7 @@ class AttachmentListDialogBuilder extends StatelessWidget { final OnViewAttachmentFileAction? onViewAttachmentFileAction; final OnCancelButtonAction? onCancelButtonAction; final OnCloseButtonAction? onCloseButtonAction; + final String? singleEmailControllerTag; const AttachmentListDialogBuilder({ Key? key, @@ -37,6 +38,7 @@ class AttachmentListDialogBuilder extends StatelessWidget { this.onViewAttachmentFileAction, this.onCancelButtonAction, this.onCloseButtonAction, + this.singleEmailControllerTag, }) : super(key: key); @override @@ -61,6 +63,7 @@ class AttachmentListDialogBuilder extends StatelessWidget { onViewAttachmentFileAction: onViewAttachmentFileAction, onCancelButtonAction: onCancelButtonAction, onCloseButtonAction: onCloseButtonAction, + singleEmailControllerTag: singleEmailControllerTag, ), ); } diff --git a/lib/features/email/presentation/widgets/attachment_list/attachment_list_item_widget.dart b/lib/features/email/presentation/widgets/attachment_list/attachment_list_item_widget.dart index e6c40f3a5..06df31129 100644 --- a/lib/features/email/presentation/widgets/attachment_list/attachment_list_item_widget.dart +++ b/lib/features/email/presentation/widgets/attachment_list/attachment_list_item_widget.dart @@ -19,6 +19,7 @@ class AttachmentListItemWidget extends StatelessWidget { final Attachment attachment; final OnDownloadAttachmentFileAction? downloadAttachmentAction; final OnViewAttachmentFileAction? viewAttachmentAction; + final String? singleEmailControllerTag; final _imagePaths = Get.find(); @@ -27,13 +28,14 @@ class AttachmentListItemWidget extends StatelessWidget { required this.attachment, this.downloadAttachmentAction, this.viewAttachmentAction, + this.singleEmailControllerTag, }) : super(key: key); @override Widget build(BuildContext context) { return Obx( () { - final controller = Get.find(); + final controller = Get.find(tag: singleEmailControllerTag); final attachmentsViewState = controller.attachmentsViewState; bool isLoading = false; if (attachment.blobId != null) { diff --git a/lib/features/email/presentation/widgets/calendar_event/event_body_content_widget.dart b/lib/features/email/presentation/widgets/calendar_event/event_body_content_widget.dart index 87295e4db..b6ba007a9 100644 --- a/lib/features/email/presentation/widgets/calendar_event/event_body_content_widget.dart +++ b/lib/features/email/presentation/widgets/calendar_event/event_body_content_widget.dart @@ -1,3 +1,4 @@ +import 'dart:math'; import 'package:core/presentation/constants/constants_ui.dart'; import 'package:core/presentation/extensions/color_extension.dart'; @@ -9,6 +10,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:get/get.dart'; import 'package:pointer_interceptor/pointer_interceptor.dart'; +import 'package:tmail_ui_user/features/email/presentation/styles/email_view_styles.dart'; import 'package:tmail_ui_user/features/email/presentation/styles/event_description_detail_widget_styles.dart'; import 'package:tmail_ui_user/main/utils/app_utils.dart'; @@ -44,15 +46,17 @@ class EventBodyContentWidget extends StatelessWidget { child: Stack( children: [ if (PlatformInfo.isWeb) - Container( - constraints: const BoxConstraints(maxHeight: EventDescriptionDetailWidgetStyles.maxHeight), + Padding( padding: const EdgeInsetsDirectional.only(end: EventDescriptionDetailWidgetStyles.webContentPadding), child: LayoutBuilder(builder: (context, constraints) { return Stack( children: [ HtmlContentViewerOnWeb( widthContent: constraints.maxWidth, - heightContent: constraints.maxHeight, + heightContent: min( + constraints.maxHeight, + EmailViewStyles.initialHtmlViewHeight, + ), contentHtml: content, mailtoDelegate: onMailtoDelegateAction, direction: AppUtils.getCurrentDirection(context), diff --git a/lib/features/email/presentation/widgets/draggable_attachment_item_widget.dart b/lib/features/email/presentation/widgets/draggable_attachment_item_widget.dart index efd553e45..bd82e4bcc 100644 --- a/lib/features/email/presentation/widgets/draggable_attachment_item_widget.dart +++ b/lib/features/email/presentation/widgets/draggable_attachment_item_widget.dart @@ -14,6 +14,7 @@ class DraggableAttachmentItemWidget extends StatelessWidget{ final OnDragAttachmentEnd? onDragEnd; final OnDownloadAttachmentFileAction? downloadAttachmentAction; final OnViewAttachmentFileAction? viewAttachmentAction; + final String? singleEmailControllerTag; const DraggableAttachmentItemWidget({ Key? key, @@ -22,6 +23,7 @@ class DraggableAttachmentItemWidget extends StatelessWidget{ this.onDragEnd, this.downloadAttachmentAction, this.viewAttachmentAction, + this.singleEmailControllerTag, }) : super(key: key); @override @@ -35,6 +37,7 @@ class DraggableAttachmentItemWidget extends StatelessWidget{ attachment: attachment, downloadAttachmentAction: downloadAttachmentAction, viewAttachmentAction: viewAttachmentAction, + singleEmailControllerTag: singleEmailControllerTag, ), ); } diff --git a/lib/features/email/presentation/widgets/email_attachments_widget.dart b/lib/features/email/presentation/widgets/email_attachments_widget.dart index 08fbe27f7..6308e8053 100644 --- a/lib/features/email/presentation/widgets/email_attachments_widget.dart +++ b/lib/features/email/presentation/widgets/email_attachments_widget.dart @@ -27,6 +27,7 @@ class EmailAttachmentsWidget extends StatelessWidget { final OnTapActionCallback? onTapShowAllAttachmentFile; final bool showDownloadAllAttachmentsButton; final OnTapActionCallback? onTapDownloadAllButton; + final String? singleEmailControllerTag; const EmailAttachmentsWidget({ super.key, @@ -40,6 +41,7 @@ class EmailAttachmentsWidget extends StatelessWidget { this.onTapShowAllAttachmentFile, this.showDownloadAllAttachmentsButton = false, this.onTapDownloadAllButton, + this.singleEmailControllerTag, }); Widget _buildMoreAttachmentButton( @@ -108,17 +110,19 @@ class EmailAttachmentsWidget extends StatelessWidget { children: attachmentDisplayed.map((attachment) { if (PlatformInfo.isWeb) { return DraggableAttachmentItemWidget( - attachment: attachment, - onDragStarted: onDragStarted, - onDragEnd: onDragEnd, - downloadAttachmentAction: downloadAttachmentAction, - viewAttachmentAction: viewAttachmentAction, + attachment: attachment, + onDragStarted: onDragStarted, + onDragEnd: onDragEnd, + downloadAttachmentAction: downloadAttachmentAction, + viewAttachmentAction: viewAttachmentAction, + singleEmailControllerTag: singleEmailControllerTag, ); } else { return AttachmentItemWidget( - attachment: attachment, - downloadAttachmentAction: downloadAttachmentAction, - viewAttachmentAction: viewAttachmentAction, + attachment: attachment, + downloadAttachmentAction: downloadAttachmentAction, + viewAttachmentAction: viewAttachmentAction, + singleEmailControllerTag: singleEmailControllerTag, ); } }).toList(), diff --git a/lib/features/email/presentation/widgets/email_receiver_widget.dart b/lib/features/email/presentation/widgets/email_receiver_widget.dart index ba5d6358c..ed287969e 100644 --- a/lib/features/email/presentation/widgets/email_receiver_widget.dart +++ b/lib/features/email/presentation/widgets/email_receiver_widget.dart @@ -56,7 +56,7 @@ class _EmailReceiverWidgetState extends State { return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded( + Flexible( child: Container( constraints: BoxConstraints(maxHeight: _maxHeight), child: ListView( @@ -149,7 +149,7 @@ class _EmailReceiverWidgetState extends State { return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded( + Flexible( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, diff --git a/lib/features/email/presentation/widgets/email_sender_builder.dart b/lib/features/email/presentation/widgets/email_sender_builder.dart index d2adf7d4f..949c3a881 100644 --- a/lib/features/email/presentation/widgets/email_sender_builder.dart +++ b/lib/features/email/presentation/widgets/email_sender_builder.dart @@ -14,12 +14,14 @@ class EmailSenderBuilder extends StatelessWidget { final EmailAddress emailAddress; final OnOpenEmailAddressDetailAction? openEmailAddressDetailAction; + final bool showSenderEmail; const EmailSenderBuilder({ - Key? key, + super.key, required this.emailAddress, this.openEmailAddressDetailAction, -}) : super(key: key); + this.showSenderEmail = true, + }); @override Widget build(BuildContext context) { @@ -27,7 +29,7 @@ class EmailSenderBuilder extends StatelessWidget { scrollDirection: Axis.horizontal, physics: const NeverScrollableScrollPhysics(), child: Row( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ if (emailAddress.displayName.isNotEmpty) @@ -38,32 +40,30 @@ class EmailSenderBuilder extends StatelessWidget { AppUtils.copyEmailAddressToClipboard(context, emailAddress.emailAddress); }, borderRadius: 8, - padding: const EdgeInsets.all(3), + padding: EdgeInsets.zero, customStyle: ThemeUtils.textStyleHeadingHeadingSmall( color: Colors.black, ), overflow: CommonTextStyle.defaultTextOverFlow, softWrap: CommonTextStyle.defaultSoftWrap ), - Padding( - padding: const EdgeInsets.only(top: 2), - child: MaterialTextButton( + if (showSenderEmail) + MaterialTextButton( label: '<${emailAddress.emailAddress}>', onTap: () => openEmailAddressDetailAction?.call(context, emailAddress), onLongPress: () { AppUtils.copyEmailAddressToClipboard(context, emailAddress.emailAddress); }, borderRadius: 8, - padding: const EdgeInsets.all(3), + padding: EdgeInsets.zero, customStyle: ThemeUtils.textStyleBodyBody1( color: AppColor.steelGray400, ), overflow: CommonTextStyle.defaultTextOverFlow, softWrap: CommonTextStyle.defaultSoftWrap - ), - ) + ) ] - ) + ), ); } } \ No newline at end of file diff --git a/lib/features/email/presentation/widgets/email_subject_widget.dart b/lib/features/email/presentation/widgets/email_subject_widget.dart index dbd4e1531..285ea10bd 100644 --- a/lib/features/email/presentation/widgets/email_subject_widget.dart +++ b/lib/features/email/presentation/widgets/email_subject_widget.dart @@ -20,7 +20,9 @@ class EmailSubjectWidget extends StatelessWidget { child: Text( presentationEmail.getEmailTitle(), maxLines: EmailSubjectStyles.maxLines, - style: ThemeUtils.textStyleHeadingH5(color: Colors.black) + style: ThemeUtils.textStyleHeadingH5(color: Colors.black).copyWith( + overflow: TextOverflow.ellipsis, + ), ) ); } diff --git a/lib/features/email/presentation/widgets/email_view_app_bar_widget.dart b/lib/features/email/presentation/widgets/email_view_app_bar_widget.dart index 7d1fd6357..5da432ef8 100644 --- a/lib/features/email/presentation/widgets/email_view_app_bar_widget.dart +++ b/lib/features/email/presentation/widgets/email_view_app_bar_widget.dart @@ -8,7 +8,7 @@ import 'package:model/email/email_action_type.dart'; import 'package:model/email/presentation_email.dart'; import 'package:model/extensions/presentation_mailbox_extension.dart'; import 'package:model/mailbox/presentation_mailbox.dart'; -import 'package:tmail_ui_user/features/email/presentation/controller/single_email_controller.dart'; +import 'package:tmail_ui_user/features/email/presentation/model/email_loaded.dart'; import 'package:tmail_ui_user/features/email/presentation/styles/email_view_app_bar_widget_styles.dart'; import 'package:tmail_ui_user/features/email/presentation/widgets/email_view_back_button.dart'; import 'package:tmail_ui_user/main/localizations/app_localizations.dart'; @@ -19,7 +19,6 @@ typedef OnMoreActionClick = void Function(PresentationEmail, RelativeRect?); class EmailViewAppBarWidget extends StatelessWidget { final _imagePaths = Get.find(); final _responsiveUtils = Get.find(); - final _singleEmailController = Get.find(); final PresentationEmail presentationEmail; final List? optionsWidget; @@ -28,29 +27,45 @@ class EmailViewAppBarWidget extends StatelessWidget { final VoidCallback onBackAction; final OnEmailActionClick? onEmailActionClick; final OnMoreActionClick? onMoreActionClick; + final bool supportBackAction; + final BoxDecoration? appBarDecoration; + final EmailLoaded? emailLoaded; + final bool isInsideThreadDetailView; + final double? height; + final EdgeInsetsGeometry? iconPadding; + final EdgeInsetsGeometry? iconMargin; + final EdgeInsetsGeometry? padding; EmailViewAppBarWidget({ Key? key, required this.presentationEmail, required this.onBackAction, required this.isSearchActivated, + required this.emailLoaded, this.mailboxContain, this.onEmailActionClick, this.onMoreActionClick, this.optionsWidget, + this.supportBackAction = true, + this.appBarDecoration, + this.isInsideThreadDetailView = false, + this.height, + this.iconPadding, + this.iconMargin, + this.padding, }) : super(key: key); @override Widget build(BuildContext context) { final child = LayoutBuilder(builder: (context, constraints) { return Container( - height: PlatformInfo.isIOS + height: height ?? (PlatformInfo.isIOS ? EmailViewAppBarWidgetStyles.heightIOS(context, _responsiveUtils) - : EmailViewAppBarWidgetStyles.height, - padding: PlatformInfo.isIOS + : EmailViewAppBarWidgetStyles.height), + padding: padding ?? (PlatformInfo.isIOS ? EmailViewAppBarWidgetStyles.paddingIOS(context, _responsiveUtils) - : EmailViewAppBarWidgetStyles.padding, - decoration: const BoxDecoration( + : EmailViewAppBarWidgetStyles.padding), + decoration: appBarDecoration ?? const BoxDecoration( border: Border( bottom: BorderSide( color: EmailViewAppBarWidgetStyles.bottomBorderColor, @@ -63,89 +78,30 @@ class EmailViewAppBarWidget extends StatelessWidget { ), color: EmailViewAppBarWidgetStyles.backgroundColor, ), - child: Row(children: [ - if (_supportDisplayMailboxNameTitle(context)) - EmailViewBackButton( - imagePaths: _imagePaths, - onBackAction: onBackAction, - mailboxContain: mailboxContain, - isSearchActivated: isSearchActivated, - maxWidth: constraints.maxWidth, + child: Row(mainAxisAlignment: MainAxisAlignment.end, children: [ + if (_supportDisplayMailboxNameTitle(context) && supportBackAction) + Expanded( + child: Align( + alignment: Alignment.centerLeft, + child: EmailViewBackButton( + imagePaths: _imagePaths, + onBackAction: onBackAction, + mailboxContain: mailboxContain, + isSearchActivated: isSearchActivated, + maxWidth: constraints.maxWidth, + ), + ), ), - const Spacer(), Row( children: [ - if (optionsWidget != null) ... optionsWidget!, - TMailButtonWidget.fromIcon( - icon: _imagePaths.icMoveEmail, - iconSize: EmailViewAppBarWidgetStyles.buttonIconSize, - iconColor: EmailViewAppBarWidgetStyles.iconColor, - tooltipMessage: AppLocalizations.of(context).move_message, - backgroundColor: Colors.transparent, - onTapActionCallback: () => onEmailActionClick?.call(presentationEmail, EmailActionType.moveToMailbox) - ), - TMailButtonWidget.fromIcon( - icon: presentationEmail.hasStarred - ? _imagePaths.icStar - : _imagePaths.icUnStar, - iconSize: EmailViewAppBarWidgetStyles.buttonIconSize, - iconColor: presentationEmail.hasStarred - ? null - : EmailViewAppBarWidgetStyles.iconColor, - backgroundColor: Colors.transparent, - tooltipMessage: presentationEmail.hasStarred - ? AppLocalizations.of(context).not_starred - : AppLocalizations.of(context).mark_as_starred, - onTapActionCallback: () => onEmailActionClick?.call( - presentationEmail, - presentationEmail.hasStarred ? EmailActionType.unMarkAsStarred : EmailActionType.markAsStarred - ) - ), - if (PlatformInfo.isWeb && PlatformInfo.isCanvasKit) - Obx(() => AbsorbPointer( - absorbing: _singleEmailController.currentEmailLoaded.value == null, - child: TMailButtonWidget.fromIcon( - icon: _imagePaths.icPrinter, - iconSize: EmailViewAppBarWidgetStyles.deleteButtonIconSize, - iconColor: EmailViewAppBarWidgetStyles.iconColor, - backgroundColor: Colors.transparent, - tooltipMessage: AppLocalizations.of(context).printAll, - onTapActionCallback: () => onEmailActionClick?.call( - presentationEmail, - EmailActionType.printAll, - ), - ), - )), - TMailButtonWidget.fromIcon( - icon: _imagePaths.icDeleteComposer, - iconSize: EmailViewAppBarWidgetStyles.deleteButtonIconSize, - iconColor: EmailViewAppBarWidgetStyles.iconColor, - backgroundColor: Colors.transparent, - tooltipMessage: canDeletePermanently - ? AppLocalizations.of(context).delete_permanently - : AppLocalizations.of(context).move_to_trash, - onTapActionCallback: () { - if (canDeletePermanently) { - onEmailActionClick?.call(presentationEmail, EmailActionType.deletePermanently); - } else { - onEmailActionClick?.call(presentationEmail, EmailActionType.moveToTrash); - } - } - ), - TMailButtonWidget.fromIcon( - icon: _imagePaths.icMoreVertical, - iconSize: EmailViewAppBarWidgetStyles.buttonIconSize, - iconColor: EmailViewAppBarWidgetStyles.iconColor, - backgroundColor: Colors.transparent, - tooltipMessage: AppLocalizations.of(context).more, - onTapActionCallback: _responsiveUtils.isScreenWithShortestSide(context) - ? () => onMoreActionClick?.call(presentationEmail, null) - : null, - onTapActionAtPositionCallback: !_responsiveUtils.isScreenWithShortestSide(context) - ? (position) => onMoreActionClick?.call(presentationEmail, position) - : null - ), - ] + if (optionsWidget != null) ... optionsWidget!, + ..._buildActionButtons( + appLocalizations: AppLocalizations.of(context), + isScreenWithShortestSide: _responsiveUtils.isScreenWithShortestSide(context), + isResponsiveMobile: _responsiveUtils.isMobile(context), + isResponsiveDesktop: _responsiveUtils.isDesktop(context), + ), + ] ), ]) ); @@ -154,7 +110,7 @@ class EmailViewAppBarWidget extends StatelessWidget { return PopScope( canPop: false, onPopInvokedWithResult: (_, __) { - if (!PlatformInfo.isAndroid) return; + if (PlatformInfo.isAndroid) return; onBackAction(); }, child: child, @@ -178,4 +134,137 @@ class EmailViewAppBarWidget extends StatelessWidget { bool get canDeletePermanently { return mailboxContain?.isTrash == true || mailboxContain?.isSpam == true; } + + Widget _getReplyButton(AppLocalizations appLocalizations) => TMailButtonWidget.fromIcon( + icon: _imagePaths.icReply, + iconSize: EmailViewAppBarWidgetStyles.buttonIconSize, + iconColor: EmailViewAppBarWidgetStyles.iconColor, + tooltipMessage: appLocalizations.reply, + backgroundColor: Colors.transparent, + onTapActionCallback: () => onEmailActionClick?.call( + presentationEmail, + EmailActionType.reply, + ), + padding: iconPadding, + margin: iconMargin, + ); + + Widget _getPrintButton(AppLocalizations appLocalizations) => AbsorbPointer( + absorbing: emailLoaded == null, + child: TMailButtonWidget.fromIcon( + icon: _imagePaths.icPrinter, + iconSize: EmailViewAppBarWidgetStyles.deleteButtonIconSize, + iconColor: EmailViewAppBarWidgetStyles.iconColor, + backgroundColor: Colors.transparent, + tooltipMessage: appLocalizations.printAll, + onTapActionCallback: () => onEmailActionClick?.call( + presentationEmail, + EmailActionType.printAll, + ), + padding: iconPadding, + margin: iconMargin, + ), + ); + + Widget _getMoveEmailButton(AppLocalizations appLocalizations) => TMailButtonWidget.fromIcon( + icon: _imagePaths.icMoveEmail, + iconSize: EmailViewAppBarWidgetStyles.buttonIconSize, + iconColor: EmailViewAppBarWidgetStyles.iconColor, + tooltipMessage: appLocalizations.move_message, + backgroundColor: Colors.transparent, + onTapActionCallback: () => onEmailActionClick?.call( + presentationEmail, + EmailActionType.moveToMailbox, + ), + padding: iconPadding, + margin: iconMargin, + ); + + Widget _getMarkStarButton(AppLocalizations applocalizations) => TMailButtonWidget.fromIcon( + icon: presentationEmail.hasStarred + ? _imagePaths.icStar + : _imagePaths.icUnStar, + iconSize: EmailViewAppBarWidgetStyles.buttonIconSize, + iconColor: presentationEmail.hasStarred + ? null + : EmailViewAppBarWidgetStyles.iconColor, + backgroundColor: Colors.transparent, + tooltipMessage: presentationEmail.hasStarred + ? applocalizations.not_starred + : applocalizations.mark_as_starred, + onTapActionCallback: () => onEmailActionClick?.call( + presentationEmail, + presentationEmail.hasStarred + ? EmailActionType.unMarkAsStarred + : EmailActionType.markAsStarred + ), + padding: iconPadding, + margin: iconMargin, + ); + + Widget _getDeleteButton(AppLocalizations applocalizations) => TMailButtonWidget.fromIcon( + icon: _imagePaths.icDeleteComposer, + iconSize: EmailViewAppBarWidgetStyles.deleteButtonIconSize, + iconColor: EmailViewAppBarWidgetStyles.iconColor, + backgroundColor: Colors.transparent, + tooltipMessage: canDeletePermanently + ? applocalizations.delete_permanently + : applocalizations.move_to_trash, + onTapActionCallback: () { + if (canDeletePermanently) { + onEmailActionClick?.call(presentationEmail, EmailActionType.deletePermanently); + } else { + onEmailActionClick?.call(presentationEmail, EmailActionType.moveToTrash); + } + }, + padding: iconPadding, + margin: iconMargin, + ); + + Widget _getMoreButton( + AppLocalizations applocalizations, + bool isScreenWithShortestSide, + ) => TMailButtonWidget.fromIcon( + icon: _imagePaths.icMoreVertical, + iconSize: EmailViewAppBarWidgetStyles.buttonIconSize, + iconColor: EmailViewAppBarWidgetStyles.iconColor, + backgroundColor: Colors.transparent, + tooltipMessage: applocalizations.more, + onTapActionCallback: isScreenWithShortestSide + ? () => onMoreActionClick?.call(presentationEmail, null) + : null, + onTapActionAtPositionCallback: !isScreenWithShortestSide + ? (position) => onMoreActionClick?.call(presentationEmail, position) + : null, + padding: iconPadding, + margin: iconMargin, + ); + + List _buildActionButtons({ + required AppLocalizations appLocalizations, + required bool isScreenWithShortestSide, + required bool isResponsiveMobile, + required bool isResponsiveDesktop, + }) { + if (!isInsideThreadDetailView) { + return [ + _getPrintButton(appLocalizations), + _getMoveEmailButton(appLocalizations), + _getMarkStarButton(appLocalizations), + _getDeleteButton(appLocalizations), + _getMoreButton(appLocalizations, isScreenWithShortestSide), + ]; + } + + return [ + _getReplyButton(appLocalizations), + if (!isResponsiveMobile) + _getMoveEmailButton(appLocalizations), + if (isResponsiveDesktop) ...[ + _getMarkStarButton(appLocalizations), + _getDeleteButton(appLocalizations), + ], + _getMoreButton(appLocalizations, isScreenWithShortestSide), + ]; + } } \ No newline at end of file diff --git a/lib/features/email/presentation/widgets/email_view_bottom_bar_widget.dart b/lib/features/email/presentation/widgets/email_view_bottom_bar_widget.dart index 095c4e7dd..bebaa0a57 100644 --- a/lib/features/email/presentation/widgets/email_view_bottom_bar_widget.dart +++ b/lib/features/email/presentation/widgets/email_view_bottom_bar_widget.dart @@ -21,6 +21,8 @@ class EmailViewBottomBarWidget extends StatelessWidget { final EmailLoaded emailLoaded; final String userName; final OnEmailActionCallback emailActionCallback; + final BoxDecoration? bottomBarDecoration; + final EdgeInsetsGeometry? padding; const EmailViewBottomBarWidget({ Key? key, @@ -30,6 +32,8 @@ class EmailViewBottomBarWidget extends StatelessWidget { required this.emailLoaded, required this.userName, required this.emailActionCallback, + this.bottomBarDecoration, + this.padding, }) : super(key: key); @override @@ -40,7 +44,7 @@ class EmailViewBottomBarWidget extends StatelessWidget { ); return Container( - decoration: const BoxDecoration( + decoration: bottomBarDecoration ?? const BoxDecoration( border: Border( top: BorderSide( color: EmailViewBottomBarWidgetStyles.topBorderColor, @@ -53,7 +57,7 @@ class EmailViewBottomBarWidget extends StatelessWidget { ), color: EmailViewBottomBarWidgetStyles.backgroundColor ), - padding: EmailViewBottomBarWidgetStyles.padding, + padding: padding ?? EmailViewBottomBarWidgetStyles.padding, child: IntrinsicHeight( child: Row( children: [ diff --git a/lib/features/email/presentation/widgets/information_sender_and_receiver_builder.dart b/lib/features/email/presentation/widgets/information_sender_and_receiver_builder.dart index 8ec70fff8..b7e8086b5 100644 --- a/lib/features/email/presentation/widgets/information_sender_and_receiver_builder.dart +++ b/lib/features/email/presentation/widgets/information_sender_and_receiver_builder.dart @@ -1,14 +1,17 @@ import 'package:core/presentation/extensions/color_extension.dart'; import 'package:core/presentation/resources/image_paths.dart'; +import 'package:core/presentation/utils/icon_utils.dart'; import 'package:core/presentation/utils/responsive_utils.dart'; import 'package:core/presentation/utils/theme_utils.dart'; import 'package:core/presentation/views/button/tmail_button_widget.dart'; +import 'package:core/presentation/views/image/avatar_builder.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:model/email/email_action_type.dart'; import 'package:model/email/presentation_email.dart'; import 'package:model/extensions/presentation_email_extension.dart'; import 'package:tmail_ui_user/features/base/widget/email_avatar_builder.dart'; +import 'package:tmail_ui_user/features/email/presentation/model/email_loaded.dart'; import 'package:tmail_ui_user/features/email/presentation/model/email_unsubscribe.dart'; import 'package:tmail_ui_user/features/email/presentation/model/smime_signature_status.dart'; import 'package:tmail_ui_user/features/email/presentation/widgets/email_receiver_widget.dart'; @@ -27,6 +30,12 @@ class InformationSenderAndReceiverBuilder extends StatelessWidget { final OnEmailActionClick? onEmailActionClick; final double? maxBodyHeight; final SMimeSignatureStatus? sMimeStatus; + final bool isInsideThreadDetailView; + final EmailLoaded? emailLoaded; + final OnMoreActionClick? onMoreActionClick; + final bool showRecipients; + final VoidCallback? onToggleThreadDetailCollapseExpand; + final OnTapAvatarActionClick? onTapAvatarActionClick; const InformationSenderAndReceiverBuilder({ Key? key, @@ -38,66 +47,110 @@ class InformationSenderAndReceiverBuilder extends StatelessWidget { this.maxBodyHeight, this.openEmailAddressDetailAction, this.onEmailActionClick, + this.isInsideThreadDetailView = false, + this.emailLoaded, + this.onMoreActionClick, + this.showRecipients = true, + this.onToggleThreadDetailCollapseExpand, + this.onTapAvatarActionClick, }) : super(key: key); @override Widget build(BuildContext context) { return Padding( - padding: const EdgeInsetsDirectional.only(start: 16, end: 16, top: 16), + padding: const EdgeInsetsDirectional.symmetric(horizontal: 16), child: Row( - crossAxisAlignment: emailSelected.countRecipients > 0 + crossAxisAlignment: emailSelected.countRecipients > 0 && (showRecipients || responsiveUtils.isMobile(context)) ? CrossAxisAlignment.start : CrossAxisAlignment.center, children: [ - EmailAvatarBuilder(emailSelected: emailSelected), + EmailAvatarBuilder( + emailSelected: emailSelected, + onTapAvatarActionClick: onTapAvatarActionClick, + ), const SizedBox(width: 16), Expanded(child: LayoutBuilder(builder: (context, constraints) { - return Transform( - transform: Matrix4.translationValues(0.0, -5.0, 0.0), + return InkWell( + onTap: onToggleThreadDetailCollapseExpand, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (emailSelected.from?.isNotEmpty == true) - Row(children: [ - Expanded(child: Row( - children: [ - Flexible(child: Transform( - transform: Matrix4.translationValues(-5.0, 0.0, 0.0), - child: EmailSenderBuilder( - emailAddress: emailSelected.from!.first, - openEmailAddressDetailAction: openEmailAddressDetailAction, - ) - )), - if (sMimeStatus != null && sMimeStatus != SMimeSignatureStatus.notSigned) - Tooltip( - key: const Key('smime_signature_status_icon'), - message: sMimeStatus!.getTooltipMessage(context), - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: SvgPicture.asset( - sMimeStatus!.getIcon(imagePaths), - fit: BoxFit.fill, + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Row( + children: [ + if (emailSelected.from?.isNotEmpty == true) + Flexible( + child: EmailSenderBuilder( + emailAddress: emailSelected.from!.first, + openEmailAddressDetailAction: openEmailAddressDetailAction, ), ), - ), - if (!emailSelected.isSubscribed && emailUnsubscribe != null && !responsiveUtils.isPortraitMobile(context)) - TMailButtonWidget.fromText( - text: AppLocalizations.of(context).unsubscribe, - textStyle: ThemeUtils.defaultTextStyleInterFont.copyWith( - fontWeight: FontWeight.normal, - fontSize: 14, - color: AppColor.colorTextBody, - decoration: TextDecoration.underline, + if (sMimeStatus != null && sMimeStatus != SMimeSignatureStatus.notSigned) + Tooltip( + key: const Key('smime_signature_status_icon'), + message: sMimeStatus!.getTooltipMessage(context), + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: SvgPicture.asset( + sMimeStatus!.getIcon(imagePaths), + fit: BoxFit.fill, + ), + ), ), - padding: const EdgeInsetsDirectional.symmetric(vertical: 5, horizontal: 8), - backgroundColor: Colors.transparent, - onTapActionCallback: () => onEmailActionClick?.call(emailSelected, EmailActionType.unsubscribe), - ), - ] - )), - ReceivedTimeBuilder(emailSelected: emailSelected), - ]), - if (emailSelected.countRecipients > 0) + if (!emailSelected.isSubscribed && emailUnsubscribe != null && !responsiveUtils.isPortraitMobile(context)) + TMailButtonWidget.fromText( + text: AppLocalizations.of(context).unsubscribe, + textStyle: ThemeUtils.defaultTextStyleInterFont.copyWith( + fontWeight: FontWeight.normal, + fontSize: 14, + color: AppColor.colorTextBody, + decoration: TextDecoration.underline, + ), + padding: const EdgeInsetsDirectional.symmetric(vertical: 5, horizontal: 8), + backgroundColor: Colors.transparent, + onTapActionCallback: () => onEmailActionClick?.call(emailSelected, EmailActionType.unsubscribe), + ), + if (isInsideThreadDetailView && !responsiveUtils.isMobile(context)) + ReceivedTimeBuilder( + emailSelected: emailSelected, + padding: const EdgeInsetsDirectional.only(start: 16, top: 2), + ), + ], + ), + ), + if (!isInsideThreadDetailView && !responsiveUtils.isMobile(context)) + ReceivedTimeBuilder( + emailSelected: emailSelected, + padding: const EdgeInsetsDirectional.only(start: 16, top: 2), + ), + if (isInsideThreadDetailView) + EmailViewAppBarWidget( + key: const Key('email_view_app_bar_widget'), + presentationEmail: emailSelected, + isSearchActivated: false, + onBackAction: () {}, + onEmailActionClick: onEmailActionClick, + onMoreActionClick: onMoreActionClick, + supportBackAction: false, + appBarDecoration: const BoxDecoration(), + emailLoaded: emailLoaded, + isInsideThreadDetailView: isInsideThreadDetailView, + height: IconUtils.defaultIconSize, + iconPadding: EdgeInsets.zero, + iconMargin: const EdgeInsetsDirectional.only(start: 16), + padding: const EdgeInsets.symmetric(horizontal: 8), + ), + ], + ), + if (responsiveUtils.isMobile(context)) + ReceivedTimeBuilder( + emailSelected: emailSelected, + padding: const EdgeInsetsDirectional.only(top: 5), + ), + if (emailSelected.countRecipients > 0 && showRecipients) EmailReceiverWidget( emailSelected: emailSelected, maxWidth: constraints.maxWidth, diff --git a/lib/features/email/presentation/widgets/received_time_builder.dart b/lib/features/email/presentation/widgets/received_time_builder.dart index b38730425..b35b8f240 100644 --- a/lib/features/email/presentation/widgets/received_time_builder.dart +++ b/lib/features/email/presentation/widgets/received_time_builder.dart @@ -10,16 +10,18 @@ import 'package:model/extensions/presentation_email_extension.dart'; class ReceivedTimeBuilder extends StatelessWidget { final PresentationEmail emailSelected; + final EdgeInsetsGeometry? padding; const ReceivedTimeBuilder({ Key? key, required this.emailSelected, + this.padding, }) : super(key: key); @override Widget build(BuildContext context) { return Padding( - padding: const EdgeInsetsDirectional.only(start: 16), + padding: padding ?? const EdgeInsetsDirectional.only(start: 16), child: Text( emailSelected.getReceivedAt( Localizations.localeOf(context).toLanguageTag(), 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 f336d7256..a41898779 100644 --- a/lib/features/mailbox_dashboard/presentation/bindings/mailbox_dashboard_bindings.dart +++ b/lib/features/mailbox_dashboard/presentation/bindings/mailbox_dashboard_bindings.dart @@ -137,6 +137,7 @@ import 'package:tmail_ui_user/features/thread/domain/usecases/move_multiple_emai import 'package:tmail_ui_user/features/thread/domain/usecases/search_email_interactor.dart'; import 'package:tmail_ui_user/features/thread/domain/usecases/search_more_email_interactor.dart'; import 'package:tmail_ui_user/features/thread/presentation/thread_bindings.dart'; +import 'package:tmail_ui_user/features/thread_detail/presentation/thread_detail_bindings.dart'; import 'package:tmail_ui_user/main/exceptions/cache_exception_thrower.dart'; import 'package:tmail_ui_user/main/exceptions/remote_exception_thrower.dart'; import 'package:tmail_ui_user/main/utils/ios_sharing_manager.dart'; @@ -150,8 +151,9 @@ class MailboxDashBoardBindings extends BaseBindings { SendingQueueBindings().dependencies(); MailboxBindings().dependencies(); ThreadBindings().dependencies(); - EmailBindings().dependencies(); SearchEmailBindings().dependencies(); + ThreadDetailBindings().dependencies(); + EmailBindings().dependencies(); QuotasBindings().dependencies(); } diff --git a/lib/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller.dart b/lib/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller.dart index d6f41d416..f7612c844 100644 --- a/lib/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller.dart +++ b/lib/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller.dart @@ -861,9 +861,13 @@ class MailboxDashBoardController extends ReloadableController selectedEmail.value = null; } - void openEmailDetailedView(PresentationEmail presentationEmail) { + void openEmailDetailedView(PresentationEmail presentationEmail, {bool singleEmail = false}) { setSelectedEmail(presentationEmail); - dispatchRoute(DashboardRoutes.emailDetailed); + if (singleEmail) { + dispatchRoute(DashboardRoutes.emailDetailed); + } else { + dispatchRoute(DashboardRoutes.threadDetailed); + } if (PlatformInfo.isWeb && presentationEmail.routeWeb != null) { RouteUtils.replaceBrowserHistory( title: 'Email-${presentationEmail.id?.id.value ?? ''}', @@ -898,6 +902,8 @@ class MailboxDashBoardController extends ReloadableController log('MailboxDashBoardController::handleAdvancedSearchEmail:'); if (_searchInsideEmailDetailedViewIsActive()) { _closeEmailDetailedView(); + } else if (_searchInsideThreadDetailViewIsActive()) { + _closeEmailDetailedView(); } _unSelectedMailbox(); searchController.clearFilterSuggestion(); @@ -923,6 +929,8 @@ class MailboxDashBoardController extends ReloadableController clearFilterMessageOption(); if (_searchInsideEmailDetailedViewIsActive()) { _closeEmailDetailedView(); + } else if (_searchInsideThreadDetailViewIsActive()) { + _closeEmailDetailedView(); } _unSelectedMailbox(); searchController.clearFilterSuggestion(); @@ -951,6 +959,13 @@ class MailboxDashBoardController extends ReloadableController && dashboardRoute.value == DashboardRoutes.emailDetailed; } + bool _searchInsideThreadDetailViewIsActive() { + return PlatformInfo.isWeb + && currentContext != null + && responsiveUtils.isDesktop(currentContext!) + && dashboardRoute.value == DashboardRoutes.threadDetailed; + } + void clearSearchEmail() { dispatchAction(ClearSearchEmailAction()); searchController.disableSimpleSearch(); @@ -2814,6 +2829,8 @@ class MailboxDashBoardController extends ReloadableController searchController.clearFilterSuggestion(); if (_searchInsideEmailDetailedViewIsActive()) { _closeEmailDetailedView(); + } else if (_searchInsideThreadDetailViewIsActive()) { + _closeEmailDetailedView(); } _unSelectedMailbox(); dispatchAction(QuickSearchEmailByFromAction(emailAddress)); diff --git a/lib/features/mailbox_dashboard/presentation/mailbox_dashboard_view.dart b/lib/features/mailbox_dashboard/presentation/mailbox_dashboard_view.dart index f2949e644..5d81d6b60 100644 --- a/lib/features/mailbox_dashboard/presentation/mailbox_dashboard_view.dart +++ b/lib/features/mailbox_dashboard/presentation/mailbox_dashboard_view.dart @@ -12,6 +12,7 @@ import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/model/dash import 'package:tmail_ui_user/features/search/email/presentation/search_email_view.dart'; import 'package:tmail_ui_user/features/sending_queue/presentation/sending_queue_view.dart'; import 'package:tmail_ui_user/features/thread/presentation/thread_view.dart'; +import 'package:tmail_ui_user/features/thread_detail/presentation/thread_detail_view.dart'; class MailboxDashBoardView extends BaseMailboxDashBoardView { @@ -54,6 +55,13 @@ class MailboxDashBoardView extends BaseMailboxDashBoardView { tabletLarge: bodyView, landscapeTablet: bodyView, mobile: const EmailView()); + case DashboardRoutes.threadDetailed: + return ResponsiveWidget( + responsiveUtils: controller.responsiveUtils, + desktop: bodyView, + tabletLarge: bodyView, + landscapeTablet: bodyView, + mobile: const ThreadDetailView()); case DashboardRoutes.searchEmail: return SafeArea(child: SearchEmailView()); case DashboardRoutes.sendingQueue: diff --git a/lib/features/mailbox_dashboard/presentation/mailbox_dashboard_view_web.dart b/lib/features/mailbox_dashboard/presentation/mailbox_dashboard_view_web.dart index b2f9d554d..b85c05c57 100644 --- a/lib/features/mailbox_dashboard/presentation/mailbox_dashboard_view_web.dart +++ b/lib/features/mailbox_dashboard/presentation/mailbox_dashboard_view_web.dart @@ -52,6 +52,7 @@ import 'package:tmail_ui_user/features/thread/domain/model/filter_message_option import 'package:tmail_ui_user/features/thread/presentation/model/popup_menu_item_filter_message_action.dart'; import 'package:tmail_ui_user/features/thread/presentation/styles/spam_banner/spam_report_banner_web_styles.dart'; import 'package:tmail_ui_user/features/thread/presentation/thread_view.dart'; +import 'package:tmail_ui_user/features/thread_detail/presentation/thread_detail_view.dart'; import 'package:tmail_ui_user/main/localizations/app_localizations.dart'; import 'package:tmail_ui_user/main/routes/route_navigation.dart'; @@ -269,6 +270,8 @@ class MailboxDashBoardView extends BaseMailboxDashBoardView { return _buildThreadViewForWebDesktop(context); case DashboardRoutes.emailDetailed: return const EmailView(); + case DashboardRoutes.threadDetailed: + return const ThreadDetailView(); default: return const SizedBox.shrink(); } @@ -298,6 +301,19 @@ class MailboxDashBoardView extends BaseMailboxDashBoardView { ], ), ); + case DashboardRoutes.threadDetailed: + return _buildScaffoldHaveDrawer( + body: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: ResponsiveUtils.defaultSizeLeftMenuMobile, + child: ThreadView()), + const VerticalDivider(width: 1), + const Expanded(child: ThreadDetailView()), + ], + ), + ); default: return _buildScaffoldHaveDrawer( body: Row( @@ -317,6 +333,8 @@ class MailboxDashBoardView extends BaseMailboxDashBoardView { switch(controller.dashboardRoute.value) { case DashboardRoutes.thread: return _buildScaffoldHaveDrawer(body: ThreadView()); + case DashboardRoutes.threadDetailed: + return const ThreadDetailView(); case DashboardRoutes.emailDetailed: return const EmailView(); case DashboardRoutes.searchEmail: diff --git a/lib/features/mailbox_dashboard/presentation/model/dashboard_routes.dart b/lib/features/mailbox_dashboard/presentation/model/dashboard_routes.dart index 65b7a3120..375eae627 100644 --- a/lib/features/mailbox_dashboard/presentation/model/dashboard_routes.dart +++ b/lib/features/mailbox_dashboard/presentation/model/dashboard_routes.dart @@ -2,6 +2,7 @@ enum DashboardRoutes { thread, emailDetailed, + threadDetailed, searchEmail, waiting, sendingQueue; diff --git a/lib/features/thread/data/extensions/email_cache_extension.dart b/lib/features/thread/data/extensions/email_cache_extension.dart index 31d6f9fe7..3eca22cc3 100644 --- a/lib/features/thread/data/extensions/email_cache_extension.dart +++ b/lib/features/thread/data/extensions/email_cache_extension.dart @@ -32,6 +32,7 @@ extension EmailCacheExtension on EmailCache { mailboxIds: mailboxIds != null ? Map.fromIterables(mailboxIds!.keys.map((value) => MailboxId(Id(value))), mailboxIds!.values) : null, + threadId: threadId == null ? null : ThreadId(Id(threadId!)), headerCalendarEvent: headerCalendarEvent != null ? Map.fromIterables(headerCalendarEvent!.keys.map((value) => IndividualHeaderIdentifier(value)), headerCalendarEvent!.values) : null, diff --git a/lib/features/thread/data/extensions/email_extension.dart b/lib/features/thread/data/extensions/email_extension.dart index afda4747c..ba5b2181e 100644 --- a/lib/features/thread/data/extensions/email_extension.dart +++ b/lib/features/thread/data/extensions/email_extension.dart @@ -24,6 +24,7 @@ extension EmailExtension on Email { bcc: bcc?.map((emailAddress) => emailAddress.toEmailAddressHiveCache()).toList(), replyTo: replyTo?.map((emailAddress) => emailAddress.toEmailAddressHiveCache()).toList(), mailboxIds: mailboxIds?.toMapString(), + threadId: threadId?.id.value, headerCalendarEvent: headerCalendarEvent?.toMapString(), blobId: blobId?.value, xPriorityHeader: xPriorityHeader?.toMapString(), diff --git a/lib/features/thread/data/model/email_cache.dart b/lib/features/thread/data/model/email_cache.dart index de5fc69e1..5e903e353 100644 --- a/lib/features/thread/data/model/email_cache.dart +++ b/lib/features/thread/data/model/email_cache.dart @@ -66,6 +66,9 @@ class EmailCache extends HiveObject with EquatableMixin { @HiveField(18) Map? priorityHeader; + @HiveField(19) + String? threadId; + EmailCache( this.id, { @@ -87,6 +90,7 @@ class EmailCache extends HiveObject with EquatableMixin { this.xPriorityHeader, this.importanceHeader, this.priorityHeader, + this.threadId, } ); @@ -111,5 +115,6 @@ class EmailCache extends HiveObject with EquatableMixin { xPriorityHeader, importanceHeader, priorityHeader, + threadId, ]; } \ No newline at end of file diff --git a/lib/features/thread/domain/constants/thread_constants.dart b/lib/features/thread/domain/constants/thread_constants.dart index 37fbac92f..a001e4c3a 100644 --- a/lib/features/thread/domain/constants/thread_constants.dart +++ b/lib/features/thread/domain/constants/thread_constants.dart @@ -22,6 +22,7 @@ class ThreadConstants { EmailProperty.hasAttachment, EmailProperty.replyTo, EmailProperty.mailboxIds, + EmailProperty.threadId, IndividualHeaderIdentifier.xPriorityHeader.value, IndividualHeaderIdentifier.importanceHeader.value, IndividualHeaderIdentifier.priorityHeader.value, @@ -56,6 +57,7 @@ class ThreadConstants { EmailProperty.hasAttachment, EmailProperty.replyTo, EmailProperty.mailboxIds, + EmailProperty.threadId, EmailProperty.bodyValues, EmailProperty.htmlBody, EmailProperty.attachments, diff --git a/lib/features/thread_detail/data/data_source/thread_detail_data_source.dart b/lib/features/thread_detail/data/data_source/thread_detail_data_source.dart index a4d8960fc..38e270e59 100644 --- a/lib/features/thread_detail/data/data_source/thread_detail_data_source.dart +++ b/lib/features/thread_detail/data/data_source/thread_detail_data_source.dart @@ -1,4 +1,6 @@ import 'package:jmap_dart_client/jmap/account_id.dart'; +import 'package:jmap_dart_client/jmap/core/properties/properties.dart'; +import 'package:jmap_dart_client/jmap/core/session/session.dart'; import 'package:jmap_dart_client/jmap/mail/email/email.dart'; abstract class ThreadDetailDataSource { @@ -6,4 +8,11 @@ abstract class ThreadDetailDataSource { ThreadId threadId, AccountId accountId, ); + + Future> getEmailsByIds( + Session session, + AccountId accountId, + List emailIds, { + Properties? properties, + }); } \ No newline at end of file diff --git a/lib/features/thread_detail/data/data_source/thread_detail_remote_data_source_impl.dart b/lib/features/thread_detail/data/data_source/thread_detail_remote_data_source_impl.dart index 2c2ad7dd0..eff6db02a 100644 --- a/lib/features/thread_detail/data/data_source/thread_detail_remote_data_source_impl.dart +++ b/lib/features/thread_detail/data/data_source/thread_detail_remote_data_source_impl.dart @@ -1,4 +1,6 @@ import 'package:jmap_dart_client/jmap/account_id.dart'; +import 'package:jmap_dart_client/jmap/core/properties/properties.dart'; +import 'package:jmap_dart_client/jmap/core/session/session.dart'; import 'package:jmap_dart_client/jmap/mail/email/email.dart'; import 'package:tmail_ui_user/features/thread_detail/data/data_source/thread_detail_data_source.dart'; import 'package:tmail_ui_user/features/thread_detail/data/network/thread_detail_api.dart'; @@ -22,4 +24,21 @@ class ThreadDetailRemoteDataSourceImpl implements ThreadDetailDataSource { return threadDetailApi.getThreadById(threadId, accountId); }).catchError(exceptionThrower.throwException); } + + @override + Future> getEmailsByIds( + Session session, + AccountId accountId, + List emailIds, { + Properties? properties, + }) { + return Future.sync(() async { + return threadDetailApi.getEmailsByIds( + session, + accountId, + emailIds, + properties: properties, + ); + }).catchError(exceptionThrower.throwException); + } } \ No newline at end of file diff --git a/lib/features/thread_detail/data/network/thread_detail_api.dart b/lib/features/thread_detail/data/network/thread_detail_api.dart index 98304e05c..c62d706c6 100644 --- a/lib/features/thread_detail/data/network/thread_detail_api.dart +++ b/lib/features/thread_detail/data/network/thread_detail_api.dart @@ -1,10 +1,18 @@ +import 'package:core/presentation/extensions/list_extensions.dart'; import 'package:get/get_utils/get_utils.dart'; 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/properties/properties.dart'; +import 'package:jmap_dart_client/jmap/core/session/session.dart'; import 'package:jmap_dart_client/jmap/jmap_request.dart'; import 'package:jmap_dart_client/jmap/mail/email/email.dart'; +import 'package:jmap_dart_client/jmap/mail/email/get/get_email_method.dart'; +import 'package:jmap_dart_client/jmap/mail/email/get/get_email_response.dart'; import 'package:jmap_dart_client/jmap/thread/get/get_thread_method.dart'; import 'package:jmap_dart_client/jmap/thread/get/get_thread_response.dart'; +import 'package:model/extensions/session_extension.dart'; +import 'package:tmail_ui_user/features/thread/domain/constants/thread_constants.dart'; +import 'package:tmail_ui_user/main/error/capability_validator.dart'; class ThreadDetailApi { const ThreadDetailApi(this._httpClient); @@ -34,4 +42,49 @@ class ThreadDetailApi { (thread) => thread.id == threadId, )!.emailIds; } + + Future> getEmailsByIds( + Session session, + AccountId accountId, + List emailIds, { + Properties? properties, + }) async { + final jmapRequestBuilder = JmapRequestBuilder( + _httpClient, + ProcessingInvocation(), + ); + final getLimit = session.getMaxObjectsInGet(accountId) + ?? ThreadConstants.defaultLimit; + + final listOfListEmails = await Future.wait( + emailIds.chunks(getLimit.value.toInt()).map((chunkEmailIds) async { + final getEmailMethod = GetEmailMethod(accountId) + ..addIds(chunkEmailIds.map((emailId) => emailId.id).toSet()); + + if (properties != null) { + getEmailMethod.addProperties(properties); + } + + final getEmailInvocation = jmapRequestBuilder.invocation(getEmailMethod); + + final capabilities = getEmailMethod.requiredCapabilities + .toCapabilitiesSupportTeamMailboxes(session, accountId); + + final result = await (jmapRequestBuilder + ..usings(capabilities)) + .build() + .execute(); + + final resultList = result.parse( + getEmailInvocation.methodCallId, + GetEmailResponse.deserialize); + + return resultList!.list; + }) + ); + + return listOfListEmails.reduce( + (listAllEmails, nextListEmails) => listAllEmails..addAll(nextListEmails), + ); + } } \ No newline at end of file diff --git a/lib/features/thread_detail/data/repository/thread_detail_repository_impl.dart b/lib/features/thread_detail/data/repository/thread_detail_repository_impl.dart index 3102e5b6a..e368e349f 100644 --- a/lib/features/thread_detail/data/repository/thread_detail_repository_impl.dart +++ b/lib/features/thread_detail/data/repository/thread_detail_repository_impl.dart @@ -1,7 +1,17 @@ import 'package:core/data/model/source_type/data_source_type.dart'; +import 'package:core/presentation/extensions/list_extensions.dart'; import 'package:jmap_dart_client/jmap/account_id.dart'; +import 'package:jmap_dart_client/jmap/core/properties/properties.dart'; +import 'package:jmap_dart_client/jmap/core/session/session.dart'; import 'package:jmap_dart_client/jmap/mail/email/email.dart'; +import 'package:jmap_dart_client/jmap/mail/email/email_comparator.dart'; +import 'package:jmap_dart_client/jmap/mail/email/email_comparator_property.dart'; +import 'package:jmap_dart_client/jmap/mail/mailbox/mailbox.dart'; +import 'package:model/email/email_property.dart'; +import 'package:model/extensions/list_email_extension.dart'; +import 'package:tmail_ui_user/features/email/presentation/extensions/email_extension.dart'; import 'package:tmail_ui_user/features/thread_detail/data/data_source/thread_detail_data_source.dart'; +import 'package:tmail_ui_user/features/thread_detail/domain/exceptions/empty_thread_detail_exception.dart'; import 'package:tmail_ui_user/features/thread_detail/domain/repository/thread_detail_repository.dart'; class ThreadDetailRepositoryImpl implements ThreadDetailRepository { @@ -12,9 +22,100 @@ class ThreadDetailRepositoryImpl implements ThreadDetailRepository { @override Future> getThreadById( ThreadId threadId, - AccountId accountId - ) { - return threadDetailDataSource[DataSourceType.network]! + Session session, + AccountId accountId, + MailboxId sentMailboxId, + String ownEmailAddress, + ) async { + final originalEmailIds = await threadDetailDataSource[DataSourceType.network]! .getThreadById(threadId, accountId); + + if (originalEmailIds.isEmpty) { + throw EmptyThreadDetailException(); + } + + final filteredEmailIds = await Future.wait( + originalEmailIds + .chunks(20) + .map((emailIds) => _filterBadEmails( + session, + accountId, + emailIds, + sentMailboxId, + ownEmailAddress, + )) + ); + + return filteredEmailIds + .reduce((prev, curr) => prev + curr) + .sortWithResult(EmailComparator( + EmailComparatorProperty.receivedAt + )..setIsAscending(true)) + .map((e) => e.id!) + .toList(); + } + + Future> _filterBadEmails( + Session session, + AccountId accountId, + List emailIds, + MailboxId sentMailboxId, + String ownEmailAddress, + ) async { + int retry = 3; + while (retry > 0) { + try { + final emails = await threadDetailDataSource[DataSourceType.network]! + .getEmailsByIds( + session, + accountId, + emailIds, + properties: Properties({ + EmailProperty.id, + EmailProperty.mailboxIds, + EmailProperty.from, + EmailProperty.to, + EmailProperty.cc, + EmailProperty.bcc, + EmailProperty.receivedAt, + }), + ); + return emails + .where((email) => checkEmailValidForThreadDetail( + email, + sentMailboxId, + ownEmailAddress, + )) + .toList(); + } catch (e) { + retry--; + + if (retry <= 0) rethrow; + } + } + return []; + } + + bool checkEmailValidForThreadDetail( + Email email, + MailboxId sentMailboxId, + String ownEmailAddress, + ) { + return email.id != null && ( + !email.inSentMailbox(sentMailboxId) + || !email.fromMe(ownEmailAddress) + || !email.recipientsHasMe(ownEmailAddress) + ); + } + + @override + Future> getEmailsByIds( + Session session, + AccountId accountId, + List emailIds, { + Properties? properties, + }) { + return threadDetailDataSource[DataSourceType.network]! + .getEmailsByIds(session, accountId, emailIds, properties: properties); } } \ No newline at end of file diff --git a/lib/features/thread_detail/domain/exceptions/thread_detail_overload_exception.dart b/lib/features/thread_detail/domain/exceptions/thread_detail_overload_exception.dart new file mode 100644 index 000000000..f3d914392 --- /dev/null +++ b/lib/features/thread_detail/domain/exceptions/thread_detail_overload_exception.dart @@ -0,0 +1 @@ +class ThreadDetailOverloadException implements Exception {} \ No newline at end of file diff --git a/lib/features/thread_detail/domain/repository/thread_detail_repository.dart b/lib/features/thread_detail/domain/repository/thread_detail_repository.dart index c05bd5abc..6a47f59e8 100644 --- a/lib/features/thread_detail/domain/repository/thread_detail_repository.dart +++ b/lib/features/thread_detail/domain/repository/thread_detail_repository.dart @@ -1,9 +1,22 @@ import 'package:jmap_dart_client/jmap/account_id.dart'; +import 'package:jmap_dart_client/jmap/core/properties/properties.dart'; +import 'package:jmap_dart_client/jmap/core/session/session.dart'; import 'package:jmap_dart_client/jmap/mail/email/email.dart'; +import 'package:jmap_dart_client/jmap/mail/mailbox/mailbox.dart'; abstract class ThreadDetailRepository { Future> getThreadById( ThreadId threadId, + Session session, AccountId accountId, + MailboxId sentMailboxId, + String ownEmailAddress, ); + + Future> getEmailsByIds( + Session session, + AccountId accountId, + List emailIds, { + Properties? properties, + }); } \ No newline at end of file diff --git a/lib/features/thread_detail/domain/state/get_emails_by_ids_state.dart b/lib/features/thread_detail/domain/state/get_emails_by_ids_state.dart new file mode 100644 index 000000000..468e52662 --- /dev/null +++ b/lib/features/thread_detail/domain/state/get_emails_by_ids_state.dart @@ -0,0 +1,18 @@ +import 'package:core/presentation/state/failure.dart'; +import 'package:core/presentation/state/success.dart'; +import 'package:model/email/presentation_email.dart'; + +class GettingEmailsByIds extends LoadingState {} + +class GetEmailsByIdsSuccess extends UIState { + GetEmailsByIdsSuccess(this.presentationEmails); + + final List presentationEmails; + + @override + List get props => [presentationEmails]; +} + +class GetEmailsByIdsFailure extends FeatureFailure { + GetEmailsByIdsFailure({super.exception, super.onRetry}); +} \ No newline at end of file diff --git a/lib/features/thread_detail/domain/state/get_thread_by_id_state.dart b/lib/features/thread_detail/domain/state/get_thread_by_id_state.dart index 5b8828c20..8ea94e3df 100644 --- a/lib/features/thread_detail/domain/state/get_thread_by_id_state.dart +++ b/lib/features/thread_detail/domain/state/get_thread_by_id_state.dart @@ -14,5 +14,5 @@ class GetThreadByIdSuccess extends UIState { } class GetThreadByIdFailure extends FeatureFailure { - GetThreadByIdFailure({super.exception}); + GetThreadByIdFailure({super.exception, super.onRetry}); } \ No newline at end of file diff --git a/lib/features/thread_detail/domain/usecases/get_emails_by_ids_interactor.dart b/lib/features/thread_detail/domain/usecases/get_emails_by_ids_interactor.dart new file mode 100644 index 000000000..2f905de44 --- /dev/null +++ b/lib/features/thread_detail/domain/usecases/get_emails_by_ids_interactor.dart @@ -0,0 +1,52 @@ +import 'package:core/presentation/state/failure.dart'; +import 'package:core/presentation/state/success.dart'; +import 'package:core/utils/app_logger.dart'; +import 'package:dartz/dartz.dart'; +import 'package:jmap_dart_client/jmap/account_id.dart'; +import 'package:jmap_dart_client/jmap/core/properties/properties.dart'; +import 'package:jmap_dart_client/jmap/core/session/session.dart'; +import 'package:jmap_dart_client/jmap/mail/email/email.dart'; +import 'package:model/email/email_property.dart'; +import 'package:model/extensions/email_extension.dart'; +import 'package:tmail_ui_user/features/thread_detail/domain/exceptions/thread_detail_overload_exception.dart'; +import 'package:tmail_ui_user/features/thread_detail/domain/repository/thread_detail_repository.dart'; +import 'package:tmail_ui_user/features/thread_detail/domain/state/get_emails_by_ids_state.dart'; + +class GetEmailsByIdsInteractor { + const GetEmailsByIdsInteractor(this._threadDetailRepository); + + final ThreadDetailRepository _threadDetailRepository; + + Stream> execute( + Session session, + AccountId accountId, + List emailIds, { + Properties? properties, + }) async* { + try { + if (emailIds.length > 1 && ( + properties?.value.contains(EmailProperty.htmlBody) == true || + properties?.value.contains(EmailProperty.bodyValues) == true || + properties?.value.contains(EmailProperty.attachments) == true)) { + throw ThreadDetailOverloadException(); + } + + yield Right(GettingEmailsByIds()); + final result = await _threadDetailRepository.getEmailsByIds( + session, + accountId, + emailIds, + properties: properties, + ); + yield Right(GetEmailsByIdsSuccess( + result.map((e) => e.toPresentationEmail()).toList(), + )); + } catch (e) { + logError('GetEmailsByIdsInteractor::execute(): Exception: $e'); + yield Left(GetEmailsByIdsFailure( + exception: e, + onRetry: execute(session, accountId, emailIds, properties: properties), + )); + } + } +} \ No newline at end of file diff --git a/lib/features/thread_detail/domain/usecases/get_thread_by_id_interactor.dart b/lib/features/thread_detail/domain/usecases/get_thread_by_id_interactor.dart index 51f6f43c8..480a748f5 100644 --- a/lib/features/thread_detail/domain/usecases/get_thread_by_id_interactor.dart +++ b/lib/features/thread_detail/domain/usecases/get_thread_by_id_interactor.dart @@ -3,8 +3,9 @@ import 'package:core/presentation/state/success.dart'; import 'package:core/utils/app_logger.dart'; import 'package:dartz/dartz.dart'; import 'package:jmap_dart_client/jmap/account_id.dart'; +import 'package:jmap_dart_client/jmap/core/session/session.dart'; import 'package:jmap_dart_client/jmap/mail/email/email.dart'; -import 'package:tmail_ui_user/features/thread_detail/domain/exceptions/empty_thread_detail_exception.dart'; +import 'package:jmap_dart_client/jmap/mail/mailbox/mailbox.dart'; import 'package:tmail_ui_user/features/thread_detail/domain/repository/thread_detail_repository.dart'; import 'package:tmail_ui_user/features/thread_detail/domain/state/get_thread_by_id_state.dart'; @@ -15,23 +16,34 @@ class GetThreadByIdInteractor { Stream> execute( ThreadId threadId, + Session session, AccountId accountId, + MailboxId sentMailboxId, + String ownEmailAddress, ) async* { try { yield Right(GettingThreadById()); final result = await _threadDetailRepository.getThreadById( threadId, + session, accountId, + sentMailboxId, + ownEmailAddress, ); - if (result.isEmpty) { - throw EmptyThreadDetailException(); - } - yield Right(GetThreadByIdSuccess(result)); } catch (e) { logError('GetEmailIdsByThreadIdInteractor::execute(): Exception: $e'); - yield Left(GetThreadByIdFailure(exception: e)); + yield Left(GetThreadByIdFailure( + exception: e, + onRetry: execute( + threadId, + session, + accountId, + sentMailboxId, + ownEmailAddress, + ), + )); } } } \ No newline at end of file diff --git a/lib/features/thread_detail/presentation/extension/close_thread_detail_action.dart b/lib/features/thread_detail/presentation/extension/close_thread_detail_action.dart new file mode 100644 index 000000000..139d80cfd --- /dev/null +++ b/lib/features/thread_detail/presentation/extension/close_thread_detail_action.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; +import 'package:tmail_ui_user/features/email/presentation/action/email_ui_action.dart'; +import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/model/dashboard_routes.dart'; +import 'package:tmail_ui_user/features/thread_detail/presentation/thread_detail_controller.dart'; + +extension CloseThreadDetailAction on ThreadDetailController { + void closeThreadDetailAction(BuildContext? context) { + if (isSearchRunning) { + mailboxDashBoardController.dispatchRoute(DashboardRoutes.searchEmail); + } else { + mailboxDashBoardController.dispatchRoute(DashboardRoutes.thread); + } + for (var emailId in emailIdsPresentation.keys) { + mailboxDashBoardController.dispatchEmailUIAction( + CloseEmailInThreadDetailAction(emailId), + ); + } + + reset(); + } +} \ No newline at end of file diff --git a/lib/features/thread_detail/presentation/extension/focus_thread_detail_expanded_email.dart b/lib/features/thread_detail/presentation/extension/focus_thread_detail_expanded_email.dart new file mode 100644 index 000000000..e8ba38f5f --- /dev/null +++ b/lib/features/thread_detail/presentation/extension/focus_thread_detail_expanded_email.dart @@ -0,0 +1,14 @@ +import 'package:flutter/material.dart'; +import 'package:jmap_dart_client/jmap/mail/email/email.dart'; +import 'package:tmail_ui_user/features/thread_detail/presentation/thread_detail_controller.dart'; + +extension FocusThreadDetailExpandedEmail on ThreadDetailController { + void focusExpandedEmail(EmailId emailId) { + Future.delayed(const Duration(milliseconds: 200), () { + final context = GlobalObjectKey(emailId.id.value).currentContext; + if (context != null && context.mounted) { + Scrollable.ensureVisible(context); + } + }); + } +} \ No newline at end of file diff --git a/lib/features/thread_detail/presentation/extension/get_thread_detail_loading_view.dart b/lib/features/thread_detail/presentation/extension/get_thread_detail_loading_view.dart new file mode 100644 index 000000000..bfa01bd23 --- /dev/null +++ b/lib/features/thread_detail/presentation/extension/get_thread_detail_loading_view.dart @@ -0,0 +1,35 @@ +import 'package:core/presentation/views/loading/cupertino_loading_widget.dart'; +import 'package:flutter/material.dart'; +import 'package:tmail_ui_user/features/thread_detail/domain/state/get_thread_by_id_state.dart'; +import 'package:tmail_ui_user/features/thread_detail/presentation/thread_detail_controller.dart'; + +extension GetThreadDetailLoadingView on ThreadDetailController { + Widget getThreadDetailLoadingView({ + required bool isResponsiveDesktop, + }) { + final isLoading = viewState.value.fold( + (failure) => false, + (success) => success is GettingThreadById + ); + if (!isLoading) return const SizedBox.shrink(); + + return Expanded( + child: Container( + alignment: Alignment.center, + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.all(Radius.circular(20)), + ), + margin: isResponsiveDesktop + ? const EdgeInsetsDirectional.only(end: 16) + : null, + padding: const EdgeInsets.only(top: 16), + child: const SizedBox( + width: 24, + height: 24, + child: CupertinoLoadingWidget(), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/thread_detail/presentation/extension/get_thread_details_email_views.dart b/lib/features/thread_detail/presentation/extension/get_thread_details_email_views.dart new file mode 100644 index 000000000..4da6a542e --- /dev/null +++ b/lib/features/thread_detail/presentation/extension/get_thread_details_email_views.dart @@ -0,0 +1,89 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:tmail_ui_user/features/email/presentation/email_view.dart'; +import 'package:model/email/email_in_thread_status.dart'; +import 'package:tmail_ui_user/features/thread_detail/presentation/extension/load_more_thread_detail_emails.dart'; +import 'package:tmail_ui_user/features/thread_detail/presentation/extension/toggle_thread_detail_collape_expand.dart'; +import 'package:tmail_ui_user/features/thread_detail/presentation/thread_detail_controller.dart'; +import 'package:tmail_ui_user/features/thread_detail/presentation/widgets/thread_detail_collapsed_email.dart'; +import 'package:tmail_ui_user/features/thread_detail/presentation/widgets/thread_detail_load_more_circle.dart'; + +extension GetThreadDetailEmailViews on ThreadDetailController { + List getThreadDetailEmailViews() { + int? firstEmailNotLoadedIndex; + if (emailsToLoadMoreCount > 0) { + final firstNotLoadedEmailId = emailIdsPresentation.entries.firstWhereOrNull( + (entry) => entry.value == null + )?.key; + if (firstNotLoadedEmailId == null) { + firstEmailNotLoadedIndex = -1; + } else { + firstEmailNotLoadedIndex = emailIdsPresentation + .keys + .toList() + .indexOf(firstNotLoadedEmailId); + } + } + + return emailIdsPresentation.entries.map((entry) { + final emailId = entry.key; + final presentationEmail = entry.value; + final indexOfEmailId = emailIdsPresentation.keys.toList().indexOf(emailId); + if (presentationEmail == null) { + if (indexOfEmailId != firstEmailNotLoadedIndex) { + return const SizedBox.shrink(); + } + + return ThreadDetailLoadMoreCircle( + count: emailsToLoadMoreCount, + onTap: loadMoreThreadDetailEmails, + imagePaths: imagePaths, + isLoading: loadingThreadDetail, + ); + } + + if (presentationEmail.emailInThreadStatus == null) { + return const SizedBox.shrink(); + } + + final isFirstEmailInThreadDetail = indexOfEmailId == 0; + + if (presentationEmail.emailInThreadStatus == EmailInThreadStatus.collapsed) { + return ThreadDetailCollapsedEmail( + presentationEmail: presentationEmail.copyWith( + subject: isFirstEmailInThreadDetail + ? emailIdsPresentation.values.last?.subject + : null + ), + showSubject: isFirstEmailInThreadDetail, + imagePaths: imagePaths, + responsiveUtils: responsiveUtils, + mailboxContain: presentationEmail.mailboxContain, + emailLoaded: null, + openEmailAddressDetailAction: (context, emailAddress) { + // TODO: Next PR + }, + onToggleThreadDetailCollapseExpand: () { + toggleThreadDetailCollapeExpand(presentationEmail); + }, + ); + } + + return Padding( + padding: const EdgeInsetsDirectional.only(bottom: 16), + child: EmailView( + key: GlobalObjectKey(presentationEmail.id?.id.value ?? ''), + isInsideThreadDetailView: true, + emailId: presentationEmail.id, + isFirstEmailInThreadDetail: isFirstEmailInThreadDetail, + threadSubject: isFirstEmailInThreadDetail + ? emailIdsPresentation.values.last?.subject + : null, + onToggleThreadDetailCollapseExpand: () { + toggleThreadDetailCollapeExpand(presentationEmail); + }, + ), + ); + }).toList(); + } +} \ No newline at end of file diff --git a/lib/features/thread_detail/presentation/extension/handle_get_email_ids_by_thread_id_success.dart b/lib/features/thread_detail/presentation/extension/handle_get_email_ids_by_thread_id_success.dart index 21c4ebb5d..0a0c23dcb 100644 --- a/lib/features/thread_detail/presentation/extension/handle_get_email_ids_by_thread_id_success.dart +++ b/lib/features/thread_detail/presentation/extension/handle_get_email_ids_by_thread_id_success.dart @@ -5,6 +5,14 @@ extension HandleGetEmailIdsByThreadIdSuccess on ThreadDetailController { void handleGetEmailIdsByThreadIdSuccess( GetThreadByIdSuccess success, ) { - emailIds.value = success.emailIds; + if (success.emailIds.isNotEmpty) { + emailIdsPresentation.value = Map.fromEntries(success.emailIds.map( + (emailId) => MapEntry(emailId, null), + )); + } else if (mailboxDashBoardController.selectedEmail.value?.id != null) { + emailIdsPresentation.value = { + mailboxDashBoardController.selectedEmail.value!.id!: null, + }; + } } } \ No newline at end of file diff --git a/lib/features/thread_detail/presentation/extension/handle_get_emails_by_ids_success.dart b/lib/features/thread_detail/presentation/extension/handle_get_emails_by_ids_success.dart new file mode 100644 index 000000000..ab8f42625 --- /dev/null +++ b/lib/features/thread_detail/presentation/extension/handle_get_emails_by_ids_success.dart @@ -0,0 +1,63 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:tmail_ui_user/features/email/presentation/bindings/email_bindings.dart'; +import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/model/dashboard_routes.dart'; +import 'package:model/email/email_in_thread_status.dart'; +import 'package:tmail_ui_user/features/thread_detail/domain/state/get_emails_by_ids_state.dart'; +import 'package:tmail_ui_user/features/thread_detail/presentation/thread_detail_controller.dart'; + +extension HandleGetEmailsByIdsSuccess on ThreadDetailController { + void handleGetEmailsByIdsSuccess(GetEmailsByIdsSuccess success) { + final currentRoute = mailboxDashBoardController.dashboardRoute.value; + if (currentRoute != DashboardRoutes.threadDetailed) { + return; + } + + final isLoadMore = emailIdsPresentation.values.whereNotNull().isNotEmpty; + final currentScrollPosition = scrollController?.position.pixels; + final maxScrollExtent = scrollController?.position.maxScrollExtent; + final currentBottomScrollPosition = currentScrollPosition != null + && maxScrollExtent != null + ? maxScrollExtent - currentScrollPosition + : null; + + for (var presentationEmail in success.presentationEmails) { + if (presentationEmail.id == null) continue; + if (presentationEmail.id == emailIdsPresentation.keys.last) { + EmailBindings(currentEmailId: presentationEmail.id).dependencies(); + currentExpandedEmailId.value = presentationEmail.id; + } + emailIdsPresentation[presentationEmail.id!] = presentationEmail.copyWith( + emailInThreadStatus: presentationEmail.id == emailIdsPresentation.keys.last + ? EmailInThreadStatus.expanded + : EmailInThreadStatus.collapsed, + ); + } + + if (!isLoadMore) return; + + final currentExpandedEmailIndex = currentExpandedEmailId.value == null + ? -1 + : emailIdsPresentation.keys.toList().indexOf(currentExpandedEmailId.value!); + final firstLoadedMoreEmailId = success.presentationEmails.firstOrNull?.id; + final firstLoadedMoreEmailIndex = firstLoadedMoreEmailId == null + ? -1 + : emailIdsPresentation.keys.toList().indexOf(firstLoadedMoreEmailId); + if (currentExpandedEmailIndex == -1 || firstLoadedMoreEmailIndex == -1) { + return; + } + + if (currentBottomScrollPosition != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + final newMaxScrollExtent = scrollController?.position.maxScrollExtent; + if (newMaxScrollExtent == null) return; + + if (currentExpandedEmailIndex < firstLoadedMoreEmailIndex) { + return; + } else if (newMaxScrollExtent != maxScrollExtent!) { + scrollController?.jumpTo(newMaxScrollExtent - currentBottomScrollPosition); + } + }); + } + } +} \ No newline at end of file diff --git a/lib/features/thread_detail/presentation/extension/initialize_thread_detail_emails.dart b/lib/features/thread_detail/presentation/extension/initialize_thread_detail_emails.dart new file mode 100644 index 000000000..6e3a56a36 --- /dev/null +++ b/lib/features/thread_detail/presentation/extension/initialize_thread_detail_emails.dart @@ -0,0 +1,31 @@ +import 'package:dartz/dartz.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/thread_detail/domain/state/get_emails_by_ids_state.dart'; +import 'package:tmail_ui_user/features/thread_detail/presentation/thread_detail_controller.dart'; +import 'package:tmail_ui_user/features/thread_detail/presentation/utils/thread_detail_presentation_utils.dart'; + +extension InitializeThreadDetailEmails on ThreadDetailController { + void initializeThreadDetailEmails() { + final emailIdToLoadContent = emailIdsPresentation.keys.last; + final emailIdsToLoadMetaData = ThreadDetailPresentationUtils.getEmailIdsToLoad( + Map.from(emailIdsPresentation)..remove(emailIdToLoadContent), + ); + + if (accountId == null || session == null) { + consumeState(Stream.value(Left(GetEmailsByIdsFailure( + exception: NotFoundSessionException(), + )))); + return; + } + consumeState(getEmailsByIdsInteractor.execute( + session!, + accountId!, + [...emailIdsToLoadMetaData, emailIdToLoadContent], + properties: EmailUtils.getPropertiesForEmailGetMethod( + session!, + accountId!, + ), + )); + } +} \ No newline at end of file diff --git a/lib/features/thread_detail/presentation/extension/load_more_thread_detail_emails.dart b/lib/features/thread_detail/presentation/extension/load_more_thread_detail_emails.dart new file mode 100644 index 000000000..e79489d74 --- /dev/null +++ b/lib/features/thread_detail/presentation/extension/load_more_thread_detail_emails.dart @@ -0,0 +1,32 @@ +import 'package:dartz/dartz.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/thread_detail/domain/state/get_emails_by_ids_state.dart'; +import 'package:tmail_ui_user/features/thread_detail/presentation/thread_detail_controller.dart'; +import 'package:tmail_ui_user/features/thread_detail/presentation/utils/thread_detail_presentation_utils.dart'; + +extension LoadMoreThreadDetailEmails on ThreadDetailController { + void loadMoreThreadDetailEmails() { + if (accountId == null || session == null) { + consumeState(Stream.value(Left(GetEmailsByIdsFailure( + exception: NotFoundSessionException(), + )))); + return; + } + + final emailIdsToLoadMetaData = ThreadDetailPresentationUtils + .getEmailIdsToLoad(emailIdsPresentation); + if (emailIdsToLoadMetaData.isEmpty) { + return; + } + consumeState(getEmailsByIdsInteractor.execute( + session!, + accountId!, + emailIdsToLoadMetaData, + properties: EmailUtils.getPropertiesForEmailGetMethod( + session!, + accountId!, + ), + )); + } +} \ No newline at end of file diff --git a/lib/features/thread_detail/presentation/extension/toggle_thread_detail_collape_expand.dart b/lib/features/thread_detail/presentation/extension/toggle_thread_detail_collape_expand.dart new file mode 100644 index 000000000..cc0931aa8 --- /dev/null +++ b/lib/features/thread_detail/presentation/extension/toggle_thread_detail_collape_expand.dart @@ -0,0 +1,49 @@ +import 'package:get/get.dart'; +import 'package:model/email/presentation_email.dart'; +import 'package:tmail_ui_user/features/email/presentation/bindings/email_bindings.dart'; +import 'package:tmail_ui_user/features/email/presentation/controller/single_email_controller.dart'; +import 'package:model/email/email_in_thread_status.dart'; +import 'package:tmail_ui_user/features/thread_detail/presentation/extension/focus_thread_detail_expanded_email.dart'; +import 'package:tmail_ui_user/features/thread_detail/presentation/thread_detail_controller.dart'; + +extension ToggleThreadDetailCollapeExpand on ThreadDetailController { + void toggleThreadDetailCollapeExpand(PresentationEmail presentationEmail) { + final emailId = presentationEmail.id; + final expansionStatus = presentationEmail.emailInThreadStatus; + if (emailId == null || expansionStatus == null) return; + + if (expansionStatus == EmailInThreadStatus.expanded) { + emailIdsPresentation[emailId] = presentationEmail.copyWith( + emailInThreadStatus: EmailInThreadStatus.collapsed, + ); + currentExpandedEmailId.value = null; + return; + } + + final isInitialized = Get.isRegistered( + tag: emailId.id.value, + ); + if (!isInitialized) { + EmailBindings(currentEmailId: presentationEmail.id).dependencies(); + } else { + focusExpandedEmail(emailId); + } + for (var key in emailIdsPresentation.keys) { + if (emailIdsPresentation[key] == null) continue; + + if (key == emailId) { + emailIdsPresentation[key] = presentationEmail.copyWith( + emailInThreadStatus: EmailInThreadStatus.expanded, + ); + currentExpandedEmailId.value = key; + continue; + } + + if (emailIdsPresentation[key]?.emailInThreadStatus == EmailInThreadStatus.expanded) { + emailIdsPresentation[key] = emailIdsPresentation[key]?.copyWith( + emailInThreadStatus: EmailInThreadStatus.collapsed, + ); + } + } + } +} \ No newline at end of file diff --git a/lib/features/thread_detail/presentation/thread_detail_bindings.dart b/lib/features/thread_detail/presentation/thread_detail_bindings.dart new file mode 100644 index 000000000..51d79483a --- /dev/null +++ b/lib/features/thread_detail/presentation/thread_detail_bindings.dart @@ -0,0 +1,61 @@ +import 'package:core/data/model/source_type/data_source_type.dart'; +import 'package:get/get.dart'; +import 'package:jmap_dart_client/http/http_client.dart'; +import 'package:tmail_ui_user/features/base/base_bindings.dart'; +import 'package:tmail_ui_user/features/thread_detail/data/data_source/thread_detail_data_source.dart'; +import 'package:tmail_ui_user/features/thread_detail/data/data_source/thread_detail_remote_data_source_impl.dart'; +import 'package:tmail_ui_user/features/thread_detail/data/network/thread_detail_api.dart'; +import 'package:tmail_ui_user/features/thread_detail/data/repository/thread_detail_repository_impl.dart'; +import 'package:tmail_ui_user/features/thread_detail/domain/repository/thread_detail_repository.dart'; +import 'package:tmail_ui_user/features/thread_detail/domain/usecases/get_emails_by_ids_interactor.dart'; +import 'package:tmail_ui_user/features/thread_detail/domain/usecases/get_thread_by_id_interactor.dart'; +import 'package:tmail_ui_user/features/thread_detail/presentation/thread_detail_controller.dart'; +import 'package:tmail_ui_user/main/exceptions/remote_exception_thrower.dart'; + +class ThreadDetailBindings extends BaseBindings { + @override + void dependencies() { + Get.lazyPut(() => ThreadDetailApi(Get.find())); + + super.dependencies(); + } + + @override + void bindingsController() { + Get.put(ThreadDetailController( + Get.find(), + Get.find(), + )); + } + + @override + void bindingsDataSourceImpl() { + Get.lazyPut(() => ThreadDetailRemoteDataSourceImpl( + Get.find(), + Get.find(), + )); + } + + @override + void bindingsDataSource() { + Get.lazyPut(() => Get.find()); + } + + @override + void bindingsRepositoryImpl() { + Get.lazyPut(() => ThreadDetailRepositoryImpl({ + DataSourceType.network: Get.find(), + })); + } + + @override + void bindingsRepository() { + Get.lazyPut(() => Get.find()); + } + + @override + void bindingsInteractor() { + Get.lazyPut(() => GetThreadByIdInteractor(Get.find())); + Get.lazyPut(() => GetEmailsByIdsInteractor(Get.find())); + } +} diff --git a/lib/features/thread_detail/presentation/thread_detail_controller.dart b/lib/features/thread_detail/presentation/thread_detail_controller.dart index f6546d6a7..ae79ded2e 100644 --- a/lib/features/thread_detail/presentation/thread_detail_controller.dart +++ b/lib/features/thread_detail/presentation/thread_detail_controller.dart @@ -1,43 +1,108 @@ +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/session/session.dart'; import 'package:jmap_dart_client/jmap/mail/email/email.dart'; +import 'package:jmap_dart_client/jmap/mail/mailbox/mailbox.dart'; +import 'package:model/email/presentation_email.dart'; +import 'package:model/extensions/session_extension.dart'; +import 'package:model/mailbox/presentation_mailbox.dart'; import 'package:tmail_ui_user/features/base/base_controller.dart'; +import 'package:tmail_ui_user/features/email/presentation/model/email_loaded.dart'; import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller.dart'; +import 'package:tmail_ui_user/features/search/email/presentation/search_email_controller.dart'; import 'package:tmail_ui_user/features/thread_detail/domain/state/get_thread_by_id_state.dart'; +import 'package:tmail_ui_user/features/thread_detail/domain/state/get_emails_by_ids_state.dart'; import 'package:tmail_ui_user/features/thread_detail/domain/usecases/get_thread_by_id_interactor.dart'; +import 'package:tmail_ui_user/features/thread_detail/domain/usecases/get_emails_by_ids_interactor.dart'; +import 'package:tmail_ui_user/features/thread_detail/presentation/extension/close_thread_detail_action.dart'; import 'package:tmail_ui_user/features/thread_detail/presentation/extension/handle_get_email_ids_by_thread_id_success.dart'; -import 'package:tmail_ui_user/features/thread_detail/presentation/model/thread_detail_arguments.dart'; +import 'package:tmail_ui_user/features/thread_detail/presentation/extension/handle_get_emails_by_ids_success.dart'; +import 'package:tmail_ui_user/features/thread_detail/presentation/extension/initialize_thread_detail_emails.dart'; +import 'package:tmail_ui_user/main/routes/route_navigation.dart'; class ThreadDetailController extends BaseController { - final ThreadDetailArguments arguments; final GetThreadByIdInteractor _getEmailIdsByThreadIdInteractor; + final GetEmailsByIdsInteractor getEmailsByIdsInteractor; ThreadDetailController( - this.arguments, this._getEmailIdsByThreadIdInteractor, + this.getEmailsByIdsInteractor, ); - final emailIds = [].obs; + final emailIdsPresentation = {}.obs; + final currentExpandedEmailId = Rxn(); + final currentEmailLoaded = Rxn(); final mailboxDashBoardController = Get.find(); + final searchEmailController = Get.find(); + + ScrollController? scrollController; AccountId? get accountId => mailboxDashBoardController.accountId.value; + Session? get session => mailboxDashBoardController.sessionCurrent; + MailboxId? get sentMailboxId => mailboxDashBoardController.getMailboxIdByRole( + PresentationMailbox.roleSent, + ); + String? get ownEmailAddress => session?.getOwnEmailAddress(); + int get emailsToLoadMoreCount => emailIdsPresentation + .values + .where((email) => email == null) + .length; + bool get loadingThreadDetail => viewState.value.fold( + (failure) => false, + (success) => success is GettingThreadById + || success is GettingEmailsByIds, + ); + bool get isSearchRunning { + final isWebSearchRunning = mailboxDashBoardController + .searchController + .isSearchEmailRunning; + final isMobileSearchRunning = searchEmailController + .searchIsRunning + .value == true; + return isWebSearchRunning || isMobileSearchRunning; + } @override void onInit() { super.onInit(); - if (accountId != null) { - consumeState(_getEmailIdsByThreadIdInteractor.execute( - arguments.threadId, - accountId!, - )); - } + ever(mailboxDashBoardController.selectedEmail, (presentationEmail) { + if (presentationEmail?.threadId == null) { + closeThreadDetailAction(currentContext); + return; + } + if (session != null && + accountId != null && + sentMailboxId != null && + ownEmailAddress != null) { + scrollController = ScrollController(); + consumeState(_getEmailIdsByThreadIdInteractor.execute( + presentationEmail!.threadId!, + session!, + accountId!, + sentMailboxId!, + ownEmailAddress!, + )); + } + }); + } + + void reset() { + emailIdsPresentation.clear(); + scrollController?.dispose(); + scrollController = null; + currentExpandedEmailId.value = null; + currentEmailLoaded.value = null; } @override void handleSuccessViewState(success) { if (success is GetThreadByIdSuccess) { handleGetEmailIdsByThreadIdSuccess(success); + initializeThreadDetailEmails(); + } else if (success is GetEmailsByIdsSuccess) { + handleGetEmailsByIdsSuccess(success); } else { super.handleSuccessViewState(success); } @@ -46,7 +111,11 @@ class ThreadDetailController extends BaseController { @override void handleFailureViewState(failure) { if (failure is GetThreadByIdFailure) { - // TODO: handle failure + showRetryToast(failure); + return; + } + if (failure is GetEmailsByIdsFailure) { + showRetryToast(failure); return; } super.handleFailureViewState(failure); diff --git a/lib/features/thread_detail/presentation/thread_detail_view.dart b/lib/features/thread_detail/presentation/thread_detail_view.dart new file mode 100644 index 000000000..81428b566 --- /dev/null +++ b/lib/features/thread_detail/presentation/thread_detail_view.dart @@ -0,0 +1,199 @@ +import 'package:collection/collection.dart'; +import 'package:core/presentation/extensions/color_extension.dart'; +import 'package:dartz/dartz.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:model/extensions/session_extension.dart'; +import 'package:model/mailbox/presentation_mailbox.dart'; +import 'package:tmail_ui_user/features/email/presentation/action/email_ui_action.dart'; +import 'package:tmail_ui_user/features/email/presentation/controller/single_email_controller.dart'; +import 'package:tmail_ui_user/features/email/presentation/email_view.dart'; +import 'package:tmail_ui_user/features/email/presentation/widgets/email_view_bottom_bar_widget.dart'; +import 'package:tmail_ui_user/features/thread_detail/domain/state/get_thread_by_id_state.dart'; +import 'package:tmail_ui_user/features/thread_detail/presentation/extension/close_thread_detail_action.dart'; +import 'package:tmail_ui_user/features/thread_detail/presentation/extension/get_thread_detail_loading_view.dart'; +import 'package:tmail_ui_user/features/thread_detail/presentation/extension/get_thread_details_email_views.dart'; +import 'package:tmail_ui_user/features/thread_detail/presentation/thread_detail_controller.dart'; +import 'package:tmail_ui_user/features/thread_detail/presentation/widgets/thread_detail_app_bar.dart'; +import 'package:tmail_ui_user/main/routes/route_navigation.dart'; + +class ThreadDetailView extends GetWidget { + const ThreadDetailView({super.key}); + + @override + Widget build(BuildContext context) { + final multipleEmailsView = SelectionArea(child: SafeArea( + child: Column( + children: [ + Obx(() { + final currentViewState = controller.viewState.value.fold(id, id); + if (currentViewState is GettingThreadById) { + return const SizedBox.shrink(); + } + + return ThreadDetailAppBar( + responsiveUtils: controller.responsiveUtils, + imagePaths: controller.imagePaths, + isSearchRunning: controller.isSearchRunning, + closeThreadDetailAction: controller.closeThreadDetailAction, + lastEmailOfThread: controller.emailIdsPresentation.values.lastOrNull, + ownUserName: controller.session?.getOwnEmailAddress() ?? '', + mailboxContain: _getMailboxContain(), + emailLoaded: getBinding( + tag: controller.emailIdsPresentation.keys.lastOrNull?.id.value + )?.currentEmailLoaded.value, + onEmailActionClick: (email, action) { + // TODO: Next PR + }, + onMoreActionClick: (p0, p1) { + // TODO: Next PR + }, + ); + }), + Obx(() => controller.getThreadDetailLoadingView( + isResponsiveDesktop: controller.responsiveUtils.isDesktop(context), + )), + Obx(() { + return controller.viewState.value.fold( + (failure) => const SizedBox.shrink(), + (success) { + if (success is GettingThreadById) { + return const SizedBox.shrink(); + } + + return Expanded( + child: Padding( + padding: _padding(context), + child: Stack( + children: [ + const Positioned.fill( + child: ColoredBox(color: Colors.white), + ), + SingleChildScrollView( + controller: controller.scrollController, + child: Column( + mainAxisSize: MainAxisSize.min, + children: controller.getThreadDetailEmailViews() + ), + ), + ], + ), + ), + ); + }, + ); + }), + Obx(() { + final currentViewState = controller.viewState.value.fold(id, id); + if (currentViewState is GettingThreadById) { + return const SizedBox.shrink(); + } + + final expandedEmailId = controller.currentExpandedEmailId.value; + if (expandedEmailId == null) { + return _roundBottomPlaceHolder( + isDesktop: controller.responsiveUtils.isDesktop(context), + ); + } + final expandedPresentationEmail = controller.emailIdsPresentation[expandedEmailId]; + if (expandedPresentationEmail == null) { + return _roundBottomPlaceHolder( + isDesktop: controller.responsiveUtils.isDesktop(context), + ); + } + + final currentEmailLoaded = controller.currentEmailLoaded.value; + if (currentEmailLoaded == null) { + return _roundBottomPlaceHolder( + isDesktop: controller.responsiveUtils.isDesktop(context), + ); + } + + return Padding( + padding: controller.responsiveUtils.isDesktop(context) + ? const EdgeInsetsDirectional.only(end: 16) + : EdgeInsets.zero, + child: ClipRRect( + borderRadius: const BorderRadius.vertical( + bottom: Radius.circular(20), + ), + child: EmailViewBottomBarWidget( + key: const Key('email_view_button_bar'), + imagePaths: controller.imagePaths, + responsiveUtils: controller.responsiveUtils, + emailLoaded: currentEmailLoaded, + presentationEmail: expandedPresentationEmail, + userName: controller.session?.getOwnEmailAddress() ?? '', + emailActionCallback: (action, email) { + controller.mailboxDashBoardController + ..dispatchEmailUIAction(PerformEmailActionInThreadDetailAction( + emailActionType: action, + presentationEmail: email, + )) + ..dispatchEmailUIAction(EmailUIAction()); + }, + bottomBarDecoration: const BoxDecoration( + color: Colors.white, + border: Border( + top: BorderSide(color: AppColor.colorDividerEmailView), + ), + ), + padding: EdgeInsets.zero, + ), + ), + ); + }), + if (controller.responsiveUtils.isDesktop(context)) + const SizedBox(height: 16), + ], + ), + )); + + return Obx(() { + final currentViewState = controller.viewState.value.fold(id, id); + if (currentViewState is GettingThreadById && + controller.responsiveUtils.isTabletLarge(context)) { + return controller.getThreadDetailLoadingView(isResponsiveDesktop: false); + } + + if (controller.emailIdsPresentation.length == 1 && + controller.emailIdsPresentation.values.firstOrNull != null) { + return EmailView( + emailId: controller.emailIdsPresentation.values.firstOrNull?.id, + ); + } + + return multipleEmailsView; + }); + } + + EdgeInsetsGeometry _padding(BuildContext context) { + if (controller.responsiveUtils.isDesktop(context)) { + return const EdgeInsetsDirectional.only(end: 16); + } + return EdgeInsets.zero; + } + + PresentationMailbox? _getMailboxContain() { + if (controller.isSearchRunning) { + return null; + } + + return controller.mailboxDashBoardController.selectedMailbox.value; + } + + Widget _roundBottomPlaceHolder({required bool isDesktop}) { + return Container( + height: 40, + margin: isDesktop + ? const EdgeInsetsDirectional.only(end: 16) + : EdgeInsets.zero, + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical( + bottom: Radius.circular(20), + ), + ) + ); + } +} \ No newline at end of file diff --git a/lib/features/thread_detail/presentation/utils/thread_detail_presentation_utils.dart b/lib/features/thread_detail/presentation/utils/thread_detail_presentation_utils.dart new file mode 100644 index 000000000..32f0165f4 --- /dev/null +++ b/lib/features/thread_detail/presentation/utils/thread_detail_presentation_utils.dart @@ -0,0 +1,39 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:jmap_dart_client/jmap/mail/email/email.dart'; +import 'package:model/email/presentation_email.dart'; + +class ThreadDetailPresentationUtils { + const ThreadDetailPresentationUtils._(); + + static const _initialLoadSize = 2; + static const _defaultLoadSize = 20; + + @visibleForTesting + static get initialLoadSize => _initialLoadSize; + @visibleForTesting + static get defaultLoadSize => _defaultLoadSize; + + static List getEmailIdsToLoad( + Map emailIdsPresentation, + ) { + final validEmailIdsToLoad = emailIdsPresentation.entries + .where((entry) => entry.value == null) + .map((entry) => entry.key) + .toList(); + + if (validEmailIdsToLoad.length == emailIdsPresentation.length) { + // No email loaded yet + return validEmailIdsToLoad.sublist( + 0, + min(validEmailIdsToLoad.length, _initialLoadSize), + ); + } + + return validEmailIdsToLoad.sublist( + validEmailIdsToLoad.length - min(validEmailIdsToLoad.length, _defaultLoadSize), + validEmailIdsToLoad.length, + ); + } +} \ No newline at end of file diff --git a/lib/features/thread_detail/presentation/widgets/thread_detail_app_bar.dart b/lib/features/thread_detail/presentation/widgets/thread_detail_app_bar.dart new file mode 100644 index 000000000..7b7e35581 --- /dev/null +++ b/lib/features/thread_detail/presentation/widgets/thread_detail_app_bar.dart @@ -0,0 +1,198 @@ +import 'package:core/presentation/resources/image_paths.dart'; +import 'package:core/presentation/utils/responsive_utils.dart'; +import 'package:core/presentation/views/button/tmail_button_widget.dart'; +import 'package:core/utils/platform_info.dart'; +import 'package:flutter/material.dart'; +import 'package:model/email/email_action_type.dart'; +import 'package:model/email/presentation_email.dart'; +import 'package:model/extensions/email_extension.dart'; +import 'package:model/extensions/presentation_email_extension.dart'; +import 'package:model/mailbox/presentation_mailbox.dart'; +import 'package:tmail_ui_user/features/email/presentation/model/email_loaded.dart'; +import 'package:tmail_ui_user/features/email/presentation/styles/email_view_app_bar_widget_styles.dart'; +import 'package:tmail_ui_user/features/email/presentation/utils/email_utils.dart'; +import 'package:tmail_ui_user/features/email/presentation/widgets/email_view_app_bar_widget.dart'; +import 'package:tmail_ui_user/features/email/presentation/widgets/email_view_back_button.dart'; +import 'package:tmail_ui_user/main/localizations/app_localizations.dart'; + +class ThreadDetailAppBar extends StatelessWidget { + const ThreadDetailAppBar({ + super.key, + required this.responsiveUtils, + required this.imagePaths, + required this.isSearchRunning, + required this.closeThreadDetailAction, + required this.lastEmailOfThread, + required this.ownUserName, + this.mailboxContain, + this.optionWidgets = const [], + this.onEmailActionClick, + this.onMoreActionClick, + this.emailLoaded, + }); + + final ResponsiveUtils responsiveUtils; + final ImagePaths imagePaths; + final bool isSearchRunning; + final void Function(BuildContext context) closeThreadDetailAction; + final PresentationEmail? lastEmailOfThread; + final String ownUserName; + final PresentationMailbox? mailboxContain; + final List optionWidgets; + final OnEmailActionClick? onEmailActionClick; + final OnMoreActionClick? onMoreActionClick; + final EmailLoaded? emailLoaded; + + @override + Widget build(BuildContext context) { + final isReplyToListEnabled = EmailUtils.isReplyToListEnabled( + emailLoaded?.emailCurrent?.listPost ?? '', + ); + + final child = LayoutBuilder( + builder: (context, constraints) { + return Container( + height: PlatformInfo.isIOS + ? EmailViewAppBarWidgetStyles.heightIOS(context, responsiveUtils) + : EmailViewAppBarWidgetStyles.height, + padding: PlatformInfo.isIOS + ? EmailViewAppBarWidgetStyles.paddingIOS(context, responsiveUtils) + : EmailViewAppBarWidgetStyles.padding, + margin: !PlatformInfo.isMobile && responsiveUtils.isDesktop(context) + ? const EdgeInsetsDirectional.only(end: 16) + : EdgeInsets.zero, + decoration: const BoxDecoration( + border: Border( + bottom: BorderSide( + color: EmailViewAppBarWidgetStyles.bottomBorderColor, + width: EmailViewAppBarWidgetStyles.bottomBorderWidth, + ), + ), + borderRadius: BorderRadius.vertical( + top: Radius.circular(EmailViewAppBarWidgetStyles.radius), + ), + color: EmailViewAppBarWidgetStyles.backgroundColor, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (_supportDisplayMailboxNameTitle(context)) + Expanded( + child: Align( + alignment: Alignment.centerLeft, + child: EmailViewBackButton( + imagePaths: imagePaths, + onBackAction: () => closeThreadDetailAction(context), + mailboxContain: mailboxContain, + isSearchActivated: isSearchRunning, + maxWidth: constraints.maxWidth, + ), + ), + ), + ...optionWidgets, + if (lastEmailOfThread != null) ...[ + TMailButtonWidget.fromIcon( + icon: imagePaths.icReply, + iconSize: EmailViewAppBarWidgetStyles.buttonIconSize, + iconColor: EmailViewAppBarWidgetStyles.iconColor, + tooltipMessage: AppLocalizations.of(context).reply, + backgroundColor: Colors.transparent, + onTapActionCallback: () => onEmailActionClick?.call( + lastEmailOfThread!, + EmailActionType.reply, + ), + ), + if (lastEmailOfThread!.getCountMailAddressWithoutMe(ownUserName) > 1) + TMailButtonWidget.fromIcon( + icon: imagePaths.icReplyAll, + iconSize: EmailViewAppBarWidgetStyles.buttonIconSize, + iconColor: EmailViewAppBarWidgetStyles.iconColor, + tooltipMessage: AppLocalizations.of(context).reply_all, + backgroundColor: Colors.transparent, + onTapActionCallback: () => onEmailActionClick?.call( + lastEmailOfThread!, + EmailActionType.replyAll, + ), + ), + if (isReplyToListEnabled) + TMailButtonWidget.fromIcon( + icon: imagePaths.icReply, + iconSize: EmailViewAppBarWidgetStyles.buttonIconSize, + iconColor: EmailViewAppBarWidgetStyles.iconColor, + tooltipMessage: AppLocalizations.of(context).replyToList, + backgroundColor: Colors.transparent, + onTapActionCallback: () => onEmailActionClick?.call( + lastEmailOfThread!, + EmailActionType.replyToList, + ), + ), + TMailButtonWidget.fromIcon( + icon: imagePaths.icForward, + iconSize: EmailViewAppBarWidgetStyles.buttonIconSize, + iconColor: EmailViewAppBarWidgetStyles.iconColor, + tooltipMessage: AppLocalizations.of(context).forward, + backgroundColor: Colors.transparent, + onTapActionCallback: () => onEmailActionClick?.call( + lastEmailOfThread!, + EmailActionType.forward, + ), + ), + TMailButtonWidget.fromIcon( + icon: lastEmailOfThread!.hasStarred + ? imagePaths.icStar + : imagePaths.icUnStar, + iconSize: EmailViewAppBarWidgetStyles.buttonIconSize, + iconColor: lastEmailOfThread!.hasStarred + ? null + : EmailViewAppBarWidgetStyles.iconColor, + backgroundColor: Colors.transparent, + tooltipMessage: lastEmailOfThread!.hasStarred + ? AppLocalizations.of(context).not_starred + : AppLocalizations.of(context).mark_as_starred, + onTapActionCallback: () => onEmailActionClick?.call( + lastEmailOfThread!, + lastEmailOfThread!.hasStarred + ? EmailActionType.unMarkAsStarred + : EmailActionType.markAsStarred + ), + ), + TMailButtonWidget.fromIcon( + icon: imagePaths.icMoreVertical, + iconSize: EmailViewAppBarWidgetStyles.buttonIconSize, + iconColor: EmailViewAppBarWidgetStyles.iconColor, + backgroundColor: Colors.transparent, + tooltipMessage: AppLocalizations.of(context).more, + onTapActionCallback: responsiveUtils.isScreenWithShortestSide(context) + ? () => onMoreActionClick?.call(lastEmailOfThread!, null) + : null, + onTapActionAtPositionCallback: !responsiveUtils.isScreenWithShortestSide(context) + ? (position) => onMoreActionClick?.call(lastEmailOfThread!, position) + : null, + ), + ], + ], + ), + ); + }, + ); + + return PopScope( + canPop: false, + onPopInvokedWithResult: (_, __) { + closeThreadDetailAction(context); + }, + child: child, + ); + } + + bool _supportDisplayMailboxNameTitle(BuildContext context) { + final isSupportedDevice = PlatformInfo.isWeb + ? responsiveUtils.isDesktop(context) + || responsiveUtils.isMobile(context) + || responsiveUtils.isTablet(context) + : responsiveUtils.isPortraitMobile(context) + || responsiveUtils.isLandscapeMobile(context) + || responsiveUtils.isTablet(context); + return isSupportedDevice || isSearchRunning; + } +} diff --git a/lib/features/thread_detail/presentation/widgets/thread_detail_collapsed_email.dart b/lib/features/thread_detail/presentation/widgets/thread_detail_collapsed_email.dart new file mode 100644 index 000000000..62c12f4cc --- /dev/null +++ b/lib/features/thread_detail/presentation/widgets/thread_detail_collapsed_email.dart @@ -0,0 +1,90 @@ +import 'package:core/presentation/extensions/color_extension.dart'; +import 'package:core/presentation/resources/image_paths.dart'; +import 'package:core/presentation/utils/responsive_utils.dart'; +import 'package:core/presentation/views/text/text_overflow_builder.dart'; +import 'package:flutter/material.dart'; +import 'package:model/email/presentation_email.dart'; +import 'package:model/mailbox/presentation_mailbox.dart'; +import 'package:tmail_ui_user/features/email/presentation/model/email_loaded.dart'; +import 'package:tmail_ui_user/features/email/presentation/widgets/email_sender_builder.dart'; +import 'package:tmail_ui_user/features/email/presentation/widgets/email_subject_widget.dart'; +import 'package:tmail_ui_user/features/email/presentation/widgets/email_view_app_bar_widget.dart'; +import 'package:tmail_ui_user/features/email/presentation/widgets/information_sender_and_receiver_builder.dart'; + +class ThreadDetailCollapsedEmail extends StatelessWidget { + const ThreadDetailCollapsedEmail({ + super.key, + required this.presentationEmail, + required this.showSubject, + required this.imagePaths, + required this.responsiveUtils, + this.openEmailAddressDetailAction, + this.mailboxContain, + this.emailLoaded, + this.onEmailActionClick, + this.onMoreActionClick, + this.onToggleThreadDetailCollapseExpand, + }); + + final PresentationEmail presentationEmail; + final bool showSubject; + final ImagePaths imagePaths; + final ResponsiveUtils responsiveUtils; + final OnOpenEmailAddressDetailAction? openEmailAddressDetailAction; + final PresentationMailbox? mailboxContain; + final EmailLoaded? emailLoaded; + final OnEmailActionClick? onEmailActionClick; + final OnMoreActionClick? onMoreActionClick; + final VoidCallback? onToggleThreadDetailCollapseExpand; + + String get preview => presentationEmail.getPartialContent(); + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: const BoxDecoration( + color: Colors.white, + border: Border( + top: BorderSide( + color: AppColor.colorDividerEmailView, + width: 0.5, + ), + ), + ), + child: InkWell( + onTap: onToggleThreadDetailCollapseExpand, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (showSubject) + EmailSubjectWidget(presentationEmail: presentationEmail) + else + const SizedBox(height: 16), + InformationSenderAndReceiverBuilder( + emailSelected: presentationEmail, + responsiveUtils: responsiveUtils, + imagePaths: imagePaths, + emailLoaded: emailLoaded, + isInsideThreadDetailView: true, + onEmailActionClick: onEmailActionClick, + onMoreActionClick: onMoreActionClick, + openEmailAddressDetailAction: openEmailAddressDetailAction, + showRecipients: false, + onTapAvatarActionClick: onToggleThreadDetailCollapseExpand, + ), + Padding( + padding: const EdgeInsets.all(16), + child: TextOverflowBuilder( + preview, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/thread_detail/presentation/widgets/thread_detail_load_more_circle.dart b/lib/features/thread_detail/presentation/widgets/thread_detail_load_more_circle.dart new file mode 100644 index 000000000..78894f36c --- /dev/null +++ b/lib/features/thread_detail/presentation/widgets/thread_detail_load_more_circle.dart @@ -0,0 +1,99 @@ +import 'package:core/presentation/extensions/color_extension.dart'; +import 'package:core/presentation/resources/image_paths.dart'; +import 'package:core/presentation/views/loading/cupertino_loading_widget.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +class ThreadDetailLoadMoreCircle extends StatefulWidget { + const ThreadDetailLoadMoreCircle({ + super.key, + required this.count, + required this.onTap, + required this.imagePaths, + required this.isLoading, + }); + + final int count; + final VoidCallback onTap; + final ImagePaths imagePaths; + final bool isLoading; + + @override + State createState() => _ThreadDetailLoadMoreCircleState(); +} + +class _ThreadDetailLoadMoreCircleState extends State { + final _isHover = ValueNotifier(false); + + @override + void dispose() { + _isHover.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 16), + color: Colors.white, + child: Stack( + alignment: Alignment.centerLeft, + children: [ + Container( + height: 8, + decoration: const BoxDecoration( + border: Border.symmetric(horizontal: BorderSide( + color: AppColor.colorDividerEmailView, + width: 0.5, + )), + ), + ), + InkWell( + onTap: widget.onTap, + onHover: (value) => _isHover.value = value, + child: Container( + margin: const EdgeInsetsDirectional.only(start: 16), + width: 56, + height: 56, + decoration: BoxDecoration( + border: Border.all( + color: AppColor.colorDividerEmailView, + width: 0.5, + ), + shape: BoxShape.circle, + color: Colors.white, + ), + alignment: Alignment.center, + child: ValueListenableBuilder( + valueListenable: _isHover, + builder: (context, isHover, child) { + if (widget.isLoading) { + return const SizedBox( + width: 24, + height: 24, + child: CupertinoLoadingWidget(), + ); + } + + if (isHover) { + return child ?? const SizedBox.shrink(); + } + + return Text( + '${widget.count}', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.normal, + height: 24 / 16, + letterSpacing: -0.1, + ), + ); + }, + child: SvgPicture.asset(widget.imagePaths.icExpandArrows), + ), + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/model/lib/email/email_in_thread_status.dart b/model/lib/email/email_in_thread_status.dart new file mode 100644 index 000000000..be9908f1c --- /dev/null +++ b/model/lib/email/email_in_thread_status.dart @@ -0,0 +1 @@ +enum EmailInThreadStatus { collapsed, expanded } diff --git a/model/lib/email/email_property.dart b/model/lib/email/email_property.dart index c8d0e4e3a..63dd72040 100644 --- a/model/lib/email/email_property.dart +++ b/model/lib/email/email_property.dart @@ -15,6 +15,7 @@ class EmailProperty { static const String bcc = 'bcc'; static const String replyTo = 'replyTo'; static const String mailboxIds = 'mailboxIds'; + static const String threadId = 'threadId'; static const String bodyValues = 'bodyValues'; static const String htmlBody = 'htmlBody'; static const String attachments = 'attachments'; diff --git a/model/lib/email/presentation_email.dart b/model/lib/email/presentation_email.dart index eadce0da2..e65fa52b1 100644 --- a/model/lib/email/presentation_email.dart +++ b/model/lib/email/presentation_email.dart @@ -13,6 +13,7 @@ import 'package:jmap_dart_client/jmap/mail/email/individual_header_identifier.da import 'package:jmap_dart_client/jmap/mail/email/keyword_identifier.dart'; import 'package:jmap_dart_client/jmap/mail/mailbox/mailbox.dart'; import 'package:model/email/email_content.dart'; +import 'package:model/email/email_in_thread_status.dart'; import 'package:model/email/mail_priority_header.dart'; import 'package:model/extensions/email_address_extension.dart'; import 'package:model/extensions/keyword_identifier_extension.dart'; @@ -38,6 +39,7 @@ class PresentationEmail with EquatableMixin, SearchSnippetMixin { final Set? bcc; final Set? replyTo; final Map? mailboxIds; + final ThreadId? threadId; final SelectMode selectMode; final Uri? routeWeb; final PresentationMailbox? mailboxContain; @@ -48,6 +50,7 @@ class PresentationEmail with EquatableMixin, SearchSnippetMixin { final Map? xPriorityHeader; final Map? importanceHeader; final Map? priorityHeader; + final EmailInThreadStatus? emailInThreadStatus; PresentationEmail({ this.id, @@ -65,6 +68,7 @@ class PresentationEmail with EquatableMixin, SearchSnippetMixin { this.bcc, this.replyTo, this.mailboxIds, + this.threadId, this.selectMode = SelectMode.INACTIVE, this.routeWeb, this.mailboxContain, @@ -75,6 +79,7 @@ class PresentationEmail with EquatableMixin, SearchSnippetMixin { this.xPriorityHeader, this.importanceHeader, this.priorityHeader, + this.emailInThreadStatus, }); String getSenderName() { @@ -183,6 +188,7 @@ class PresentationEmail with EquatableMixin, SearchSnippetMixin { bcc, replyTo, mailboxIds, + threadId, selectMode, routeWeb, mailboxContain, @@ -195,6 +201,7 @@ class PresentationEmail with EquatableMixin, SearchSnippetMixin { xPriorityHeader, importanceHeader, priorityHeader, + emailInThreadStatus, ]; PresentationEmail copyWith({ @@ -213,6 +220,7 @@ class PresentationEmail with EquatableMixin, SearchSnippetMixin { Set? bcc, Set? replyTo, Map? mailboxIds, + ThreadId? threadId, SelectMode? selectMode, Uri? routeWeb, PresentationMailbox? mailboxContain, @@ -223,6 +231,7 @@ class PresentationEmail with EquatableMixin, SearchSnippetMixin { Map? xPriorityHeader, Map? importanceHeader, Map? priorityHeader, + EmailInThreadStatus? emailInThreadStatus, }) { return PresentationEmail( id: id ?? this.id, @@ -240,6 +249,7 @@ class PresentationEmail with EquatableMixin, SearchSnippetMixin { bcc: bcc ?? this.bcc, replyTo: replyTo ?? this.replyTo, mailboxIds: mailboxIds ?? this.mailboxIds, + threadId: threadId ?? this.threadId, selectMode: selectMode ?? this.selectMode, routeWeb: routeWeb ?? this.routeWeb, mailboxContain: mailboxContain ?? this.mailboxContain, @@ -250,6 +260,7 @@ class PresentationEmail with EquatableMixin, SearchSnippetMixin { xPriorityHeader: xPriorityHeader ?? this.xPriorityHeader, importanceHeader: importanceHeader ?? this.importanceHeader, priorityHeader: priorityHeader ?? this.priorityHeader, + emailInThreadStatus: emailInThreadStatus ?? this.emailInThreadStatus, ); } } \ No newline at end of file diff --git a/model/lib/extensions/email_extension.dart b/model/lib/extensions/email_extension.dart index bf0126192..1d1db3226 100644 --- a/model/lib/extensions/email_extension.dart +++ b/model/lib/extensions/email_extension.dart @@ -116,6 +116,7 @@ extension EmailExtension on Email { bcc: bcc, replyTo: replyTo, mailboxIds: mailboxIds, + threadId: threadId, selectMode: selectMode, emailHeader: headers?.toList(), bodyValues: bodyValues, @@ -146,6 +147,7 @@ extension EmailExtension on Email { bcc: updatedProperties.contain(EmailProperty.bcc) ? newEmail.bcc : bcc, replyTo: updatedProperties.contain(EmailProperty.replyTo) ? newEmail.replyTo : replyTo, mailboxIds: updatedProperties.contain(EmailProperty.mailboxIds) ? newEmail.mailboxIds : mailboxIds, + threadId: updatedProperties.contain(EmailProperty.threadId) ? newEmail.threadId : threadId, headerCalendarEvent: updatedProperties.contain(IndividualHeaderIdentifier.headerCalendarEvent.value) ? newEmail.headerCalendarEvent : headerCalendarEvent, xPriorityHeader: updatedProperties.contain(IndividualHeaderIdentifier.xPriorityHeader.value) ? newEmail.xPriorityHeader diff --git a/model/lib/extensions/list_email_extension.dart b/model/lib/extensions/list_email_extension.dart index a4a609e48..68ffb00ea 100644 --- a/model/lib/extensions/list_email_extension.dart +++ b/model/lib/extensions/list_email_extension.dart @@ -34,4 +34,8 @@ extension ListEmailExtension on List { } }); } + + List sortWithResult(Comparator comparator) { + return List.from(this..sortBy(comparator)); + } } \ No newline at end of file diff --git a/model/lib/extensions/session_extension.dart b/model/lib/extensions/session_extension.dart index 9a21ea315..48a4542a3 100644 --- a/model/lib/extensions/session_extension.dart +++ b/model/lib/extensions/session_extension.dart @@ -8,10 +8,12 @@ import 'package:jmap_dart_client/jmap/account_id.dart'; import 'package:jmap_dart_client/jmap/core/capability/calendar_event_capability.dart'; import 'package:jmap_dart_client/jmap/core/capability/capability_identifier.dart'; import 'package:jmap_dart_client/jmap/core/capability/capability_properties.dart'; +import 'package:jmap_dart_client/jmap/core/capability/core_capability.dart'; import 'package:jmap_dart_client/jmap/core/capability/default_capability.dart'; import 'package:jmap_dart_client/jmap/core/capability/empty_capability.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/core/unsigned_int.dart'; import 'package:model/error_type_handler/account_exception.dart'; import 'package:model/error_type_handler/unknown_address_exception.dart'; import 'package:model/error_type_handler/unknown_uri_exception.dart'; @@ -181,4 +183,16 @@ extension SessionExtension on Session { return supportedLanguages.firstOrNull; } } + + UnsignedInt? getMaxObjectsInGet(AccountId accountId) { + try { + return getCapabilityProperties( + accountId, + CapabilityIdentifier.jmapCore + )?.maxObjectsInGet; + } catch (e) { + logError('SessionExtensions::getMaxObjectsInGet():Exception: $e'); + return null; + } + } } \ No newline at end of file diff --git a/test/features/email/presentation/controller/single_email_controller_test.dart b/test/features/email/presentation/controller/single_email_controller_test.dart index 7d0e9622d..0c6678bf2 100644 --- a/test/features/email/presentation/controller/single_email_controller_test.dart +++ b/test/features/email/presentation/controller/single_email_controller_test.dart @@ -239,14 +239,14 @@ void main() { when(mailboxDashboardController.emailUIAction).thenReturn(Rxn(null)); when(mailboxDashboardController.viewState).thenReturn(Rx(Right(UIState.idle))); when(mailboxDashboardController.sessionCurrent).thenReturn(testSession); - singleEmailController.onInit(); Get.put(acceptCalendarEventInteractor); - mailboxDashboardController.accountId.refresh(); - singleEmailController.handleSuccessViewState( - ParseCalendarEventSuccess([ - BlobCalendarEvent( - blobId: blobId, - calendarEventList: [calendarEvent])])); + singleEmailController.onInit(); + mailboxDashboardController.accountId.refresh(); + singleEmailController.handleSuccessViewState( + ParseCalendarEventSuccess([ + BlobCalendarEvent( + blobId: blobId, + calendarEventList: [calendarEvent])])); // act singleEmailController.onCalendarEventReplyAction(EventActionType.yes, emailId); @@ -267,8 +267,8 @@ void main() { when(mailboxDashboardController.emailUIAction).thenReturn(Rxn(null)); when(mailboxDashboardController.viewState).thenReturn(Rx(Right(UIState.idle))); when(mailboxDashboardController.sessionCurrent).thenReturn(testSession); - singleEmailController.onInit(); Get.put(maybeCalendarEventInteractor); + singleEmailController.onInit(); mailboxDashboardController.accountId.refresh(); singleEmailController.handleSuccessViewState( ParseCalendarEventSuccess([ @@ -295,8 +295,8 @@ void main() { when(mailboxDashboardController.emailUIAction).thenReturn(Rxn(null)); when(mailboxDashboardController.viewState).thenReturn(Rx(Right(UIState.idle))); when(mailboxDashboardController.sessionCurrent).thenReturn(testSession); - singleEmailController.onInit(); Get.put(rejectCalendarEventInteractor); + singleEmailController.onInit(); mailboxDashboardController.accountId.refresh(); singleEmailController.handleSuccessViewState( ParseCalendarEventSuccess([ diff --git a/test/features/thread_detail/domain/usecases/get_emails_by_ids_interactor_test.dart b/test/features/thread_detail/domain/usecases/get_emails_by_ids_interactor_test.dart new file mode 100644 index 000000000..87dd8e51d --- /dev/null +++ b/test/features/thread_detail/domain/usecases/get_emails_by_ids_interactor_test.dart @@ -0,0 +1,52 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:jmap_dart_client/jmap/core/id.dart'; +import 'package:jmap_dart_client/jmap/core/properties/properties.dart'; +import 'package:jmap_dart_client/jmap/mail/email/email.dart'; +import 'package:mockito/annotations.dart'; +import 'package:model/email/email_property.dart'; +import 'package:tmail_ui_user/features/thread_detail/domain/exceptions/thread_detail_overload_exception.dart'; +import 'package:tmail_ui_user/features/thread_detail/domain/repository/thread_detail_repository.dart'; +import 'package:tmail_ui_user/features/thread_detail/domain/state/get_emails_by_ids_state.dart'; +import 'package:tmail_ui_user/features/thread_detail/domain/usecases/get_emails_by_ids_interactor.dart'; + +import '../../../../fixtures/account_fixtures.dart'; +import '../../../../fixtures/session_fixtures.dart'; +import 'get_emails_by_ids_interactor_test.mocks.dart'; + +@GenerateNiceMocks([MockSpec()]) +void main() { + final repository = MockThreadDetailRepository(); + final getEmailsByIdsInteractor = GetEmailsByIdsInteractor(repository); + + group('get emails by ids interactor test:', () { + test( + 'should throw ThreadDetailOverloadException ' + 'when there are more than 1 emailIds ' + 'and properties contains any of htmlBody, bodyValues or attachments', + () async { + // arrange + final emailIds = [EmailId(Id('1')), EmailId(Id('2'))]; + final properties = Properties({ + EmailProperty.htmlBody, + EmailProperty.bodyValues, + EmailProperty.attachments, + }); + final result = await getEmailsByIdsInteractor.execute( + SessionFixtures.aliceSession, + AccountFixtures.aliceAccountId, + emailIds, + properties: properties, + ).last; + + // assert + expect( + result.fold( + (failure) => failure is GetEmailsByIdsFailure && + failure.exception is ThreadDetailOverloadException, + (success) => false, + ), + true, + ); + }); + }); +} \ No newline at end of file diff --git a/test/features/thread_detail/presentation/extension/handle_get_email_ids_by_thread_id_success_test.dart b/test/features/thread_detail/presentation/extension/handle_get_email_ids_by_thread_id_success_test.dart new file mode 100644 index 000000000..df4211e1d --- /dev/null +++ b/test/features/thread_detail/presentation/extension/handle_get_email_ids_by_thread_id_success_test.dart @@ -0,0 +1,68 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:get/get.dart'; +import 'package:jmap_dart_client/jmap/core/id.dart'; +import 'package:jmap_dart_client/jmap/mail/email/email.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:model/email/presentation_email.dart'; +import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller.dart'; +import 'package:tmail_ui_user/features/thread_detail/domain/state/get_thread_by_id_state.dart'; +import 'package:tmail_ui_user/features/thread_detail/presentation/extension/handle_get_email_ids_by_thread_id_success.dart'; +import 'package:tmail_ui_user/features/thread_detail/presentation/thread_detail_controller.dart'; + +import 'handle_get_email_ids_by_thread_id_success_test.mocks.dart'; + +@GenerateNiceMocks([ + MockSpec(), + MockSpec(), +]) +void main() { + final threadDetailController = MockThreadDetailController(); + group('handle get email ids by thread id success test:', () { + test( + 'should assign emailIds with result from success ' + 'when result from success is not empty', + () { + // arrange + final success = GetThreadByIdSuccess([ + EmailId(Id('1')), + EmailId(Id('2')), + ]); + when(threadDetailController.emailIdsPresentation).thenReturn( + {}.obs, + ); + + // act + threadDetailController.handleGetEmailIdsByThreadIdSuccess(success); + + // assert + expect(threadDetailController.emailIdsPresentation.keys, success.emailIds); + }); + + test( + 'should assign emailIds with result from mailbox dashboard controller ' + 'when result from success is empty ' + 'and mailbox dashboard controller selected email is not null', + () { + // arrange + final mailboxDashBoardController = MockMailboxDashBoardController(); + final success = GetThreadByIdSuccess([]); + when(mailboxDashBoardController.selectedEmail).thenReturn( + Rxn(PresentationEmail(id: EmailId(Id('1')))), + ); + when(threadDetailController.emailIdsPresentation).thenReturn( + {}.obs, + ); + when(threadDetailController.mailboxDashBoardController).thenReturn(mailboxDashBoardController); + + // act + threadDetailController.handleGetEmailIdsByThreadIdSuccess(success); + + // assert + expect( + threadDetailController.emailIdsPresentation.keys, + [mailboxDashBoardController.selectedEmail.value!.id!], + ); + }); + }); +} \ No newline at end of file diff --git a/test/features/thread_detail/presentation/extension/initialize_thread_detail_emails_test.dart b/test/features/thread_detail/presentation/extension/initialize_thread_detail_emails_test.dart new file mode 100644 index 000000000..4f455f979 --- /dev/null +++ b/test/features/thread_detail/presentation/extension/initialize_thread_detail_emails_test.dart @@ -0,0 +1,68 @@ +import 'package:flutter_test/flutter_test.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/mail/email/email.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:tmail_ui_user/features/thread_detail/domain/usecases/get_emails_by_ids_interactor.dart'; +import 'package:tmail_ui_user/features/thread_detail/presentation/extension/initialize_thread_detail_emails.dart'; +import 'package:tmail_ui_user/features/thread_detail/presentation/thread_detail_controller.dart'; + +import '../../../../fixtures/session_fixtures.dart'; +import 'initialize_thread_detail_emails_test.mocks.dart'; + +@GenerateNiceMocks([ + MockSpec(), + MockSpec(), +]) +void main() { + group('InitializeThreadDetailEmails', () { + late MockGetEmailsByIdsInteractor getEmailsByIdsInteractor; + late MockThreadDetailController threadDetailController; + + setUp(() { + getEmailsByIdsInteractor = MockGetEmailsByIdsInteractor(); + threadDetailController = MockThreadDetailController(); + }); + + test('initializeThreadDetailEmails should call getEmailsByIdsInteractor with 3 elements', () async { + // Arrange + final emailIds = [ + EmailId(Id('email1')), + EmailId(Id('email2')), + EmailId(Id('email3')), + EmailId(Id('email4')), + EmailId(Id('email5')), + ]; + + when(threadDetailController.emailIdsPresentation).thenReturn( + Map.fromEntries(emailIds.map((emailId) => MapEntry(emailId, null))).obs, + ); + when(threadDetailController.getEmailsByIdsInteractor).thenReturn(getEmailsByIdsInteractor); + when(getEmailsByIdsInteractor.execute( + any, + any, + any, + properties: anyNamed('properties'), + )).thenAnswer((_) => const Stream.empty()); + final accountId = AccountId(Id('accountId')); + final session = SessionFixtures.aliceSession; + when(threadDetailController.accountId).thenReturn(accountId); + when(threadDetailController.session).thenReturn(session); + + // Act + threadDetailController.initializeThreadDetailEmails(); + + // Assert + final captured = verify(getEmailsByIdsInteractor.execute( + captureAny, + captureAny, + captureAny, + properties: captureAnyNamed('properties'), + )).captured; + + expect((captured[2] as List).length, 3); + }); + }); +} diff --git a/test/features/thread_detail/presentation/extension/load_more_thread_detail_emails_test.dart b/test/features/thread_detail/presentation/extension/load_more_thread_detail_emails_test.dart new file mode 100644 index 000000000..fe745c8fd --- /dev/null +++ b/test/features/thread_detail/presentation/extension/load_more_thread_detail_emails_test.dart @@ -0,0 +1,104 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:get/get.dart'; +import 'package:jmap_dart_client/jmap/core/id.dart'; +import 'package:jmap_dart_client/jmap/mail/email/email.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:model/email/presentation_email.dart'; +import 'package:tmail_ui_user/features/email/presentation/utils/email_utils.dart'; +import 'package:tmail_ui_user/features/thread_detail/domain/usecases/get_emails_by_ids_interactor.dart'; +import 'package:tmail_ui_user/features/thread_detail/presentation/extension/load_more_thread_detail_emails.dart'; +import 'package:tmail_ui_user/features/thread_detail/presentation/thread_detail_controller.dart'; + +import '../../../../fixtures/account_fixtures.dart'; +import '../../../../fixtures/session_fixtures.dart'; +import 'load_more_thread_detail_emails_test.mocks.dart'; + +@GenerateNiceMocks([ + MockSpec(), + MockSpec(), +]) +void main() { + late MockThreadDetailController controller; + late MockGetEmailsByIdsInteractor getEmailsByIdsInteractor; + + setUp(() { + controller = MockThreadDetailController(); + getEmailsByIdsInteractor = MockGetEmailsByIdsInteractor(); + }); + + test('Only call getEmailsByIdsInteractor.execute on emails where presentation email is null, max 20 emails', () async { + // Arrange + when(controller.emailIdsPresentation).thenReturn({ + for (int i = 0; i < 40; i++) + EmailId(Id('$i')): null, + EmailId(Id('40')): PresentationEmail(), + }.obs); + when(controller.session).thenReturn(SessionFixtures.aliceSession); + when(controller.accountId).thenReturn(AccountFixtures.aliceAccountId); + when(controller.getEmailsByIdsInteractor).thenReturn(getEmailsByIdsInteractor); + + // Act + controller.loadMoreThreadDetailEmails(); + + // Assert + verify(getEmailsByIdsInteractor.execute( + SessionFixtures.aliceSession, + AccountFixtures.aliceAccountId, + List.generate(20, (i) => EmailId(Id('${i + 20}'))), + properties: EmailUtils.getPropertiesForEmailGetMethod( + SessionFixtures.aliceSession, + AccountFixtures.aliceAccountId, + ), + )).called(1); + }); + + test('No getEmailsByIdsInteractor.execute call if emailIdsToLoadMetaData is empty', () async { + // Arrange + when(controller.emailIdsPresentation).thenReturn({ + EmailId(Id('1')): PresentationEmail(), + EmailId(Id('2')): PresentationEmail(), + }.obs); + when(controller.session).thenReturn(SessionFixtures.aliceSession); + when(controller.accountId).thenReturn(AccountFixtures.aliceAccountId); + when(controller.getEmailsByIdsInteractor).thenReturn(getEmailsByIdsInteractor); + + // Act + controller.loadMoreThreadDetailEmails(); + + // Assert + verifyNever(getEmailsByIdsInteractor.execute( + any, + any, + any, + properties: anyNamed('properties'), + )); + }); + + test('Call getEmailsByIdsInteractor.execute when there are less than 20 emails', () async { + // Arrange + const limit = 15; + when(controller.emailIdsPresentation).thenReturn({ + for (int i = 0; i < limit; i++) + EmailId(Id('$i')): null, + EmailId(Id('$limit')): PresentationEmail(), + }.obs); + when(controller.session).thenReturn(SessionFixtures.aliceSession); + when(controller.accountId).thenReturn(AccountFixtures.aliceAccountId); + when(controller.getEmailsByIdsInteractor).thenReturn(getEmailsByIdsInteractor); + + // Act + controller.loadMoreThreadDetailEmails(); + + // Assert + verify(getEmailsByIdsInteractor.execute( + SessionFixtures.aliceSession, + AccountFixtures.aliceAccountId, + List.generate(limit, (i) => EmailId(Id('$i'))), + properties: EmailUtils.getPropertiesForEmailGetMethod( + SessionFixtures.aliceSession, + AccountFixtures.aliceAccountId, + ), + )).called(1); + }); +} diff --git a/test/features/thread_detail/presentation/utils/thread_detail_presentation_utils_test.dart b/test/features/thread_detail/presentation/utils/thread_detail_presentation_utils_test.dart new file mode 100644 index 000000000..13212f3cc --- /dev/null +++ b/test/features/thread_detail/presentation/utils/thread_detail_presentation_utils_test.dart @@ -0,0 +1,68 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:jmap_dart_client/jmap/core/id.dart'; +import 'package:jmap_dart_client/jmap/mail/email/email.dart'; +import 'package:model/email/presentation_email.dart'; +import 'package:tmail_ui_user/features/thread_detail/presentation/utils/thread_detail_presentation_utils.dart'; + +void main() { + group('ThreadDetailPresentationUtils', () { + group('getEmailIdsToLoad', () { + test('loads first time with length equal or more than initial load size', () { + final emailIdsPresentation = { + EmailId(Id('1')): null, + EmailId(Id('2')): null, + EmailId(Id('3')): null, + EmailId(Id('4')): null, + EmailId(Id('5')): null, + }; + + final result = ThreadDetailPresentationUtils.getEmailIdsToLoad(emailIdsPresentation); + + expect(result.length, equals(ThreadDetailPresentationUtils.initialLoadSize)); + expect(result, equals([EmailId(Id('1')), EmailId(Id('2'))])); + }); + + test('loads first time with length smaller than initial load size', () { + final emailIdsPresentation = { + EmailId(Id('1')): null, + EmailId(Id('2')): null, + }; + + final result = ThreadDetailPresentationUtils.getEmailIdsToLoad(emailIdsPresentation); + + expect(result.length, equals(emailIdsPresentation.length)); + expect(result, equals([EmailId(Id('1')), EmailId(Id('2'))])); + }); + + test('loads with length equal or more than default load size', () { + final emailIdsPresentation = { + EmailId(Id('1')): PresentationEmail(), + EmailId(Id('2')): null, + EmailId(Id('3')): null, + EmailId(Id('4')): null, + EmailId(Id('5')): null, + EmailId(Id('6')): null, + EmailId(Id('7')): null, + }; + + final result = ThreadDetailPresentationUtils.getEmailIdsToLoad(emailIdsPresentation); + + expect(result.length, equals(6)); + expect(result, equals([EmailId(Id('2')), EmailId(Id('3')), EmailId(Id('4')), EmailId(Id('5')), EmailId(Id('6')), EmailId(Id('7'))])); + }); + + test('loads with length smaller than default load size', () { + final emailIdsPresentation = { + EmailId(Id('1')): PresentationEmail(), + EmailId(Id('2')): null, + EmailId(Id('3')): null, + }; + + final result = ThreadDetailPresentationUtils.getEmailIdsToLoad(emailIdsPresentation); + + expect(result.length, equals(emailIdsPresentation.length - 1)); + expect(result, equals([EmailId(Id('2')), EmailId(Id('3'))])); + }); + }); + }); +} \ No newline at end of file