diff --git a/lib/features/mailbox_dashboard/presentation/model/search/suggesstion_email_address.dart b/lib/features/mailbox_dashboard/presentation/model/search/suggesstion_email_address.dart new file mode 100644 index 000000000..c46348b61 --- /dev/null +++ b/lib/features/mailbox_dashboard/presentation/model/search/suggesstion_email_address.dart @@ -0,0 +1,17 @@ +import 'package:equatable/equatable.dart'; +import 'package:jmap_dart_client/jmap/mail/email/email_address.dart'; + +class SuggestionEmailAddress with EquatableMixin { + final EmailAddress emailAddress; + final SuggestionEmailState state; + + SuggestionEmailAddress(this.emailAddress, {this.state = SuggestionEmailState.valid}); + + @override + List get props => [emailAddress, state]; +} + +enum SuggestionEmailState { + valid, + duplicated, +} \ No newline at end of file diff --git a/lib/features/mailbox_dashboard/presentation/styles/text_field_autocomplete_email_address_web_style.dart b/lib/features/mailbox_dashboard/presentation/styles/text_field_autocomplete_email_address_web_style.dart new file mode 100644 index 000000000..6cf3ea129 --- /dev/null +++ b/lib/features/mailbox_dashboard/presentation/styles/text_field_autocomplete_email_address_web_style.dart @@ -0,0 +1,46 @@ +import 'package:core/presentation/extensions/color_extension.dart'; +import 'package:flutter/material.dart'; + +class TextFieldAutoCompleteEmailAddressWebStyles { + static const EdgeInsetsGeometry padding = EdgeInsets.symmetric(vertical: 8); + static const EdgeInsetsGeometry textInputContentPadding = EdgeInsets.symmetric(vertical: 16); + static const EdgeInsets tagEditorPadding = EdgeInsets.symmetric(horizontal: 8); + + static const double borderRadius = 10.0; + static const double suggestionBoxRadius = 20.0; + + static const double borderWidth = 2.0; + static const double minTextFieldWidth = 20.0; + + static const double suggestionBoxElevation = 20.0; + static const double suggestionBoxMaxHeight = 350.0; + + static const double fieldTitleWidth = 112.0; + static const double space = 8.0; + + static const InputBorder textInputBorder = OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(10)), + borderSide: BorderSide.none, + ); + + static const Color textInputFillColor = Colors.white; + static const Color focusedBorderColor = AppColor.primaryColor; + static const Color enableBorderColor = AppColor.colorInputBorderCreateMailbox; + static const Color suggestionBoxBackgroundColor = Colors.white; + + static const TextStyle fieldTitleTextStyle = TextStyle( + color: AppColor.colorContentEmail, + fontSize: 14, + ); + static const TextStyle textInputHintStyle = TextStyle( + fontSize: 16, + color: AppColor.colorHintSearchBar, + ); + static const TextStyle textInputTextStyle = TextStyle( + fontSize: 16, + color: Colors.black, + fontWeight: FontWeight.w500, + ); + + static const Duration debounceDuration = Duration(milliseconds: 150); +} \ No newline at end of file diff --git a/lib/features/mailbox_dashboard/presentation/widgets/advanced_search/text_field_autocomplete_email_address_web.dart b/lib/features/mailbox_dashboard/presentation/widgets/advanced_search/text_field_autocomplete_email_address_web.dart new file mode 100644 index 000000000..fcf058c9d --- /dev/null +++ b/lib/features/mailbox_dashboard/presentation/widgets/advanced_search/text_field_autocomplete_email_address_web.dart @@ -0,0 +1,349 @@ +import 'dart:async'; +import 'dart:math'; +import 'package:collection/collection.dart'; +import 'package:core/presentation/extensions/color_extension.dart'; +import 'package:core/presentation/utils/responsive_utils.dart'; +import 'package:core/utils/app_logger.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:jmap_dart_client/jmap/mail/email/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/mailbox_dashboard/presentation/model/search/advanced_search_filter.dart'; +import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/model/search/suggesstion_email_address.dart'; +import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/styles/text_field_autocomplete_email_address_web_style.dart'; +import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/widgets/advanced_search/autocomplete_suggestion_item_widget_web.dart'; +import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/widgets/advanced_search/autocomplete_tag_item_widget_web.dart'; +import 'package:tmail_ui_user/main/utils/app_constants.dart'; + +typedef OnSuggestionEmailAddress = Future> Function(String word); +typedef OnUpdateListEmailAddressAction = void Function(AdvancedSearchFilterField field, List newData); +typedef OnDeleteEmailAddressTypeAction = void Function(AdvancedSearchFilterField field); +typedef OnShowFullListEmailAddressAction = void Function(AdvancedSearchFilterField field); +typedef OnFocusEmailAddressChangeAction = void Function(AdvancedSearchFilterField field, bool isFocus); +typedef OnRemoveEmailAddressAction = void Function(EmailAddress emailAddress, AdvancedSearchFilterField field); +typedef OnDeleteTagAction = void Function(EmailAddress emailAddress); + +class TextFieldAutocompleteEmailAddressWeb extends StatefulWidget { + + final AdvancedSearchFilterField field; + final List listEmailAddress; + final ExpandMode expandMode; + final FocusNode? focusNode; + final bool autoDisposeFocusNode; + final GlobalKey? keyTagEditor; + final FocusNode? nextFocusNode; + final EdgeInsetsGeometry? padding; + final EdgeInsetsGeometry? margin; + final OnSuggestionEmailAddress? onSuggestionEmailAddress; + final OnDeleteTagAction? onDeleteTag; + final OnUpdateListEmailAddressAction? onUpdateListEmailAddressAction; + final OnDeleteEmailAddressTypeAction? onDeleteEmailAddressTypeAction; + final OnShowFullListEmailAddressAction? onShowFullListEmailAddressAction; + final OnFocusEmailAddressChangeAction? onFocusEmailAddressChangeAction; + final OnRemoveEmailAddressAction? onRemoveEmailAddressAction; + final VoidCallback? onFocusNextAddressAction; + final TextEditingController? controller; + final TapRegionCallback? onTapOutside; + + const TextFieldAutocompleteEmailAddressWeb({ + Key? key, + required this.field, + required this.listEmailAddress, + this.expandMode = ExpandMode.EXPAND, + this.focusNode, + this.autoDisposeFocusNode = true, + this.keyTagEditor, + this.nextFocusNode, + this.padding, + this.margin, + this.onSuggestionEmailAddress, + this.onDeleteTag, + this.onUpdateListEmailAddressAction, + this.onDeleteEmailAddressTypeAction, + this.onShowFullListEmailAddressAction, + this.onFocusEmailAddressChangeAction, + this.onRemoveEmailAddressAction, + this.onFocusNextAddressAction, + this.controller, + this.onTapOutside, + }) : super(key: key); + + @override + State createState() => _TextFieldAutocompleteEmailAddressWebState(); +} + +class _TextFieldAutocompleteEmailAddressWebState extends State { + bool _lastTagFocused = false; + late List _currentListEmailAddress; + Timer? _gapBetweenTagChangedAndFindSuggestion; + + @override + void initState() { + super.initState(); + _currentListEmailAddress = widget.listEmailAddress; + } + + @override + void didUpdateWidget(covariant TextFieldAutocompleteEmailAddressWeb oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.listEmailAddress != oldWidget.listEmailAddress) { + _currentListEmailAddress = widget.listEmailAddress; + } + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: TextFieldAutoCompleteEmailAddressWebStyles.padding, + child: LayoutBuilder( + builder: ((context, constraints) { + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SizedBox( + width: TextFieldAutoCompleteEmailAddressWebStyles.fieldTitleWidth, + child: Text( + widget.field.getTitle(context), + style: TextFieldAutoCompleteEmailAddressWebStyles.fieldTitleTextStyle, + ), + ), + const SizedBox(width: TextFieldAutoCompleteEmailAddressWebStyles.space), + Expanded( + child: FocusScope( + child: Focus( + onFocusChange: (focus) { + widget.onFocusEmailAddressChangeAction?.call(widget.field, focus); + }, + onKey: (focusNode, event) { + if (event.logicalKey == LogicalKeyboardKey.tab) { + widget.nextFocusNode?.requestFocus(); + widget.onFocusNextAddressAction?.call(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }, + child: StatefulBuilder( + builder: ((context, setState) { + return TagEditor( + key: widget.keyTagEditor, + length: _collapsedListEmailAddress.length, + controller: widget.controller, + focusNode: widget.focusNode, + autoDisposeFocusNode: widget.autoDisposeFocusNode, + keyboardType: TextInputType.emailAddress, + textInputAction: TextInputAction.done, + debounceDuration: TextFieldAutoCompleteEmailAddressWebStyles.debounceDuration, + hasAddButton: false, + autofocus: widget.field == AdvancedSearchFilterField.form && _currentListEmailAddress.isEmpty, + inputDecoration: InputDecoration( + filled: true, + fillColor: TextFieldAutoCompleteEmailAddressWebStyles.textInputFillColor, + border: TextFieldAutoCompleteEmailAddressWebStyles.textInputBorder, + hintText: widget.field.getHintText(context), + hintStyle: TextFieldAutoCompleteEmailAddressWebStyles.textInputHintStyle, + isDense: true, + contentPadding: TextFieldAutoCompleteEmailAddressWebStyles.textInputContentPadding, + ), + padding: TextFieldAutoCompleteEmailAddressWebStyles.tagEditorPadding, + borderRadius: TextFieldAutoCompleteEmailAddressWebStyles.borderRadius, + borderSize: TextFieldAutoCompleteEmailAddressWebStyles.borderWidth, + focusedBorderColor: TextFieldAutoCompleteEmailAddressWebStyles.focusedBorderColor, + enableBorder: true, + enableBorderColor: AppColor.colorInputBorderCreateMailbox, + minTextFieldWidth: TextFieldAutoCompleteEmailAddressWebStyles.minTextFieldWidth, + resetTextOnSubmitted: true, + suggestionsBoxElevation: TextFieldAutoCompleteEmailAddressWebStyles.suggestionBoxElevation, + suggestionsBoxBackgroundColor: TextFieldAutoCompleteEmailAddressWebStyles.suggestionBoxBackgroundColor, + suggestionsBoxRadius: TextFieldAutoCompleteEmailAddressWebStyles.suggestionBoxRadius, + suggestionsBoxMaxHeight: TextFieldAutoCompleteEmailAddressWebStyles.suggestionBoxMaxHeight, + suggestionBoxWidth: _getSuggestionBoxWidth(constraints.maxWidth), + textStyle: TextFieldAutoCompleteEmailAddressWebStyles.textInputTextStyle, + onFocusTagAction: (focused) => _handleFocusTagAction.call(focused, setState), + onDeleteTagAction: () => _handleDeleteLatestTagAction.call(setState), + onSelectOptionAction: (item) => _handleSelectOptionAction.call(item, setState), + onSubmitted: (value) => _handleSubmitTagAction.call(value, setState), + onTapOutside: widget.onTapOutside, + tagBuilder: (context, index) { + final currentEmailAddress = _currentListEmailAddress.elementAt(index); + final isLatestEmail = currentEmailAddress == _currentListEmailAddress.last; + return AutoCompleteTagItemWidgetWeb( + field: widget.field, + currentEmailAddress: currentEmailAddress, + currentListEmailAddress: _currentListEmailAddress, + collapsedListEmailAddress: _collapsedListEmailAddress, + isLatestEmail: isLatestEmail, + isCollapsed: _isCollapse, + isLatestTagFocused: _lastTagFocused, + onDeleteTagAction: (emailAddress) => _handleDeleteTagAction.call(emailAddress, setState), + onShowFullAction: widget.onShowFullListEmailAddressAction, + ); + }, + onTagChanged: (tag) => _handleOnTagChangeAction.call(tag, setState), + findSuggestions: _findSuggestions, + useDefaultHighlight: true, + suggestionBuilder: (context, tagEditorState, suggestionEmailAddress, index, length, highlight, suggestionValid) { + return AutoCompleteSuggestionItemWidgetWeb( + suggestionState: suggestionEmailAddress.state, + emailAddress: suggestionEmailAddress.emailAddress, + suggestionValid: suggestionValid, + highlight: highlight, + onSelectedAction: (emailAddress) { + setState(() => _currentListEmailAddress.add(emailAddress)); + _updateListEmailAddressAction(); + tagEditorState.resetTextField(); + tagEditorState.closeSuggestionBox(); + }, + ); + }, + ); + }) + ), + ) + ), + ), + ], + ); + }) + ), + ); + } + + 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 _isDuplicated(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('_TextFieldAutocompleteEmailAddressWebState::_handleGapBetweenTagChangedAndFindSuggestion:Timeout'); + } + + void _updateListEmailAddressAction() { + widget.onUpdateListEmailAddressAction?.call( + widget.field, + _currentListEmailAddress + ); + } + + void _handleFocusTagAction(bool focused, StateSetter stateSetter) { + if (mounted) { + stateSetter(() => _lastTagFocused = focused); + } + } + + void _handleDeleteLatestTagAction(StateSetter stateSetter) { + if (_currentListEmailAddress.isNotEmpty) { + stateSetter(_currentListEmailAddress.removeLast); + _updateListEmailAddressAction(); + } + } + + void _handleDeleteTagAction(EmailAddress emailAddress, StateSetter stateSetter) { + if (_currentListEmailAddress.isNotEmpty) { + stateSetter(() => _currentListEmailAddress.remove(emailAddress)); + _updateListEmailAddressAction(); + } + } + + void _handleSelectOptionAction( + SuggestionEmailAddress suggestionEmailAddress, + StateSetter stateSetter + ) { + if (!_isDuplicated(suggestionEmailAddress.emailAddress.emailAddress)) { + stateSetter(() => _currentListEmailAddress.add(suggestionEmailAddress.emailAddress)); + _updateListEmailAddressAction(); + } + } + + void _handleSubmitTagAction( + String value, + StateSetter stateSetter + ) { + final textTrim = value.trim(); + if (!_isDuplicated(textTrim)) { + stateSetter(() => _currentListEmailAddress.add(EmailAddress(null, textTrim))); + _updateListEmailAddressAction(); + } + } + + void _handleOnTagChangeAction( + String value, + StateSetter stateSetter + ) { + final textTrim = value.trim(); + if (!_isDuplicated(textTrim)) { + stateSetter(() => _currentListEmailAddress.add(EmailAddress(null, textTrim))); + _updateListEmailAddressAction(); + } + _gapBetweenTagChangedAndFindSuggestion = Timer( + const Duration(seconds: 1), + _handleGapBetweenTagChangedAndFindSuggestion + ); + } + + double? _getSuggestionBoxWidth(double maxWidth) { + if (maxWidth < ResponsiveUtils.minTabletWidth) { + final newWidth = min(maxWidth, 300.0); + return newWidth; + } else { + return null; + } + } +} \ No newline at end of file