TF-2116 Create RecipientComposer widget and style

(cherry picked from commit 24be032c8c04e9673953c9dc83d096e7b61fdb04)
This commit is contained in:
dab246
2023-09-06 11:46:38 +07:00
committed by Dat H. Pham
parent b5326ec186
commit f84f2ce4fd
7 changed files with 623 additions and 526 deletions
@@ -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
);
}
@@ -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
);
}
@@ -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
);
}
@@ -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<List<EmailAddress>> Function(String word);
typedef OnUpdateListEmailAddressAction = void Function(PrefixEmailAddress, List<EmailAddress>);
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<PrefixEmailAddress> _listEmailAddressType;
final TextEditingController? controller;
final bool? isInitial;
final FocusNode? focusNode;
final bool autoDisposeFocusNode;
final GlobalKey? keyTagEditor;
final FocusNode? nextFocusNode;
List<EmailAddress> listEmailAddress = <EmailAddress>[];
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<SuggestionEmailAddress>(
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<SuggestionEmailAddress> 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<List<SuggestionEmailAddress>> _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<SuggestionEmailAddress>.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<EmailAddress> addedEmailAddresses) {
if (addedEmailAddresses.contains(item)) {
return SuggestionEmailAddress(item, state: SuggestionEmailState.duplicated);
} else {
return SuggestionEmailAddress(item);
}
}
Iterable<SuggestionEmailAddress> _matchedSuggestionEmailAddress(String query, List<EmailAddress> 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);
}
}
}
@@ -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<List<EmailAddress>> Function(String word);
typedef OnUpdateListEmailAddressAction = void Function(PrefixEmailAddress prefix, List<EmailAddress> 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<EmailAddress> 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<RecipientComposerWidget> createState() => _RecipientComposerWidgetState();
}
class _RecipientComposerWidgetState extends State<RecipientComposerWidget> {
Timer? _gapBetweenTagChangedAndFindSuggestion;
bool _lastTagFocused = false;
late List<EmailAddress> _currentListEmailAddress;
final _imagePaths = Get.find<ImagePaths>();
@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<SuggestionEmailAddress>(
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<EmailAddress> get _collapsedListEmailAddress => _isCollapse
? _currentListEmailAddress.sublist(0, 1)
: _currentListEmailAddress;
FutureOr<List<SuggestionEmailAddress>> _findSuggestions(String query) async {
if (_gapBetweenTagChangedAndFindSuggestion?.isActive ?? false) {
return [];
}
final processedQuery = query.trim();
if (processedQuery.isEmpty) {
return [];
}
final tmailSuggestion = List<SuggestionEmailAddress>.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<EmailAddress> addedEmailAddresses) {
if (addedEmailAddresses.contains(item)) {
return SuggestionEmailAddress(item, state: SuggestionEmailState.duplicated);
} else {
return SuggestionEmailAddress(item);
}
}
Iterable<SuggestionEmailAddress> _matchedSuggestionEmailAddress(String query, List<EmailAddress> addedEmailAddress) {
return addedEmailAddress
.where((addedMail) => addedMail.emailAddress.contains(query))
.map((emailAddress) => SuggestionEmailAddress(
emailAddress,
state: SuggestionEmailState.duplicated
));
}
void _handleGapBetweenTagChangedAndFindSuggestion() {
log('EmailAddressInputBuilder::_handleGapBetweenTagChangedAndFindSuggestion(): Timeout');
}
}
@@ -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<ImagePaths>();
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),
),
),
);
}
}
}
@@ -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<EmailAddress> currentListEmailAddress;
final List<EmailAddress> collapsedListEmailAddress;
final OnShowFullListEmailAddressAction? onShowFullAction;
final VoidCallback? onDeleteTagAction;
final _imagePaths = Get.find<ImagePaths>();
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
);
}
}
}