TF-4013 Unified UX between mobile and web

(cherry picked from commit 298afd1871aedc3549191b16796241aec9b4552d)
This commit is contained in:
dab246
2025-10-07 13:52:03 +07:00
committed by Dat H. Pham
parent 69d2e59f35
commit bfdd6af3f7
11 changed files with 335 additions and 506 deletions
@@ -29,20 +29,31 @@ class RecipientsCollapsedComposerWidget extends StatelessWidget {
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;
if (listEmailAddress.length == 1) {
visibleChips.add(
Padding(
padding: const EdgeInsetsDirectional.only(end: spacing),
child: RecipientCollapsedItemWidget(emailAddress: emailAddress),
child: RecipientCollapsedItemWidget(
emailAddress: listEmailAddress.first,
),
),
);
} else {
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) {
@@ -16,12 +16,11 @@ 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/list_address_extension.dart';
import 'package:tmail_ui_user/features/composer/presentation/extensions/list_named_address_extension.dart';
import 'package:tmail_ui_user/features/composer/presentation/extensions/prefix_email_address_extension.dart';
import 'package:tmail_ui_user/features/composer/presentation/extensions/mail_address_extension.dart';
import 'package:tmail_ui_user/features/composer/presentation/extensions/prefix_email_address_extension.dart';
import 'package:tmail_ui_user/features/composer/presentation/model/draggable_email_address.dart';
import 'package:tmail_ui_user/features/composer/presentation/model/email_address_action_type.dart';
import 'package:tmail_ui_user/features/composer/presentation/model/prefix_recipient_state.dart';
@@ -54,7 +53,6 @@ class RecipientComposerWidget extends StatefulWidget {
final List<EmailAddress> listEmailAddress;
final ImagePaths imagePaths;
final double maxWidth;
final ExpandMode expandMode;
final PrefixRecipientState fromState;
final PrefixRecipientState toState;
final PrefixRecipientState ccState;
@@ -71,7 +69,6 @@ class RecipientComposerWidget extends StatefulWidget {
final OnSuggestionEmailAddress? onSuggestionEmailAddress;
final OnAddEmailAddressTypeAction? onAddEmailAddressTypeAction;
final OnDeleteEmailAddressTypeAction? onDeleteEmailAddressTypeAction;
final OnShowFullListEmailAddressAction? onShowFullListEmailAddressAction;
final OnFocusEmailAddressChangeAction? onFocusEmailAddressChangeAction;
final OnRemoveDraggableEmailAddressAction? onRemoveDraggableEmailAddressAction;
final OnEditRecipientAction? onEditRecipientAction;
@@ -101,7 +98,6 @@ class RecipientComposerWidget extends StatefulWidget {
this.isInitial,
this.controller,
this.focusNode,
this.expandMode = ExpandMode.EXPAND,
this.keyTagEditor,
this.nextFocusNode,
this.padding,
@@ -111,7 +107,6 @@ class RecipientComposerWidget extends StatefulWidget {
this.onSuggestionEmailAddress,
this.onAddEmailAddressTypeAction,
this.onDeleteEmailAddressTypeAction,
this.onShowFullListEmailAddressAction,
this.onFocusEmailAddressChangeAction,
this.onFocusNextAddressAction,
this.onRemoveDraggableEmailAddressAction,
@@ -150,6 +145,8 @@ class _RecipientComposerWidgetState extends State<RecipientComposerWidget> {
@override
Widget build(BuildContext context) {
bool isMobileResponsive = _responsiveUtils.isMobile(context);
return Container(
decoration: const BoxDecoration(
border: Border(
@@ -226,9 +223,7 @@ class _RecipientComposerWidgetState extends State<RecipientComposerWidget> {
onSubmitted: (value) =>
_handleSubmitTagAction.call(value, stateSetter),
onTapOutside: (_) {},
onFocusTextInput: () {
widget.onShowFullListEmailAddressAction?.call(widget.prefix);
},
onFocusTextInput: () {},
inputDecoration: const InputDecoration(border: InputBorder.none),
tagBuilder: (context, index) {
final currentEmailAddress = _currentListEmailAddress[index];
@@ -240,13 +235,10 @@ class _RecipientComposerWidgetState extends State<RecipientComposerWidget> {
composerId: widget.composerId,
currentEmailAddress: currentEmailAddress,
currentListEmailAddress: _currentListEmailAddress,
collapsedListEmailAddress: _collapsedListEmailAddress,
isCollapsed: _isCollapse,
isTagFocused: _tagIndexFocused == index,
maxWidth: widget.maxWidth,
isMobile: _responsiveUtils.isMobile(context),
isMobile: isMobileResponsive,
onDeleteTagAction: (emailAddress) => _handleDeleteTagAction.call(emailAddress, stateSetter),
onShowFullAction: widget.onShowFullListEmailAddressAction,
onEditRecipientAction: widget.onEditRecipientAction,
onClearFocusAction: widget.onClearFocusAction,
);
@@ -311,75 +303,11 @@ class _RecipientComposerWidgetState extends State<RecipientComposerWidget> {
)
)
),
const SizedBox(width: RecipientComposerWidgetStyle.space),
if (widget.prefix == widget.prefixRootState)
if (PlatformInfo.isWeb || widget.isTestingForWeb)
...[
if (widget.fromState == PrefixRecipientState.disabled)
TMailButtonWidget.fromText(
key: Key('prefix_${widget.prefix.name}_recipient_from_button'),
text: AppLocalizations.of(context).from_email_address_prefix,
textStyle: RecipientComposerWidgetStyle.prefixButtonTextStyle,
backgroundColor: Colors.transparent,
padding: RecipientComposerWidgetStyle.prefixButtonPadding,
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'),
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.bccState == PrefixRecipientState.disabled)
TMailButtonWidget.fromText(
key: Key('prefix_${widget.prefix.name}_recipient_bcc_button'),
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.replyToState == PrefixRecipientState.disabled)
TMailButtonWidget.fromText(
key: Key('prefix_${widget.prefix.name}_recipient_reply_to_button'),
text: AppLocalizations.of(context).reply_to_email_address_prefix,
textStyle: RecipientComposerWidgetStyle.prefixButtonTextStyle,
backgroundColor: Colors.transparent,
padding: RecipientComposerWidgetStyle.prefixButtonPadding,
margin: RecipientComposerWidgetStyle.recipientMargin,
onTapActionCallback: () => widget.onAddEmailAddressTypeAction?.call(PrefixEmailAddress.replyTo),
),
]
else
TMailButtonWidget.fromIcon(
key: Key('prefix_${widget.prefix.name}_recipient_expand_button'),
icon: _isAllRecipientInputEnabled
? widget.imagePaths.icChevronUp
: widget.imagePaths.icChevronDownOutline,
backgroundColor: Colors.transparent,
iconSize: 24,
padding: const EdgeInsets.all(5),
iconColor: AppColor.colorLabelComposer,
margin: RecipientComposerWidgetStyle.enableRecipientButtonMargin,
onTapActionCallback: () => widget.onEnableAllRecipientsInputAction?.call(_isAllRecipientInputEnabled),
)
else if (PlatformInfo.isWeb || widget.isTestingForWeb)
if (widget.prefix == widget.prefixRootState && _isWeb && !isMobileResponsive)
..._buildListPrefixWidgets(),
if (widget.prefix == widget.prefixRootState && (isMobileResponsive || widget.isTestingForWeb))
_buildExpandButton(),
if (widget.prefix != widget.prefixRootState && _isWeb && !isMobileResponsive)
TMailButtonWidget.fromIcon(
icon: widget.imagePaths.icClose,
backgroundColor: Colors.transparent,
@@ -394,6 +322,82 @@ class _RecipientComposerWidgetState extends State<RecipientComposerWidget> {
);
}
bool get _isWeb => PlatformInfo.isWeb || widget.isTestingForWeb;
List<Widget> _buildListPrefixWidgets() {
return [
const SizedBox(width: RecipientComposerWidgetStyle.space),
if (widget.fromState == PrefixRecipientState.disabled)
TMailButtonWidget.fromText(
key: Key('prefix_${widget.prefix.name}_recipient_from_button'),
text: AppLocalizations.of(context).from_email_address_prefix,
textStyle: RecipientComposerWidgetStyle.prefixButtonTextStyle,
backgroundColor: Colors.transparent,
padding: RecipientComposerWidgetStyle.prefixButtonPadding,
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'),
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.bccState == PrefixRecipientState.disabled)
TMailButtonWidget.fromText(
key: Key('prefix_${widget.prefix.name}_recipient_bcc_button'),
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.replyToState == PrefixRecipientState.disabled)
TMailButtonWidget.fromText(
key: Key('prefix_${widget.prefix.name}_recipient_reply_to_button'),
text: AppLocalizations.of(context).reply_to_email_address_prefix,
textStyle: RecipientComposerWidgetStyle.prefixButtonTextStyle,
backgroundColor: Colors.transparent,
padding: RecipientComposerWidgetStyle.prefixButtonPadding,
margin: RecipientComposerWidgetStyle.recipientMargin,
onTapActionCallback: () => widget.onAddEmailAddressTypeAction?.call(PrefixEmailAddress.replyTo),
),
];
}
Widget _buildExpandButton() {
return TMailButtonWidget.fromIcon(
key: Key('prefix_${widget.prefix.name}_recipient_expand_button'),
icon: _isAllRecipientInputEnabled
? widget.imagePaths.icChevronUp
: widget.imagePaths.icChevronDownOutline,
backgroundColor: Colors.transparent,
iconSize: 24,
padding: const EdgeInsets.all(5),
iconColor: AppColor.colorLabelComposer,
margin: RecipientComposerWidgetStyle.enableRecipientButtonMargin,
onTapActionCallback: () =>
widget.onEnableAllRecipientsInputAction?.call(
_isAllRecipientInputEnabled,
),
);
}
KeyEventResult _recipientInputOnKeyListener(FocusNode node, KeyEvent event) {
if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.tab) {
widget.nextFocusNode?.requestFocus();
@@ -403,17 +407,11 @@ class _RecipientComposerWidgetState extends State<RecipientComposerWidget> {
return KeyEventResult.ignored;
}
bool get _isCollapse => _currentListEmailAddress.length > 1 && widget.expandMode == ExpandMode.COLLAPSE;
bool get _isAllRecipientInputEnabled => widget.fromState == PrefixRecipientState.enabled
&& widget.ccState == PrefixRecipientState.enabled
&& widget.bccState == PrefixRecipientState.enabled
&& widget.replyToState == PrefixRecipientState.enabled;
List<EmailAddress> get _collapsedListEmailAddress => _isCollapse
? _currentListEmailAddress.sublist(0, 1)
: _currentListEmailAddress;
FutureOr<List<SuggestionEmailAddress>> _findSuggestions(String query, {int? limit}) async {
if (_gapBetweenTagChangedAndFindSuggestion?.isActive ?? false) {
return [];
@@ -21,7 +21,6 @@ import 'package:tmail_ui_user/features/email/presentation/utils/email_utils.dart
class RecipientTagItemWidget extends StatelessWidget {
final bool isCollapsed;
final bool isTagFocused;
final ImagePaths imagePaths;
final double? maxWidth;
@@ -29,8 +28,6 @@ class RecipientTagItemWidget extends StatelessWidget {
final PrefixEmailAddress prefix;
final EmailAddress currentEmailAddress;
final List<EmailAddress> currentListEmailAddress;
final List<EmailAddress> collapsedListEmailAddress;
final OnShowFullListEmailAddressAction? onShowFullAction;
final OnDeleteTagAction? onDeleteTagAction;
final OnEditRecipientAction? onEditRecipientAction;
final bool isTestingForWeb;
@@ -44,13 +41,10 @@ class RecipientTagItemWidget extends StatelessWidget {
required this.prefix,
required this.currentEmailAddress,
required this.currentListEmailAddress,
required this.collapsedListEmailAddress,
required this.imagePaths,
@visibleForTesting this.isTestingForWeb = false,
this.isCollapsed = false,
this.isTagFocused = false,
this.isMobile = false,
this.onShowFullAction,
this.onDeleteTagAction,
this.onEditRecipientAction,
this.maxWidth,
@@ -95,9 +89,7 @@ class RecipientTagItemWidget extends StatelessWidget {
),
onClearFocusAction: onClearFocusAction,
child: Container(
key: PlatformInfo.isWeb
? Key('recipient_tag_item_${prefix.name}_$index')
: null,
key: Key('recipient_tag_item_${prefix.name}_$index'),
constraints: const BoxConstraints(maxWidth: 267),
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(
@@ -108,6 +100,9 @@ class RecipientTagItemWidget extends StatelessWidget {
),
height: 32,
padding: const EdgeInsetsDirectional.only(start: 8, end: 4),
margin: PlatformInfo.isMobile
? const EdgeInsetsDirectional.only(top: 8)
: null,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
@@ -120,28 +115,20 @@ class RecipientTagItemWidget extends StatelessWidget {
),
const SizedBox(width: 4),
Flexible(
child: PlatformInfo.isWeb
? ExtendedText(
key: Key('label_recipient_tag_item_${prefix.name}_$index'),
currentEmailAddress.asString(),
maxLines: 1,
overflowWidget: TextOverflowWidget(
position: TextOverflowPosition.middle,
clearType: TextOverflowClearType.clipRect,
child: Text(
'...',
style: RecipientTagItemWidgetStyle.labelTextStyle,
),
),
style: RecipientTagItemWidgetStyle.labelTextStyle,
)
: Text(
key: Key('label_recipient_tag_item_${prefix.name}_$index'),
currentEmailAddress.asString(),
maxLines: 1,
overflow: TextOverflow.ellipsis,
child: ExtendedText(
key: Key('label_recipient_tag_item_${prefix.name}_$index'),
currentEmailAddress.asString(),
maxLines: 1,
overflowWidget: TextOverflowWidget(
position: TextOverflowPosition.middle,
clearType: TextOverflowClearType.clipRect,
child: Text(
'...',
style: RecipientTagItemWidgetStyle.labelTextStyle,
),
),
style: RecipientTagItemWidgetStyle.labelTextStyle,
),
),
const SizedBox(width: 4),
TMailButtonWidget.fromIcon(
@@ -186,47 +173,9 @@ class RecipientTagItemWidget extends StatelessWidget {
);
}
if (PlatformInfo.isWeb) {
return tagWidget;
} else {
return Container(
key: Key('recipient_tag_item_${prefix.name}_$index'),
constraints: BoxConstraints(maxWidth: maxWidth ?? double.infinity),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(child: tagWidget),
if (isCollapsed)
TMailButtonWidget.fromText(
key: Key('counter_recipient_tag_item_${prefix.name}_$index'),
margin: _counterMargin,
text: '+$countRecipients',
onTapActionCallback: () => onShowFullAction?.call(prefix),
borderRadius: RecipientTagItemWidgetStyle.radius,
textStyle: RecipientTagItemWidgetStyle.labelTextStyle,
padding: PlatformInfo.isWeb || isTestingForWeb
? RecipientTagItemWidgetStyle.counterPadding
: RecipientTagItemWidgetStyle.mobileCounterPadding,
backgroundColor: AppColor.colorEmailAddressTag,
),
],
),
);
}
return tagWidget;
}
EdgeInsetsGeometry? get _counterMargin {
if (PlatformInfo.isWeb || isTestingForWeb) {
return PlatformInfo.isCanvasKit
? RecipientTagItemWidgetStyle.webCounterMargin
: RecipientTagItemWidgetStyle.webMobileCounterMargin;
} else {
return RecipientTagItemWidgetStyle.counterMargin;
}
}
int get countRecipients => currentListEmailAddress.length - collapsedListEmailAddress.length;
Color _getTagBackgroundColor() {
if (isTagFocused) {
return AppColor.colorItemRecipientSelected;