TF-3643 Thread Detail get emails meta data method

TF-3643 Thread Detail cache email with thread id

TF-3643 Thread Detail view thread detail

TF-3643 Thread Detail [Part 2 fixup] remove ThreadDetailController dependency from MailboxDashboardController

TF-3643 Thread Detail [Part 2 fixup] remove EmailSupervisorController dependency from ThreadDetailController

TF-3643 Thread Detail [Part 2 fixup] move isSearchRunning check into ThreadDetailController

TF-3643 Thread Detail [Part 2 fixup] ensure GetEmailsByIdsInteractor performance

TF-3643 Thread Detail [Part 2 fixup] unit test loadMoreThreadDetailEmails

TF-3643 Thread Detail [Part 2 fixup] unit test getEmailIdsToLoad

TF-3643 Thread Detail [Part 2 fixup] optimize ThreadDetailBinding bindingsDataSource

TF-3643 Thread Detail [Part 2 fixup] optimize getThreadDetailLoadingView

TF-3643 Thread Detail [Part 2 fixup] readd unique ValueKey for HtmlContentViewerOnWebWidget

TF-3643 Thread Detail [Part 2 fixup] unit test initializeThreadDetailEmails

TF-3643 Thread Detail [Part 3 fixup] Update private action button methods

TF-3643 Thread Detail [Part 4 fixup] Collapsed email preview 65 characters max

TF-3643 Thread Detail [Part 4 fixup] Remove personal scripts

TF-3643 Thread Detail move EmailInThreadStatus into PresentationEmail

TF-3643 Thread Detail refactor toggle collapse expand

TF-3643 Thread Detail EmailViewAppBarWidget required parameters

TF-3643 Thread Detail tap avatar toggle expansion

TF-3643 Thread Detail fix cannot open single email

TF-3643 Thread Detail fix open thread first time blank view

TF-3643 Thread Detail fix thread detail UI

TF-3643 Thread Detail fix round bottom view

TF-3643 Thread Detail fix round bottom view

TF-3643 Thread Detail bottom actions depend on current expanded email

TF-3643 Thread Detail hide next previous actions

TF-3643 Thread Detail fix load more show initial loading view

TF-3643 Thread Detail fix cannot view single email

TF-3643 Thread Detail fix bottom place holder not aligned

TF-3643 Thread Detail fix back to search view

TF-3643 Thread Detail fix display 65 character preview collapsed email

TF-3643 Thread Detail fix search not work

TF-3643 Thread Detail fix bottom place holder UI

TF-3643 Thread Detail fix expand view

TF-3643 Thread Detail jump to top of last email view

TF-3643 Thread Detail remove toMe condition of filtering thread

TF-3643 Thread Detail fix sort thread detail

TF-3643 Thread Detail fix assert not work in release

TF-3643 Thread Detail add load more test case <20

TF-3643 Thread Detail remove the use of currentThreadId

TF-3643 Thread Detail remove the use of selectedEmail in SingleEmailController

TF-3643 Thread Detail fix unit test SingleEmailController calendar

TF-3643 Thread Detail disable 2 way scroll control

TF-3643 Thread Detail allow thread to load sent email incase thread only contains 1 email

TF-3643 Thread Detail disable single email page view

TF-3643 Thread Detail update emailsNotLoadedCount name

TF-3643 Thread Detail remove collapsed email char limit

TF-3643 Thread Detail fix CI

TF-3643 Thread Detail unit test handleGetEmailIdsByThreadIdSuccess

TF-3643 Thread Detail make _currentEmailId private

TF-3643 Thread Detail fix search click users

TF-3643 Thread Detail fix scroll jump

TF-3643 Thread Detail fix scroll jump

TF-3643 Thread Detail fix web back action

TF-3643 Thread Detail round bottom actions

TF-3643 Thread Detail fix tablet large empty email

TF-3643 Thread Detail fix email subject selectable

TF-3643 Thread Detail fix email single view with top divider

TF-3643 Thread Detail fix email cache threadId missing

TF-3643 Thread Detail fix filter sent email in thread details

TF-3643 Thread Detail fix select text

TF-3643 Thread Detail fix subject alignment

TF-3643 Thread Detail fix subject alignment

TF-3643 Thread Detail fix tablet large empty email

TF-3643 Thread Detail add nullcheck for _jumpScrollViewToTopOfEmail

TF-3643 Thread Detail decouple ThreadDetailController from SingleEmailController

TF-3643 Thread Detail combine emailIds to emailIdsPresentation

TF-3643 Thread Detail remove currentEmailLoaded obx listener of SingleEmailController

TF-3643 Thread Detail replace threadAction with an email ui action

TF-3643 Thread Detail fix top and bottom bar delay displaying

TF-3643 Thread Detail update ReplyForwardBottomBarAction to PerformEmailActionInThreadDetailAction

TF-3643 Thread Detail use getOwnEmailAddress in ThreadDetailAppBar

TF-3643 Thread Detail add TextOverflow.ellipsis to EmailSubjectWidget

TF-3643 Thread Detail fix round border threadDetailLoadingView
This commit is contained in:
DatDang
2025-05-12 15:22:29 +07:00
committed by Dat H. Pham
parent 7c3af832e4
commit 58a576adeb
84 changed files with 2845 additions and 558 deletions
@@ -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();
}
}
@@ -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);
}
});
}
}
@@ -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(),
),
),
);
}
}
@@ -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<Widget> 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();
}
}
@@ -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,
};
}
}
}
@@ -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);
}
});
}
}
}
@@ -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!,
),
));
}
}
@@ -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!,
),
));
}
}
@@ -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<SingleEmailController>(
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,
);
}
}
}
}
@@ -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<HttpClient>()));
super.dependencies();
}
@override
void bindingsController() {
Get.put(ThreadDetailController(
Get.find<GetThreadByIdInteractor>(),
Get.find<GetEmailsByIdsInteractor>(),
));
}
@override
void bindingsDataSourceImpl() {
Get.lazyPut(() => ThreadDetailRemoteDataSourceImpl(
Get.find<ThreadDetailApi>(),
Get.find<RemoteExceptionThrower>(),
));
}
@override
void bindingsDataSource() {
Get.lazyPut<ThreadDetailDataSource>(() => Get.find<ThreadDetailRemoteDataSourceImpl>());
}
@override
void bindingsRepositoryImpl() {
Get.lazyPut(() => ThreadDetailRepositoryImpl({
DataSourceType.network: Get.find<ThreadDetailDataSource>(),
}));
}
@override
void bindingsRepository() {
Get.lazyPut<ThreadDetailRepository>(() => Get.find<ThreadDetailRepositoryImpl>());
}
@override
void bindingsInteractor() {
Get.lazyPut(() => GetThreadByIdInteractor(Get.find<ThreadDetailRepository>()));
Get.lazyPut(() => GetEmailsByIdsInteractor(Get.find<ThreadDetailRepository>()));
}
}
@@ -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 = <EmailId>[].obs;
final emailIdsPresentation = <EmailId, PresentationEmail?>{}.obs;
final currentExpandedEmailId = Rxn<EmailId>();
final currentEmailLoaded = Rxn<EmailLoaded>();
final mailboxDashBoardController = Get.find<MailboxDashBoardController>();
final searchEmailController = Get.find<SearchEmailController>();
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);
@@ -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<ThreadDetailController> {
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<SingleEmailController>(
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),
),
)
);
}
}
@@ -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<EmailId> getEmailIdsToLoad(
Map<EmailId, PresentationEmail?> 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,
);
}
}
@@ -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<Widget> 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;
}
}
@@ -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,
),
),
],
),
),
);
}
}
@@ -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<ThreadDetailLoadMoreCircle> createState() => _ThreadDetailLoadMoreCircleState();
}
class _ThreadDetailLoadMoreCircleState extends State<ThreadDetailLoadMoreCircle> {
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),
),
),
),
],
),
);
}
}