TF-4013 Collapse all recipients fields when unfocus

(cherry picked from commit cd2c2db33a242d22a06265dca5fa4601753957ab)
This commit is contained in:
dab246
2025-10-06 12:46:53 +07:00
committed by Dat H. Pham
parent 7ef7aae2ce
commit dd1d1f10fd
14 changed files with 705 additions and 369 deletions
@@ -1,25 +1,21 @@
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/theme_utils.dart';
import 'package:core/presentation/views/avatar/gradient_circle_avatar_icon.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:tmail_ui_user/features/composer/presentation/styles/draggable_recipient_tag_widget_style.dart';
class DraggableRecipientTagWidget extends StatelessWidget {
final EmailAddress emailAddress;
final EdgeInsetsGeometry? padding;
final ImagePaths imagePaths;
final _imagePaths = Get.find<ImagePaths>();
DraggableRecipientTagWidget({
const DraggableRecipientTagWidget({
super.key,
required this.emailAddress,
this.padding,
required this.imagePaths,
});
@override
@@ -27,38 +23,54 @@ class DraggableRecipientTagWidget extends StatelessWidget {
return MouseRegion(
cursor: SystemMouseCursors.grab,
child: Container(
decoration: const ShapeDecoration(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(DraggableRecipientTagWidgetStyle.radius)),
),
color: DraggableRecipientTagWidgetStyle.backgroundColor
constraints: const BoxConstraints(maxWidth: 267),
decoration: const BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(10)),
color: AppColor.primaryColor,
),
padding: padding ?? DraggableRecipientTagWidgetStyle.padding,
height: 32,
padding: const EdgeInsetsDirectional.only(start: 8, end: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (emailAddress.emailAddress.isNotEmpty)
GradientCircleAvatarIcon(
colors: emailAddress.avatarColors,
label: emailAddress.emailAddress.firstCharacterToUpperCase,
labelFontSize: DraggableRecipientTagWidgetStyle.avatarLabelFontSize,
iconSize: DraggableRecipientTagWidgetStyle.avatarIconSize,
GradientCircleAvatarIcon(
colors: emailAddress.avatarColors,
label: emailAddress.asString().firstCharacterToUpperCase,
textStyle: ThemeUtils.textStyleInter500().copyWith(
color: Colors.white,
fontSize: 14,
height: 19.2 / 14,
letterSpacing: 0.0,
),
Padding(
padding: DraggableRecipientTagWidgetStyle.labelPadding,
child: DefaultTextStyle(
style: DraggableRecipientTagWidgetStyle.labelTextStyle,
child: Text(emailAddress.asString()),
iconSize: 20,
),
const SizedBox(width: 4),
Flexible(
child: Text(
emailAddress.asString(),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: ThemeUtils.textStyleInter400.copyWith(
color: Colors.white,
fontSize: 17,
height: 1.0,
letterSpacing: -0.17,
),
),
),
SvgPicture.asset(
_imagePaths.icClose,
colorFilter: DraggableRecipientTagWidgetStyle.deleteIconColor.asFilter(),
fit: BoxFit.fill
const SizedBox(width: 4),
Padding(
padding: const EdgeInsets.all(4),
child: SvgPicture.asset(
imagePaths.icClose,
width: 20,
height: 20,
colorFilter: AppColor.m3SysOutline.asFilter(),
),
)
],
),
),
);
}
}
}
@@ -0,0 +1,103 @@
import 'package:core/presentation/extensions/color_extension.dart';
import 'package:flutter/material.dart';
import 'package:jmap_dart_client/jmap/mail/email/email_address.dart';
import 'package:model/extensions/email_address_extension.dart';
import 'package:tmail_ui_user/features/composer/presentation/styles/recipient_composer_widget_style.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_collapsed_item_widget.dart';
class RecipientsCollapsedComposerWidget extends StatelessWidget {
final List<EmailAddress> listEmailAddress;
final VoidCallback onShowAllRecipientsAction;
final EdgeInsetsGeometry? margin;
const RecipientsCollapsedComposerWidget({
super.key,
required this.listEmailAddress,
required this.onShowAllRecipientsAction,
this.margin,
});
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
double maxWidth = constraints.maxWidth;
double usedWidth = 0;
const double spacing = 8.0;
List<Widget> visibleChips = [];
int hiddenCount = 0;
for (var emailAddress in listEmailAddress) {
final textWidth =
_estimateTextWidth(context, emailAddress.asString()) + 80;
if (usedWidth + textWidth > maxWidth - 60) {
hiddenCount = listEmailAddress.length - visibleChips.length;
break;
}
usedWidth += textWidth + spacing;
visibleChips.add(
Padding(
padding: const EdgeInsetsDirectional.only(end: spacing),
child: RecipientCollapsedItemWidget(emailAddress: emailAddress),
),
);
}
if (hiddenCount > 0) {
final label = hiddenCount > 999 ? '999+' : '+$hiddenCount';
visibleChips.add(
Container(
padding: const EdgeInsets.symmetric(horizontal: 8),
decoration: const BoxDecoration(
borderRadius: BorderRadius.all(
Radius.circular(RecipientTagItemWidgetStyle.radius),
),
color: AppColor.grayBackgroundColor,
),
height: 32,
alignment: Alignment.center,
child: Text(
label,
style: RecipientTagItemWidgetStyle.labelTextStyle,
),
),
);
}
return MouseRegion(
cursor: SystemMouseCursors.text,
child: GestureDetector(
onTap: onShowAllRecipientsAction,
child: Container(
margin: margin,
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: const BoxDecoration(
border: Border(
bottom: BorderSide(
color: RecipientComposerWidgetStyle.borderColor,
width: 1,
),
),
),
child: Row(children: visibleChips),
),
),
);
},
);
}
double _estimateTextWidth(BuildContext context, String text) {
final textPainter = TextPainter(
text: TextSpan(
text: text,
style: DefaultTextStyle.of(context).style,
),
maxLines: 1,
textDirection: TextDirection.ltr,
)..layout();
return textPainter.width;
}
}
@@ -0,0 +1,75 @@
import 'package:core/presentation/extensions/color_extension.dart';
import 'package:core/presentation/extensions/string_extension.dart';
import 'package:core/presentation/views/avatar/gradient_circle_avatar_icon.dart';
import 'package:flutter/material.dart';
import 'package:jmap_dart_client/jmap/mail/email/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/email/presentation/utils/email_utils.dart';
class RecipientCollapsedItemWidget extends StatelessWidget {
final EmailAddress emailAddress;
const RecipientCollapsedItemWidget({
super.key,
required this.emailAddress,
});
@override
Widget build(BuildContext context) {
return Container(
constraints: const BoxConstraints(maxWidth: 267),
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(
Radius.circular(RecipientTagItemWidgetStyle.radius),
),
border: _getTagBorder(),
color: _getTagBackgroundColor(),
),
height: 32,
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
GradientCircleAvatarIcon(
colors: emailAddress.avatarColors,
label: emailAddress.asString().firstCharacterToUpperCase,
textStyle: RecipientTagItemWidgetStyle.avatarTextStyle,
iconSize: RecipientTagItemWidgetStyle.avatarIconSize,
),
const SizedBox(width: 4),
Flexible(
child: Text(
emailAddress.asString(),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: RecipientTagItemWidgetStyle.labelTextStyle,
),
),
],
),
);
}
Color _getTagBackgroundColor() {
if (EmailUtils.isEmailAddressValid(emailAddress.emailAddress)) {
return AppColor.grayBackgroundColor;
} else {
return Colors.white;
}
}
Border _getTagBorder() {
if (EmailUtils.isEmailAddressValid(emailAddress.emailAddress)) {
return Border.all(
width: 1,
color: AppColor.grayBackgroundColor,
);
} else {
return Border.all(
width: 1,
color: AppColor.colorBorderEmailAddressInvalid,
);
}
}
}
@@ -56,9 +56,11 @@ class RecipientComposerWidget extends StatefulWidget {
final double maxWidth;
final ExpandMode expandMode;
final PrefixRecipientState fromState;
final PrefixRecipientState toState;
final PrefixRecipientState ccState;
final PrefixRecipientState bccState;
final PrefixRecipientState replyToState;
final PrefixEmailAddress prefixRootState;
final bool? isInitial;
final FocusNode? focusNode;
final FocusNode? focusNodeKeyboard;
@@ -90,10 +92,12 @@ class RecipientComposerWidget extends StatefulWidget {
required this.maxWidth,
this.minInputLengthAutocomplete = AppConfig.defaultMinInputLengthAutocomplete,
@visibleForTesting this.isTestingForWeb = false,
this.toState = PrefixRecipientState.disabled,
this.ccState = PrefixRecipientState.disabled,
this.bccState = PrefixRecipientState.disabled,
this.replyToState = PrefixRecipientState.disabled,
this.fromState = PrefixRecipientState.disabled,
this.prefixRootState = PrefixEmailAddress.to,
this.isInitial,
this.controller,
this.focusNode,
@@ -360,7 +364,7 @@ class _RecipientComposerWidgetState extends State<RecipientComposerWidget> {
)
),
const SizedBox(width: RecipientComposerWidgetStyle.space),
if (widget.prefix == PrefixEmailAddress.to)
if (widget.prefix == widget.prefixRootState)
if (PlatformInfo.isWeb || widget.isTestingForWeb)
...[
if (widget.fromState == PrefixRecipientState.disabled)
@@ -373,6 +377,16 @@ class _RecipientComposerWidgetState extends State<RecipientComposerWidget> {
margin: RecipientComposerWidgetStyle.recipientMargin,
onTapActionCallback: () => widget.onAddEmailAddressTypeAction?.call(PrefixEmailAddress.from),
),
if (widget.toState == PrefixRecipientState.disabled)
TMailButtonWidget.fromText(
key: Key('prefix_${widget.prefix.name}_recipient_to_button'),
text: AppLocalizations.of(context).to_email_address_prefix,
textStyle: RecipientComposerWidgetStyle.prefixButtonTextStyle,
backgroundColor: Colors.transparent,
padding: RecipientComposerWidgetStyle.prefixButtonPadding,
margin: RecipientComposerWidgetStyle.recipientMargin,
onTapActionCallback: () => widget.onAddEmailAddressTypeAction?.call(PrefixEmailAddress.to),
),
if (widget.ccState == PrefixRecipientState.disabled)
TMailButtonWidget.fromText(
key: Key('prefix_${widget.prefix.name}_recipient_cc_button'),
@@ -404,7 +418,7 @@ class _RecipientComposerWidgetState extends State<RecipientComposerWidget> {
onTapActionCallback: () => widget.onAddEmailAddressTypeAction?.call(PrefixEmailAddress.replyTo),
),
]
else if (PlatformInfo.isMobile)
else
TMailButtonWidget.fromIcon(
key: Key('prefix_${widget.prefix.name}_recipient_expand_button'),
icon: _isAllRecipientInputEnabled
@@ -3,10 +3,8 @@ import 'package:core/presentation/extensions/string_extension.dart';
import 'package:core/presentation/resources/image_paths.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: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';
@@ -95,40 +93,51 @@ class RecipientTagItemWidget extends StatelessWidget {
onCloseAction: onClose,
),
onClearFocusAction: onClearFocusAction,
child: Chip(
labelPadding: EdgeInsetsDirectional.symmetric(
horizontal: 4,
vertical: DirectionUtils.isDirectionRTLByHasAnyRtl(currentEmailAddress.asString()) ? 0 : 2
child: Container(
constraints: const BoxConstraints(maxWidth: 267),
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(
Radius.circular(RecipientTagItemWidgetStyle.radius),
),
border: _getTagBorder(),
color: _getTagBackgroundColor(),
),
padding: EdgeInsets.zero,
label: Text(
key: Key('label_recipient_tag_item_${prefix.name}_$index'),
currentEmailAddress.asString(),
maxLines: 1,
overflow: TextOverflow.ellipsis,
softWrap: true,
),
deleteIcon: SvgPicture.asset(
imagePaths.icClose,
key: Key('delete_icon_recipient_tag_item_${prefix.name}_$index'),
fit: BoxFit.fill
),
labelStyle: RecipientTagItemWidgetStyle.labelTextStyle,
backgroundColor: _getTagBackgroundColor(),
side: _getTagBorderSide(),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(RecipientTagItemWidgetStyle.radius)),
),
avatar: currentEmailAddress.displayName.isNotEmpty
? GradientCircleAvatarIcon(
height: 32,
padding: const EdgeInsetsDirectional.only(start: 8, end: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
GradientCircleAvatarIcon(
key: Key('avatar_icon_recipient_tag_item_${prefix.name}_$index'),
colors: currentEmailAddress.avatarColors,
label: currentEmailAddress.displayName.firstLetterToUpperCase,
labelFontSize: RecipientTagItemWidgetStyle.avatarLabelFontSize,
label: currentEmailAddress.asString().firstCharacterToUpperCase,
textStyle: RecipientTagItemWidgetStyle.avatarTextStyle,
iconSize: RecipientTagItemWidgetStyle.avatarIconSize,
),
const SizedBox(width: 4),
Flexible(
child: Text(
key: Key('label_recipient_tag_item_${prefix.name}_$index'),
currentEmailAddress.asString(),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: RecipientTagItemWidgetStyle.labelTextStyle,
),
),
const SizedBox(width: 4),
TMailButtonWidget.fromIcon(
key: Key('delete_icon_recipient_tag_item_${prefix.name}_$index'),
icon: imagePaths.icClose,
iconSize: 20,
iconColor: AppColor.m3SysOutline,
padding: const EdgeInsets.all(4),
borderRadius: 100,
backgroundColor: Colors.transparent,
onTapActionCallback: () =>
onDeleteTagAction?.call(currentEmailAddress),
)
: null,
onDeleted: () => onDeleteTagAction?.call(currentEmailAddress),
],
),
),
);
@@ -139,12 +148,15 @@ class RecipientTagItemWidget extends StatelessWidget {
filterField: prefix.filterField,
composerId: composerId,
),
feedback: DraggableRecipientTagWidget(emailAddress: currentEmailAddress),
childWhenDragging: DraggableRecipientTagWidget(emailAddress: currentEmailAddress),
child: MouseRegion(
cursor: SystemMouseCursors.grab,
child: tagWidget,
feedback: DraggableRecipientTagWidget(
imagePaths: imagePaths,
emailAddress: currentEmailAddress,
),
childWhenDragging: DraggableRecipientTagWidget(
imagePaths: imagePaths,
emailAddress: currentEmailAddress,
),
child: tagWidget,
);
}
@@ -202,15 +214,21 @@ class RecipientTagItemWidget extends StatelessWidget {
}
}
BorderSide _getTagBorderSide() {
Border _getTagBorder() {
if (isTagFocused) {
return const BorderSide(width: 1, color: AppColor.primaryColor);
} else if (EmailUtils.isEmailAddressValid(currentEmailAddress.emailAddress)) {
return const BorderSide(width: 1, color: AppColor.colorEmailAddressTag);
} else {
return const BorderSide(
return Border.all(
width: 1,
color: AppColor.colorBorderEmailAddressInvalid
color: AppColor.primaryColor,
);
} else if (EmailUtils.isEmailAddressValid(currentEmailAddress.emailAddress)) {
return Border.all(
width: 1,
color: AppColor.grayBackgroundColor,
);
} else {
return Border.all(
width: 1,
color: AppColor.colorBorderEmailAddressInvalid,
);
}
}