From f84f2ce4fd12c3659197c9b26031c9fcfb87b3d7 Mon Sep 17 00:00:00 2001 From: dab246 Date: Wed, 6 Sep 2023 11:46:38 +0700 Subject: [PATCH] TF-2116 Create RecipientComposer widget and style (cherry picked from commit 24be032c8c04e9673953c9dc83d096e7b61fdb04) --- .../recipient_composer_widget_style.dart | 33 ++ ...ecipient_suggestion_item_widget_style.dart | 22 + .../recipient_tag_item_widget_style.dart | 16 + .../widgets/email_address_input_builder.dart | 526 ------------------ .../widgets/recipient_composer_widget.dart | 332 +++++++++++ .../recipient_suggestion_item_widget.dart | 96 ++++ .../widgets/recipient_tag_item_widget.dart | 124 +++++ 7 files changed, 623 insertions(+), 526 deletions(-) create mode 100644 lib/features/composer/presentation/styles/recipient_composer_widget_style.dart create mode 100644 lib/features/composer/presentation/styles/recipient_suggestion_item_widget_style.dart create mode 100644 lib/features/composer/presentation/styles/recipient_tag_item_widget_style.dart delete mode 100644 lib/features/composer/presentation/widgets/email_address_input_builder.dart create mode 100644 lib/features/composer/presentation/widgets/recipient_composer_widget.dart create mode 100644 lib/features/composer/presentation/widgets/recipient_suggestion_item_widget.dart create mode 100644 lib/features/composer/presentation/widgets/recipient_tag_item_widget.dart diff --git a/lib/features/composer/presentation/styles/recipient_composer_widget_style.dart b/lib/features/composer/presentation/styles/recipient_composer_widget_style.dart new file mode 100644 index 000000000..b1a42ae01 --- /dev/null +++ b/lib/features/composer/presentation/styles/recipient_composer_widget_style.dart @@ -0,0 +1,33 @@ + +import 'package:core/presentation/extensions/color_extension.dart'; +import 'package:flutter/material.dart'; + +class RecipientComposerWidgetStyle { + static const double deleteRecipientFieldIconSize = 20; + static const double space = 8; + + static const Color borderColor = AppColor.colorLineComposer; + static const Color deleteRecipientFieldIconColor = AppColor.colorCollapseMailbox; + + static const EdgeInsetsGeometry deleteRecipientFieldIconPadding = EdgeInsetsDirectional.all(3); + static const EdgeInsetsGeometry prefixButtonPadding = EdgeInsetsDirectional.symmetric(vertical: 3, horizontal: 5); + static const EdgeInsetsGeometry labelMargin = EdgeInsetsDirectional.only(top: 16); + static const EdgeInsetsGeometry recipientMargin = EdgeInsetsDirectional.only(top: 12); + + static const TextStyle prefixButtonTextStyle = TextStyle( + fontSize: 15, + fontWeight: FontWeight.normal, + decoration: TextDecoration.underline, + color: AppColor.colorPrefixButtonComposer + ); + static const TextStyle labelTextStyle = TextStyle( + fontSize: 14, + fontWeight: FontWeight.w400, + color: AppColor.colorLabelComposer + ); + static const TextStyle inputTextStyle = TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: Colors.black + ); +} \ No newline at end of file diff --git a/lib/features/composer/presentation/styles/recipient_suggestion_item_widget_style.dart b/lib/features/composer/presentation/styles/recipient_suggestion_item_widget_style.dart new file mode 100644 index 000000000..2e4375864 --- /dev/null +++ b/lib/features/composer/presentation/styles/recipient_suggestion_item_widget_style.dart @@ -0,0 +1,22 @@ +import 'package:core/presentation/extensions/color_extension.dart'; +import 'package:flutter/material.dart'; + +class RecipientSuggestionItemWidgetStyle { + static const double radius = 20; + static const double selectedIconSize = 24; + + static const EdgeInsetsGeometry suggestionDuplicatedMargin = EdgeInsets.all(8.0); + static const EdgeInsetsGeometry labelDuplicatedPadding = EdgeInsets.symmetric(horizontal: 8.0); + static const EdgeInsetsGeometry labelPadding = EdgeInsets.symmetric(horizontal: 16.0); + + static const TextStyle labelTextStyle = TextStyle( + color: AppColor.colorHintSearchBar, + fontSize: 13, + fontWeight: FontWeight.normal + ); + static const TextStyle labelHighlightTextStyle = TextStyle( + color: Colors.black, + fontSize: 13, + fontWeight: FontWeight.bold + ); +} \ No newline at end of file diff --git a/lib/features/composer/presentation/styles/recipient_tag_item_widget_style.dart b/lib/features/composer/presentation/styles/recipient_tag_item_widget_style.dart new file mode 100644 index 000000000..ac86ed8bf --- /dev/null +++ b/lib/features/composer/presentation/styles/recipient_tag_item_widget_style.dart @@ -0,0 +1,16 @@ +import 'package:core/utils/platform_info.dart'; +import 'package:flutter/material.dart'; + +class RecipientTagItemWidgetStyle { + static const double radius = 10; + + static const EdgeInsetsGeometry padding = EdgeInsetsDirectional.only(start: 4); + static const EdgeInsetsGeometry counterPadding = EdgeInsetsDirectional.symmetric(vertical: 5, horizontal: 8); + static const EdgeInsetsGeometry counterMargin = EdgeInsetsDirectional.only(top: PlatformInfo.isWeb ? 8 : 0); + + static const TextStyle labelTextStyle = TextStyle( + color: Colors.black, + fontSize: 17, + fontWeight: FontWeight.w400 + ); +} \ No newline at end of file diff --git a/lib/features/composer/presentation/widgets/email_address_input_builder.dart b/lib/features/composer/presentation/widgets/email_address_input_builder.dart deleted file mode 100644 index 4acd754fd..000000000 --- a/lib/features/composer/presentation/widgets/email_address_input_builder.dart +++ /dev/null @@ -1,526 +0,0 @@ - -import 'dart:async'; - -import 'package:collection/collection.dart'; -import 'package:core/core.dart'; -import 'package:core/utils/direction_utils.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_svg/flutter_svg.dart'; -import 'package:get/get.dart'; -import 'package:jmap_dart_client/jmap/mail/email/email_address.dart'; -import 'package:model/model.dart'; -import 'package:super_tag_editor/tag_editor.dart'; -import 'package:super_tag_editor/widgets/rich_text_widget.dart'; -import 'package:tmail_ui_user/features/composer/presentation/extensions/prefix_email_address_extension.dart'; -import 'package:tmail_ui_user/features/composer/presentation/styles/composer_style.dart'; -import 'package:tmail_ui_user/features/composer/presentation/widgets/suggestion_email_address.dart'; -import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/widgets/advanced_search/avatar_suggestion_item_widget.dart'; -import 'package:tmail_ui_user/main/localizations/app_localizations.dart'; -import 'package:tmail_ui_user/main/utils/app_constants.dart'; - -typedef OnSuggestionEmailAddress = Future> Function(String word); -typedef OnUpdateListEmailAddressAction = void Function(PrefixEmailAddress, List); -typedef OnAddEmailAddressTypeAction = void Function(PrefixEmailAddress); -typedef OnDeleteEmailAddressTypeAction = void Function(PrefixEmailAddress); -typedef OnShowFullListEmailAddressAction = void Function(PrefixEmailAddress); -typedef OnFocusEmailAddressChangeAction = void Function(PrefixEmailAddress, bool); -typedef OnFocusNextAddressAction = void Function(); - -class EmailAddressInputBuilder { - - static const _suggestionBoxRadius = 20.0; - final BuildContext _context; - final ImagePaths _imagePaths; - final ResponsiveUtils _responsiveUtils; - final ExpandMode expandMode; - final PrefixEmailAddress _prefixEmailAddress; - final List _listEmailAddressType; - final TextEditingController? controller; - final bool? isInitial; - final FocusNode? focusNode; - final bool autoDisposeFocusNode; - final GlobalKey? keyTagEditor; - final FocusNode? nextFocusNode; - - List listEmailAddress = []; - - OnUpdateListEmailAddressAction? _onUpdateListEmailAddressAction; - OnSuggestionEmailAddress? _onSuggestionEmailAddress; - OnAddEmailAddressTypeAction? _onAddEmailAddressTypeAction; - OnDeleteEmailAddressTypeAction? _onDeleteEmailAddressTypeAction; - OnShowFullListEmailAddressAction? _onShowFullListEmailAddressAction; - OnFocusEmailAddressChangeAction? _onFocusEmailAddressChangeAction; - OnFocusNextAddressAction? _onFocusNextAddressAction; - - Timer? _gapBetweenTagChangedAndFindSuggestion; - bool lastTagFocused = false; - - void addOnUpdateListEmailAddressAction(OnUpdateListEmailAddressAction onUpdateListEmailAddressAction) { - _onUpdateListEmailAddressAction = onUpdateListEmailAddressAction; - } - - void addOnSuggestionEmailAddress(OnSuggestionEmailAddress onSuggestionEmailAddress) { - _onSuggestionEmailAddress = onSuggestionEmailAddress; - } - - void addOnAddEmailAddressTypeAction(OnAddEmailAddressTypeAction onAddEmailAddressTypeAction) { - _onAddEmailAddressTypeAction = onAddEmailAddressTypeAction; - } - - void addOnDeleteEmailAddressTypeAction(OnDeleteEmailAddressTypeAction onDeleteEmailAddressTypeAction) { - _onDeleteEmailAddressTypeAction = onDeleteEmailAddressTypeAction; - } - - void addOnShowFullListEmailAddressAction(OnShowFullListEmailAddressAction onShowFullListEmailAddressAction) { - _onShowFullListEmailAddressAction = onShowFullListEmailAddressAction; - } - - void addOnFocusEmailAddressChangeAction(OnFocusEmailAddressChangeAction onFocusEmailAddressChangeAction) { - _onFocusEmailAddressChangeAction = onFocusEmailAddressChangeAction; - } - - void addOnFocusNextAddressAction(OnFocusNextAddressAction? onFocusNextAddressAction) { - _onFocusNextAddressAction = onFocusNextAddressAction; - } - - EmailAddressInputBuilder( - this._context, - this._imagePaths, - this._responsiveUtils, - this._prefixEmailAddress, - this.listEmailAddress, - this._listEmailAddressType, - { - this.isInitial, - this.controller, - this.focusNode, - this.autoDisposeFocusNode = true, - this.expandMode = ExpandMode.EXPAND, - this.keyTagEditor, - this.nextFocusNode, - } - ); - - Widget build() { - return Padding( - padding: _prefixEmailAddress == PrefixEmailAddress.to - ? ComposerStyle.getToAddressPadding(_context, _responsiveUtils) - : ComposerStyle.getCcBccAddressPadding(_context, _responsiveUtils), - child: Row( - children: [ - Text( - '${_prefixEmailAddress.asName(_context)}:', - style: const TextStyle( - fontSize: 15, - color: AppColor.colorHintEmailAddressInput - ) - ), - SizedBox(width: ComposerStyle.getSpace(_context, _responsiveUtils)), - Expanded(child: _buildTagEditor()), - SizedBox(width: ComposerStyle.getSpace(_context, _responsiveUtils)), - if (_prefixEmailAddress == PrefixEmailAddress.to) - Row(children: [ - if (!_listEmailAddressType.contains(PrefixEmailAddress.cc)) - buildTextIcon(AppLocalizations.of(_context).cc_email_address_prefix, - padding: const EdgeInsets.all(5), - textStyle: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.normal, - decoration: TextDecoration.underline, - color: AppColor.lineItemListColor), - onTap: () => _onAddEmailAddressTypeAction?.call(PrefixEmailAddress.cc)), - if (!_listEmailAddressType.contains(PrefixEmailAddress.bcc)) - buildTextIcon(AppLocalizations.of(_context).bcc_email_address_prefix, - padding: const EdgeInsets.all(5), - textStyle: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.normal, - decoration: TextDecoration.underline, - color: AppColor.lineItemListColor), - onTap: () => _onAddEmailAddressTypeAction?.call(PrefixEmailAddress.bcc)), - ]) - else - buildIconWeb( - icon: SvgPicture.asset( - _imagePaths.icCloseComposer, - width: 28, - height: 28, - fit: BoxFit.fill - ), - iconPadding: EdgeInsets.zero, - onTap: () => _onDeleteEmailAddressTypeAction?.call(_prefixEmailAddress)) - ] - ), - ); - } - - Widget _buildTagEditor() { - return StatefulBuilder(builder: (BuildContext context, StateSetter setState) { - final newListEmailAddress = _isCollapse ? listEmailAddress.sublist(0, 1) : listEmailAddress; - return FocusScope(child: Focus( - onFocusChange: (focus) => _onFocusEmailAddressChangeAction?.call(_prefixEmailAddress, focus), - onKey: (focusNode, event) { - if (event is RawKeyDownEvent && event.logicalKey == LogicalKeyboardKey.tab) { - nextFocusNode?.requestFocus(); - _onFocusNextAddressAction?.call(); - return KeyEventResult.handled; - } - return KeyEventResult.ignored; - }, - child: TagEditor( - key: keyTagEditor, - length: newListEmailAddress.length, - controller: controller, - focusNode: focusNode, - autoDisposeFocusNode: autoDisposeFocusNode, - keyboardType: TextInputType.emailAddress, - textInputAction: TextInputAction.done, - debounceDuration: const Duration(milliseconds: 150), - hasAddButton: false, - tagSpacing: 8, - autofocus: _prefixEmailAddress != PrefixEmailAddress.to && listEmailAddress.isEmpty, - minTextFieldWidth: 20, - resetTextOnSubmitted: true, - suggestionsBoxElevation: _suggestionBoxRadius, - suggestionsBoxBackgroundColor: Colors.white, - suggestionsBoxRadius: 20, - suggestionsBoxMaxHeight: 350, - textStyle: const TextStyle(color: AppColor.colorEmailAddress, fontSize: 14, fontWeight: FontWeight.w500), - onFocusTagAction: (focused) { - setState(() { - lastTagFocused = focused; - }); - }, - onDeleteTagAction: () => _handleDeleteTagAction(setState, context), - onSelectOptionAction: (item) { - if (!_isDuplicatedRecipient(item.emailAddress.emailAddress)) { - setState(() => listEmailAddress.add(item.emailAddress)); - _onUpdateListEmailAddressAction?.call(_prefixEmailAddress, listEmailAddress); - } - }, - onSubmitted: (value) { - log('EmailAddressInputBuilder::_buildTagEditor():onSubmitted: value: $value'); - final textTrim = value.trim(); - log('EmailAddressInputBuilder::_buildTagEditor():onSubmitted: textTrim: $textTrim'); - if (!_isDuplicatedRecipient(textTrim)) { - setState(() => listEmailAddress.add(EmailAddress(null, textTrim))); - _onUpdateListEmailAddressAction?.call(_prefixEmailAddress, listEmailAddress); - } - }, - inputDecoration: const InputDecoration(border: InputBorder.none), - tagBuilder: (context, index) { - final isLastEmail = index == listEmailAddress.length - 1; - return Stack( - alignment: AlignmentDirectional.centerEnd, - children: [ - Padding( - padding: EdgeInsetsDirectional.only( - top: PlatformInfo.isWeb ? 8 : 0, - end: _isCollapse ? 50 : 0), - child: InkWell( - onTap: () => _isCollapse - ? _onShowFullListEmailAddressAction?.call(_prefixEmailAddress) - : null, - child: Chip( - padding: DirectionUtils.isDirectionRTLByLanguage(context) - ? EdgeInsets.zero - : null, - labelPadding: EdgeInsetsDirectional.symmetric( - horizontal: 12, - vertical: DirectionUtils.isDirectionRTLByHasAnyRtl(newListEmailAddress[index].asString()) ? 0 : 2 - ), - label: Text( - newListEmailAddress[index].asString(), - maxLines: 1, - overflow: CommonTextStyle.defaultTextOverFlow, - softWrap: CommonTextStyle.defaultSoftWrap, - ), - deleteIcon: SvgPicture.asset(_imagePaths.icClose, fit: BoxFit.fill), - labelStyle: const TextStyle(color: Colors.black, fontSize: 17, fontWeight: FontWeight.normal), - backgroundColor: _getTagBackgroundColor(listEmailAddress[index], isLastEmail), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - side: _getTagBorderSide(listEmailAddress[index], isLastEmail), - ), - avatar: newListEmailAddress[index].displayName.isNotEmpty - ? CircleAvatar( - backgroundColor: AppColor.colorTextButton, - child: Text( - listEmailAddress[index].displayName[0].toUpperCase(), - style: const TextStyle( - color: Colors.white, - fontSize: 12, - fontWeight: FontWeight.w500 - ) - )) - : null, - onDeleted: () { - setState(() => listEmailAddress.removeAt(index)); - _onUpdateListEmailAddressAction?.call(_prefixEmailAddress, listEmailAddress); - }, - ) - ) - ), - if (_isCollapse) - _buildCounter( - context, - listEmailAddress.length - newListEmailAddress.length - ), - ] - ); - }, - onTagChanged: (value) { - log('EmailAddressInputBuilder::_buildTagEditor():onTagChanged: value: $value'); - final textTrim = value.trim(); - log('EmailAddressInputBuilder::_buildTagEditor():onTagChanged: textTrim: $textTrim'); - if (!_isDuplicatedRecipient(textTrim)) { - setState(() => listEmailAddress.add(EmailAddress(null, textTrim))); - _onUpdateListEmailAddressAction?.call(_prefixEmailAddress, listEmailAddress); - } - _gapBetweenTagChangedAndFindSuggestion = Timer( - const Duration(seconds: 1), - _handleGapBetweenTagChangedAndFindSuggestion); - }, - findSuggestions: _findSuggestions, - useDefaultHighlight: false, - suggestionBuilder: ( - context, - tagEditorState, - suggestionEmailAddress, - index, - length, - highlight, - suggestionValid - ) { - switch (suggestionEmailAddress.state) { - case SuggestionEmailState.duplicated: - return _buildExistedSuggestionItem( - setState, - context, - suggestionEmailAddress.emailAddress, - suggestionValid - ); - default: - return _buildSuggestionItem( - setState, - context, - tagEditorState, - suggestionEmailAddress.emailAddress, - index, - length, - highlight, - suggestionValid - ); - } - }, - ) - )); - }); - } - - Widget _buildCounter(BuildContext context, int count) { - return Padding( - padding: const EdgeInsetsDirectional.only(start: 8, top: PlatformInfo.isWeb ? 8 : 0), - child: InkWell( - onTap: () => _onShowFullListEmailAddressAction?.call(_prefixEmailAddress), - child: Chip( - labelPadding: const EdgeInsets.symmetric(horizontal: 8), - label: Text( - '+$count', - maxLines: 1, - overflow: CommonTextStyle.defaultTextOverFlow, - softWrap: CommonTextStyle.defaultSoftWrap), - labelStyle: const TextStyle(color: Colors.black, fontSize: 17, fontWeight: FontWeight.normal), - backgroundColor: AppColor.colorEmailAddressTag, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - side: const BorderSide(width: 0, color: AppColor.colorEmailAddressTag), - ), - ), - ), - ); - } - - bool get _isCollapse { - return listEmailAddress.length > 1 && expandMode == ExpandMode.COLLAPSE; - } - - Widget _buildExistedSuggestionItem( - StateSetter setState, - BuildContext context, - EmailAddress emailAddress, - String? suggestionValid - ) { - return Container( - padding: const EdgeInsets.all(8.0), - decoration: const BoxDecoration( - borderRadius: BorderRadius.all(Radius.circular(_suggestionBoxRadius))), - child: Container( - decoration: const BoxDecoration( - color: AppColor.colorBgMenuItemDropDownSelected, - borderRadius: BorderRadius.all(Radius.circular(_suggestionBoxRadius))), - child: ListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 8.0), - leading: AvatarSuggestionItemWidget(emailAddress: emailAddress), - title: _buildTitleSuggestionItem(emailAddress, suggestionValid), - subtitle: _buildSubtitleSuggestionItem(emailAddress, suggestionValid), - trailing: SvgPicture.asset(_imagePaths.icFilterSelected, - width: 24, - height: 24, - fit: BoxFit.fill), - ) - ) - ); - } - - Widget _buildSuggestionItem( - StateSetter setState, - BuildContext context, - TagsEditorState tagEditorState, - EmailAddress emailAddress, - int index, - int length, - bool highlight, - String? suggestionValid - ) { - return Container( - color: highlight ? AppColor.colorItemSelected : Colors.white, - child: Material( - type: MaterialType.transparency, - child: ListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 16), - leading: AvatarSuggestionItemWidget(emailAddress: emailAddress), - title: _buildTitleSuggestionItem(emailAddress, suggestionValid), - subtitle: _buildSubtitleSuggestionItem(emailAddress, suggestionValid), - onTap: () { - setState(() => listEmailAddress.add(emailAddress)); - _onUpdateListEmailAddressAction?.call(_prefixEmailAddress, listEmailAddress); - tagEditorState.resetTextField(); - tagEditorState.closeSuggestionBox(); - }, - ), - ), - ); - } - - Widget _buildTitleSuggestionItem(EmailAddress emailAddress, String? suggestionValid) { - return RichTextWidget( - textOrigin: emailAddress.asString(), - wordSearched: suggestionValid ?? '' - ); - } - - Widget? _buildSubtitleSuggestionItem(EmailAddress emailAddress, String? suggestionValid) { - if (emailAddress.displayName.isNotEmpty && emailAddress.emailAddress.isNotEmpty) { - return RichTextWidget( - textOrigin: emailAddress.emailAddress, - wordSearched: suggestionValid ?? '', - styleTextOrigin: const TextStyle( - color: AppColor.colorHintSearchBar, - fontSize: 13, - fontWeight: FontWeight.normal - ), - styleWordSearched: const TextStyle( - color: Colors.black, - fontSize: 13, - fontWeight: FontWeight.bold - ) - ); - } else { - return null; - } - } - - FutureOr> _findSuggestions(String query) async { - log('EmailAddressInputBuilder::_findSuggestions():query: $query'); - if (_gapBetweenTagChangedAndFindSuggestion?.isActive ?? false) { - log('EmailAddressInputBuilder::_findSuggestions(): return empty'); - return []; - } - - final processedQuery = query.trim(); - if (processedQuery.isEmpty) { - return []; - } - - final tmailSuggestion = List.empty(growable: true); - if (processedQuery.length >= AppConstants.limitCharToStartSearch && _onSuggestionEmailAddress != null) { - tmailSuggestion.addAll( - (await _onSuggestionEmailAddress!(processedQuery)) - .map((emailAddress) => _toSuggestionEmailAddress(emailAddress, listEmailAddress))); - } - - tmailSuggestion.addAll(_matchedSuggestionEmailAddress(processedQuery, listEmailAddress)); - - final currentTextOnTextField = controller?.text ?? ''; - if (currentTextOnTextField.isEmpty) { - return []; - } - - return tmailSuggestion.toSet().toList(); - } - - bool _isEmailAddressValid(String emailAddress) => GetUtils.isEmail(emailAddress); - - bool _isDuplicatedRecipient(String inputEmail) { - if (inputEmail.isEmpty) { - return false; - } - return listEmailAddress - .map((emailAddress) => emailAddress.email) - .whereNotNull() - .contains(inputEmail); - } - - SuggestionEmailAddress _toSuggestionEmailAddress(EmailAddress item, List addedEmailAddresses) { - if (addedEmailAddresses.contains(item)) { - return SuggestionEmailAddress(item, state: SuggestionEmailState.duplicated); - } else { - return SuggestionEmailAddress(item); - } - } - - Iterable _matchedSuggestionEmailAddress(String query, List addedEmailAddress) { - return addedEmailAddress.where((addedMail) => addedMail.emailAddress.contains(query)) - .map((emailAddress) => SuggestionEmailAddress(emailAddress, state: SuggestionEmailState.duplicated)); - } - - void _handleGapBetweenTagChangedAndFindSuggestion() { - log('EmailAddressInputBuilder::_handleGapBetweenTagChangedAndFindSuggestion(): Timeout'); - } - - Color _getTagBackgroundColor(EmailAddress emailCurrent, bool isLastEmail) { - if (lastTagFocused && isLastEmail) { - return AppColor.colorItemRecipientSelected; - } else { - return _isEmailAddressValid(emailCurrent.emailAddress) - ? AppColor.colorEmailAddressTag - : Colors.white; - } - } - - BorderSide _getTagBorderSide(EmailAddress emailCurrent, bool isLastEmail) { - if (lastTagFocused && isLastEmail) { - return const BorderSide( - width: 1, - color: AppColor.primaryColor); - } else { - return BorderSide( - width: _isEmailAddressValid(emailCurrent.emailAddress) ? 0 : 1, - color: _isEmailAddressValid(emailCurrent.emailAddress) - ? AppColor.colorEmailAddressTag - : AppColor.colorBorderEmailAddressInvalid); - } - } - - void _handleDeleteTagAction(StateSetter setState, BuildContext context) { - log('EmailAddressInputBuilder::_handleDeleteTagAction()'); - if (listEmailAddress.isNotEmpty) { - setState(() { - listEmailAddress.removeLast(); - }); - _onUpdateListEmailAddressAction?.call(_prefixEmailAddress, listEmailAddress); - } - } -} \ No newline at end of file diff --git a/lib/features/composer/presentation/widgets/recipient_composer_widget.dart b/lib/features/composer/presentation/widgets/recipient_composer_widget.dart new file mode 100644 index 000000000..11bcf293f --- /dev/null +++ b/lib/features/composer/presentation/widgets/recipient_composer_widget.dart @@ -0,0 +1,332 @@ + +import 'dart:async'; + +import 'package:collection/collection.dart'; +import 'package:core/presentation/resources/image_paths.dart'; +import 'package:core/presentation/views/button/tmail_button_widget.dart'; +import 'package:core/utils/app_logger.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:get/get.dart'; +import 'package:jmap_dart_client/jmap/mail/email/email_address.dart'; +import 'package:model/email/prefix_email_address.dart'; +import 'package:model/extensions/email_address_extension.dart'; +import 'package:model/mailbox/expand_mode.dart'; +import 'package:super_tag_editor/tag_editor.dart'; +import 'package:tmail_ui_user/features/composer/presentation/extensions/prefix_email_address_extension.dart'; +import 'package:tmail_ui_user/features/composer/presentation/model/prefix_recipient_state.dart'; +import 'package:tmail_ui_user/features/composer/presentation/styles/recipient_composer_widget_style.dart'; +import 'package:tmail_ui_user/features/composer/presentation/widgets/recipient_suggestion_item_widget.dart'; +import 'package:tmail_ui_user/features/composer/presentation/widgets/recipient_tag_item_widget.dart'; +import 'package:tmail_ui_user/features/composer/presentation/widgets/suggestion_email_address.dart'; +import 'package:tmail_ui_user/main/localizations/app_localizations.dart'; +import 'package:tmail_ui_user/main/utils/app_constants.dart'; + +typedef OnSuggestionEmailAddress = Future> Function(String word); +typedef OnUpdateListEmailAddressAction = void Function(PrefixEmailAddress prefix, List newData); +typedef OnAddEmailAddressTypeAction = void Function(PrefixEmailAddress prefix); +typedef OnDeleteEmailAddressTypeAction = void Function(PrefixEmailAddress prefix); +typedef OnShowFullListEmailAddressAction = void Function(PrefixEmailAddress prefix); +typedef OnFocusEmailAddressChangeAction = void Function(PrefixEmailAddress prefix, bool isFocus); + +class RecipientComposerWidget extends StatefulWidget { + + final PrefixEmailAddress prefix; + final List listEmailAddress; + final ExpandMode expandMode; + final PrefixRecipientState ccState; + final PrefixRecipientState bccState; + final bool? isInitial; + final FocusNode? focusNode; + final bool autoDisposeFocusNode; + final GlobalKey? keyTagEditor; + final FocusNode? nextFocusNode; + final TextEditingController? controller; + final OnUpdateListEmailAddressAction? onUpdateListEmailAddressAction; + final OnSuggestionEmailAddress? onSuggestionEmailAddress; + final OnAddEmailAddressTypeAction? onAddEmailAddressTypeAction; + final OnDeleteEmailAddressTypeAction? onDeleteEmailAddressTypeAction; + final OnShowFullListEmailAddressAction? onShowFullListEmailAddressAction; + final OnFocusEmailAddressChangeAction? onFocusEmailAddressChangeAction; + final VoidCallback? onFocusNextAddressAction; + final EdgeInsetsGeometry? padding; + final EdgeInsetsGeometry? margin; + + const RecipientComposerWidget({ + super.key, + required this.prefix, + required this.listEmailAddress, + this.ccState = PrefixRecipientState.disabled, + this.bccState = PrefixRecipientState.disabled, + this.isInitial, + this.controller, + this.focusNode, + this.autoDisposeFocusNode = true, + this.expandMode = ExpandMode.EXPAND, + this.keyTagEditor, + this.nextFocusNode, + this.padding, + this.margin, + this.onUpdateListEmailAddressAction, + this.onSuggestionEmailAddress, + this.onAddEmailAddressTypeAction, + this.onDeleteEmailAddressTypeAction, + this.onShowFullListEmailAddressAction, + this.onFocusEmailAddressChangeAction, + this.onFocusNextAddressAction, + }); + + @override + State createState() => _RecipientComposerWidgetState(); +} + +class _RecipientComposerWidgetState extends State { + + Timer? _gapBetweenTagChangedAndFindSuggestion; + bool _lastTagFocused = false; + late List _currentListEmailAddress; + + final _imagePaths = Get.find(); + + @override + void initState() { + super.initState(); + _currentListEmailAddress = widget.listEmailAddress; + } + + @override + Widget build(BuildContext context) { + return Container( + decoration: const BoxDecoration( + border: Border( + bottom: BorderSide( + color: RecipientComposerWidgetStyle.borderColor, + width: 1 + ) + ) + ), + padding: widget.padding, + margin: widget.margin, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: RecipientComposerWidgetStyle.labelMargin, + child: Text( + '${widget.prefix.asName(context)}:', + style: RecipientComposerWidgetStyle.labelTextStyle + ), + ), + const SizedBox(width: RecipientComposerWidgetStyle.space), + Expanded( + child: FocusScope( + child: Focus( + onFocusChange: (focus) => widget.onFocusEmailAddressChangeAction?.call(widget.prefix, focus), + onKey: (focusNode, event) { + if (event is RawKeyDownEvent && event.logicalKey == LogicalKeyboardKey.tab) { + widget.nextFocusNode?.requestFocus(); + widget.onFocusNextAddressAction?.call(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }, + child: TagEditor( + key: widget.keyTagEditor, + length: _collapsedListEmailAddress.length, + controller: widget.controller, + focusNode: widget.focusNode, + autoDisposeFocusNode: widget.autoDisposeFocusNode, + keyboardType: TextInputType.emailAddress, + textInputAction: TextInputAction.done, + debounceDuration: const Duration(milliseconds: 150), + hasAddButton: false, + tagSpacing: 8, + autofocus: widget.prefix != PrefixEmailAddress.to && _currentListEmailAddress.isEmpty, + minTextFieldWidth: 20, + resetTextOnSubmitted: true, + suggestionsBoxElevation: 20.0, + suggestionsBoxBackgroundColor: Colors.white, + suggestionsBoxRadius: 20, + suggestionsBoxMaxHeight: 350, + textStyle: RecipientComposerWidgetStyle.inputTextStyle, + onFocusTagAction: (focused) { + setState(() { + _lastTagFocused = focused; + }); + }, + onDeleteTagAction: () { + if (_currentListEmailAddress.isNotEmpty) { + setState(_currentListEmailAddress.removeLast); + widget.onUpdateListEmailAddressAction?.call(widget.prefix, _currentListEmailAddress); + } + }, + onSelectOptionAction: (item) { + if (!_isDuplicatedRecipient(item.emailAddress.emailAddress)) { + setState(() => _currentListEmailAddress.add(item.emailAddress)); + widget.onUpdateListEmailAddressAction?.call(widget.prefix, _currentListEmailAddress); + } + }, + onSubmitted: (value) { + final textTrim = value.trim(); + if (!_isDuplicatedRecipient(textTrim)) { + setState(() => _currentListEmailAddress.add(EmailAddress(null, textTrim))); + widget.onUpdateListEmailAddressAction?.call(widget.prefix, _currentListEmailAddress); + } + }, + inputDecoration: const InputDecoration(border: InputBorder.none), + tagBuilder: (context, index) { + final currentEmailAddress = _currentListEmailAddress[index]; + final isLatestEmail = currentEmailAddress == _currentListEmailAddress.last; + + return RecipientTagItemWidget( + prefix: widget.prefix, + currentEmailAddress: currentEmailAddress, + currentListEmailAddress: _currentListEmailAddress, + collapsedListEmailAddress: _collapsedListEmailAddress, + isLatestEmail: isLatestEmail, + isCollapsed: _isCollapse, + isLatestTagFocused: _lastTagFocused, + onDeleteTagAction: () { + setState(() => _currentListEmailAddress.removeAt(index)); + widget.onUpdateListEmailAddressAction?.call(widget.prefix, _currentListEmailAddress); + }, + onShowFullAction: widget.onShowFullListEmailAddressAction, + ); + }, + onTagChanged: (value) { + final textTrim = value.trim(); + if (!_isDuplicatedRecipient(textTrim)) { + setState(() => _currentListEmailAddress.add(EmailAddress(null, textTrim))); + widget.onUpdateListEmailAddressAction?.call(widget.prefix, _currentListEmailAddress); + } + _gapBetweenTagChangedAndFindSuggestion = Timer( + const Duration(seconds: 1), + _handleGapBetweenTagChangedAndFindSuggestion); + }, + findSuggestions: _findSuggestions, + useDefaultHighlight: false, + suggestionBuilder: ( + context, + tagEditorState, + suggestionEmailAddress, + index, + length, + highlight, + suggestionValid + ) { + return RecipientSuggestionItemWidget( + suggestionState: suggestionEmailAddress.state, + emailAddress: suggestionEmailAddress.emailAddress, + suggestionValid: suggestionValid, + highlight: highlight, + onSelectedAction: (emailAddress) { + setState(() => _currentListEmailAddress.add(emailAddress)); + widget.onUpdateListEmailAddressAction?.call(widget.prefix, _currentListEmailAddress); + tagEditorState.resetTextField(); + tagEditorState.closeSuggestionBox(); + }, + ); + }, + ) + ) + ) + ), + const SizedBox(width: RecipientComposerWidgetStyle.space), + if (widget.prefix == PrefixEmailAddress.to && widget.ccState == PrefixRecipientState.disabled) + TMailButtonWidget.fromText( + text: AppLocalizations.of(context).cc_email_address_prefix, + textStyle: RecipientComposerWidgetStyle.prefixButtonTextStyle, + backgroundColor: Colors.transparent, + padding: RecipientComposerWidgetStyle.prefixButtonPadding, + margin: RecipientComposerWidgetStyle.recipientMargin, + onTapActionCallback: () => widget.onAddEmailAddressTypeAction?.call(PrefixEmailAddress.cc), + ), + if (widget.prefix == PrefixEmailAddress.to && widget.bccState == PrefixRecipientState.disabled) + TMailButtonWidget.fromText( + text: AppLocalizations.of(context).bcc_email_address_prefix, + textStyle: RecipientComposerWidgetStyle.prefixButtonTextStyle, + backgroundColor: Colors.transparent, + padding: RecipientComposerWidgetStyle.prefixButtonPadding, + margin: RecipientComposerWidgetStyle.recipientMargin, + onTapActionCallback: () => widget.onAddEmailAddressTypeAction?.call(PrefixEmailAddress.bcc), + ), + if (widget.prefix != PrefixEmailAddress.to) + TMailButtonWidget.fromIcon( + icon: _imagePaths.icClose, + backgroundColor: Colors.transparent, + iconColor: RecipientComposerWidgetStyle.deleteRecipientFieldIconColor, + iconSize: RecipientComposerWidgetStyle.deleteRecipientFieldIconSize, + padding: RecipientComposerWidgetStyle.deleteRecipientFieldIconPadding, + margin: RecipientComposerWidgetStyle.recipientMargin, + onTapActionCallback: () => widget.onDeleteEmailAddressTypeAction?.call(widget.prefix), + ) + ] + ), + ); + } + + bool get _isCollapse => _currentListEmailAddress.length > 1 && widget.expandMode == ExpandMode.COLLAPSE; + + List get _collapsedListEmailAddress => _isCollapse + ? _currentListEmailAddress.sublist(0, 1) + : _currentListEmailAddress; + + FutureOr> _findSuggestions(String query) async { + if (_gapBetweenTagChangedAndFindSuggestion?.isActive ?? false) { + return []; + } + + final processedQuery = query.trim(); + if (processedQuery.isEmpty) { + return []; + } + + final tmailSuggestion = List.empty(growable: true); + if (processedQuery.length >= AppConstants.limitCharToStartSearch && + widget.onSuggestionEmailAddress != null) { + final listEmailAddress = await widget.onSuggestionEmailAddress!(processedQuery); + final listSuggestionEmailAddress = listEmailAddress.map((emailAddress) => _toSuggestionEmailAddress(emailAddress, _currentListEmailAddress)); + tmailSuggestion.addAll(listSuggestionEmailAddress); + } + + tmailSuggestion.addAll(_matchedSuggestionEmailAddress(processedQuery, _currentListEmailAddress)); + + final currentTextOnTextField = widget.controller?.text ?? ''; + if (currentTextOnTextField.isEmpty) { + return []; + } + + return tmailSuggestion.toSet().toList(); + } + + bool _isDuplicatedRecipient(String inputEmail) { + if (inputEmail.isEmpty) { + return false; + } + return _currentListEmailAddress + .map((emailAddress) => emailAddress.email) + .whereNotNull() + .contains(inputEmail); + } + + SuggestionEmailAddress _toSuggestionEmailAddress(EmailAddress item, List addedEmailAddresses) { + if (addedEmailAddresses.contains(item)) { + return SuggestionEmailAddress(item, state: SuggestionEmailState.duplicated); + } else { + return SuggestionEmailAddress(item); + } + } + + Iterable _matchedSuggestionEmailAddress(String query, List addedEmailAddress) { + return addedEmailAddress + .where((addedMail) => addedMail.emailAddress.contains(query)) + .map((emailAddress) => SuggestionEmailAddress( + emailAddress, + state: SuggestionEmailState.duplicated + )); + } + + void _handleGapBetweenTagChangedAndFindSuggestion() { + log('EmailAddressInputBuilder::_handleGapBetweenTagChangedAndFindSuggestion(): Timeout'); + } +} \ No newline at end of file diff --git a/lib/features/composer/presentation/widgets/recipient_suggestion_item_widget.dart b/lib/features/composer/presentation/widgets/recipient_suggestion_item_widget.dart new file mode 100644 index 000000000..9d47fffcc --- /dev/null +++ b/lib/features/composer/presentation/widgets/recipient_suggestion_item_widget.dart @@ -0,0 +1,96 @@ + +import 'package:core/presentation/extensions/color_extension.dart'; +import 'package:core/presentation/resources/image_paths.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:get/get.dart'; +import 'package:jmap_dart_client/jmap/mail/email/email_address.dart'; +import 'package:model/extensions/email_address_extension.dart'; +import 'package:super_tag_editor/widgets/rich_text_widget.dart'; +import 'package:tmail_ui_user/features/composer/presentation/styles/recipient_suggestion_item_widget_style.dart'; +import 'package:tmail_ui_user/features/composer/presentation/widgets/suggestion_email_address.dart'; +import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/widgets/advanced_search/avatar_suggestion_item_widget.dart'; + +typedef OnSelectedRecipientSuggestionAction = Function(EmailAddress emailAddress); + +class RecipientSuggestionItemWidget extends StatelessWidget { + + final SuggestionEmailState suggestionState; + final EmailAddress emailAddress; + final String? suggestionValid; + final bool highlight; + final OnSelectedRecipientSuggestionAction? onSelectedAction; + + final _imagePaths = Get.find(); + + RecipientSuggestionItemWidget({ + super.key, + required this.suggestionState, + required this.emailAddress, + this.suggestionValid, + this.highlight = false, + this.onSelectedAction, + }); + + @override + Widget build(BuildContext context) { + if (suggestionState == SuggestionEmailState.duplicated) { + return Container( + margin: RecipientSuggestionItemWidgetStyle.suggestionDuplicatedMargin, + decoration: const BoxDecoration( + color: AppColor.colorBgMenuItemDropDownSelected, + borderRadius: BorderRadius.all(Radius.circular(RecipientSuggestionItemWidgetStyle.radius)) + ), + child: Material( + type: MaterialType.transparency, + child: ListTile( + contentPadding: RecipientSuggestionItemWidgetStyle.labelDuplicatedPadding, + leading: AvatarSuggestionItemWidget(emailAddress: emailAddress), + title: RichTextWidget( + textOrigin: emailAddress.asString(), + wordSearched: suggestionValid ?? '' + ), + subtitle: emailAddress.emailAddress.isNotEmpty + ? RichTextWidget( + textOrigin: emailAddress.emailAddress, + wordSearched: suggestionValid ?? '', + styleTextOrigin: RecipientSuggestionItemWidgetStyle.labelTextStyle, + styleWordSearched: RecipientSuggestionItemWidgetStyle.labelHighlightTextStyle + ) + : null, + trailing: SvgPicture.asset( + _imagePaths.icFilterSelected, + width: RecipientSuggestionItemWidgetStyle.selectedIconSize, + height: RecipientSuggestionItemWidgetStyle.selectedIconSize, + fit: BoxFit.fill + ), + ), + ) + ); + } else { + return Container( + color: highlight ? AppColor.colorItemSelected : Colors.white, + child: Material( + type: MaterialType.transparency, + child: ListTile( + contentPadding: RecipientSuggestionItemWidgetStyle.labelPadding, + leading: AvatarSuggestionItemWidget(emailAddress: emailAddress), + title: RichTextWidget( + textOrigin: emailAddress.asString(), + wordSearched: suggestionValid ?? '' + ), + subtitle: emailAddress.emailAddress.isNotEmpty + ? RichTextWidget( + textOrigin: emailAddress.emailAddress, + wordSearched: suggestionValid ?? '', + styleTextOrigin: RecipientSuggestionItemWidgetStyle.labelTextStyle, + styleWordSearched: RecipientSuggestionItemWidgetStyle.labelHighlightTextStyle + ) + : null, + onTap: () => onSelectedAction?.call(emailAddress), + ), + ), + ); + } + } +} diff --git a/lib/features/composer/presentation/widgets/recipient_tag_item_widget.dart b/lib/features/composer/presentation/widgets/recipient_tag_item_widget.dart new file mode 100644 index 000000000..ee532c67b --- /dev/null +++ b/lib/features/composer/presentation/widgets/recipient_tag_item_widget.dart @@ -0,0 +1,124 @@ +import 'package:core/presentation/extensions/color_extension.dart'; +import 'package:core/presentation/extensions/string_extension.dart'; +import 'package:core/presentation/resources/image_paths.dart'; +import 'package:core/presentation/utils/style_utils.dart'; +import 'package:core/presentation/views/avatar/gradient_circle_avatar_icon.dart'; +import 'package:core/presentation/views/button/tmail_button_widget.dart'; +import 'package:core/utils/direction_utils.dart'; +import 'package:core/utils/platform_info.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:get/get.dart'; +import 'package:jmap_dart_client/jmap/mail/email/email_address.dart'; +import 'package:model/email/prefix_email_address.dart'; +import 'package:model/extensions/email_address_extension.dart'; +import 'package:tmail_ui_user/features/composer/presentation/styles/recipient_tag_item_widget_style.dart'; +import 'package:tmail_ui_user/features/composer/presentation/widgets/recipient_composer_widget.dart'; + +class RecipientTagItemWidget extends StatelessWidget { + + final bool isCollapsed; + final bool isLatestTagFocused; + final bool isLatestEmail; + final PrefixEmailAddress prefix; + final EmailAddress currentEmailAddress; + final List currentListEmailAddress; + final List collapsedListEmailAddress; + final OnShowFullListEmailAddressAction? onShowFullAction; + final VoidCallback? onDeleteTagAction; + + final _imagePaths = Get.find(); + + RecipientTagItemWidget({ + super.key, + required this.prefix, + required this.currentEmailAddress, + required this.currentListEmailAddress, + required this.collapsedListEmailAddress, + this.isCollapsed = false, + this.isLatestTagFocused = false, + this.isLatestEmail = false, + this.onShowFullAction, + this.onDeleteTagAction, + }); + + @override + Widget build(BuildContext context) { + return Stack( + alignment: AlignmentDirectional.centerEnd, + children: [ + Padding( + padding: EdgeInsetsDirectional.only( + top: PlatformInfo.isWeb ? 8 : 0, + end: isCollapsed ? 40 : 0), + child: InkWell( + onTap: () => isCollapsed + ? onShowFullAction?.call(prefix) + : null, + child: Chip( + labelPadding: EdgeInsetsDirectional.symmetric( + horizontal: 4, + vertical: DirectionUtils.isDirectionRTLByHasAnyRtl(currentEmailAddress.asString()) ? 0 : 2 + ), + label: Text( + currentEmailAddress.asString(), + maxLines: 1, + overflow: CommonTextStyle.defaultTextOverFlow, + softWrap: CommonTextStyle.defaultSoftWrap, + ), + deleteIcon: SvgPicture.asset(_imagePaths.icClose, fit: BoxFit.fill), + labelStyle: RecipientTagItemWidgetStyle.labelTextStyle, + backgroundColor: _getTagBackgroundColor(), + shape: RoundedRectangleBorder( + borderRadius: const BorderRadius.all(Radius.circular(RecipientTagItemWidgetStyle.radius)), + side: _getTagBorderSide(), + ), + avatar: currentEmailAddress.displayName.isNotEmpty + ? GradientCircleAvatarIcon( + colors: currentEmailAddress.avatarColors, + label: currentEmailAddress.displayName.firstLetterToUpperCase, + labelFontSize: 10, + iconSize: 24, + ) + : null, + onDeleted: onDeleteTagAction, + ) + ), + ), + if (isCollapsed) + TMailButtonWidget.fromText( + margin: RecipientTagItemWidgetStyle.counterMargin, + text: '+${currentListEmailAddress.length - collapsedListEmailAddress.length}', + onTapActionCallback: () => onShowFullAction?.call(prefix), + borderRadius: RecipientTagItemWidgetStyle.radius, + textStyle: RecipientTagItemWidgetStyle.labelTextStyle, + padding: RecipientTagItemWidgetStyle.counterPadding, + backgroundColor: AppColor.colorEmailAddressTag, + ) + ] + ); + } + + Color _getTagBackgroundColor() { + if (isLatestTagFocused && isLatestEmail) { + return AppColor.colorItemRecipientSelected; + } else if (GetUtils.isEmail(currentEmailAddress.emailAddress)) { + return AppColor.colorEmailAddressTag; + } else { + return Colors.white; + } + } + + BorderSide _getTagBorderSide() { + if (isLatestTagFocused && isLatestEmail) { + return const BorderSide(width: 1, color: AppColor.primaryColor); + } else if (GetUtils.isEmail(currentEmailAddress.emailAddress)) { + return BorderSide.none; + } else { + return const BorderSide( + width: 1, + color: AppColor.colorBorderEmailAddressInvalid + ); + } + } +} \ No newline at end of file