TF-4171 Display tag in mailbox list, search list on mobile

This commit is contained in:
dab246
2025-11-28 03:18:40 +07:00
committed by Dat H. Pham
parent 0ab57402a0
commit 5fb0aa84e8
11 changed files with 843 additions and 184 deletions
@@ -1,6 +1,8 @@
import 'package:core/presentation/extensions/color_extension.dart'; import 'package:core/presentation/extensions/color_extension.dart';
import 'package:core/presentation/extensions/hex_color_extension.dart'; import 'package:core/presentation/extensions/hex_color_extension.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:jmap_dart_client/jmap/core/id.dart';
import 'package:labels/model/hex_color.dart';
import 'package:labels/model/label.dart'; import 'package:labels/model/label.dart';
extension LabelExtension on Label { extension LabelExtension on Label {
@@ -23,4 +25,18 @@ extension LabelExtension on Label {
return Colors.white; return Colors.white;
} }
} }
Label copyWith({
Id? id,
String? keyword,
String? displayName,
HexColor? color,
}) {
return Label(
id: id ?? this.id,
displayName: displayName ?? this.displayName,
keyword: keyword ?? this.keyword,
color: color ?? this.color,
);
}
} }
@@ -1,5 +1,6 @@
import 'package:core/presentation/utils/theme_utils.dart'; import 'package:core/presentation/utils/theme_utils.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:tmail_ui_user/features/labels/presentation/utils/label_utils.dart';
class TagWidget extends StatelessWidget { class TagWidget extends StatelessWidget {
final String text; final String text;
@@ -26,7 +27,7 @@ class TagWidget extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
Widget labelText = Text( Widget labelText = Text(
isTruncateText ? _truncateName(text) : text, isTruncateText ? LabelUtils.truncateLabel(text) : text,
style: ThemeUtils.textStyleContentCaption().copyWith( style: ThemeUtils.textStyleContentCaption().copyWith(
color: textColor, color: textColor,
), ),
@@ -73,7 +73,7 @@ class LabelUtils {
} }
final remainingCount = total - 1; final remainingCount = total - 1;
final plus = _buildPlusLabel(remainingCount); final plus = buildPlusLabel(remainingCount);
double plusWidth = effectiveMeasure(plus); double plusWidth = effectiveMeasure(plus);
if (plusMaxWidth != null) { if (plusMaxWidth != null) {
@@ -97,7 +97,7 @@ class LabelUtils {
// Normal shrink logic // Normal shrink logic
while (fitCount > 0) { while (fitCount > 0) {
final plus = _buildPlusLabel(remaining); final plus = buildPlusLabel(remaining);
double plusWidth = effectiveMeasure(plus); double plusWidth = effectiveMeasure(plus);
if (plusMaxWidth != null) { if (plusMaxWidth != null) {
@@ -151,7 +151,7 @@ class LabelUtils {
plusMaxWidth: plusMaxWidth, plusMaxWidth: plusMaxWidth,
measureWidth: measureWidth, measureWidth: measureWidth,
); );
} catch (_, __) { } catch (_) {
if (tags.isEmpty) { if (tags.isEmpty) {
return (displayed: const [], hiddenCount: 0); return (displayed: const [], hiddenCount: 0);
} }
@@ -186,8 +186,61 @@ class LabelUtils {
return (fitCount: fit, usedWidth: used); return (fitCount: fit, usedWidth: used);
} }
static Label _buildPlusLabel(int n) => Label( static Label buildPlusLabel(int n) => Label(
id: Id(LabelExtension.moreLabelId), id: Id(LabelExtension.moreLabelId),
displayName: n > 999 ? '+999+' : '+$n', displayName: n > 999 ? '+999+' : '+$n',
); );
static ({
List<Label> displayed,
int hiddenCount,
}) computeDisplayedLabelsByMaxDisplay({
required List<Label> labels,
int maxDisplay = 3,
}) {
if (labels.isEmpty) {
return (displayed: [], hiddenCount: 0);
}
final limited =
labels.length <= maxDisplay ? labels : labels.take(maxDisplay).toList();
final hiddenCount =
labels.length > maxDisplay ? labels.length - maxDisplay : 0;
return (displayed: limited, hiddenCount: hiddenCount);
}
static ({
List<Label> displayed,
int hiddenCount,
}) safeComputeDisplayedLabelsByMaxDisplay({
required List<Label> labels,
int maxDisplay = 3,
}) {
try {
return computeDisplayedLabelsByMaxDisplay(
labels: labels,
maxDisplay: maxDisplay,
);
} catch (_) {
if (labels.isEmpty) {
return (displayed: const [], hiddenCount: 0);
}
return (
displayed: [labels.first],
hiddenCount: labels.length - 1,
);
}
}
static String truncateLabel(String name, {int maxLength = 16}) {
try {
if (name.length <= maxLength) return name;
return '${name.substring(0, maxLength - 1)}...';
} catch (_) {
return name;
}
}
} }
@@ -8,16 +8,26 @@ class LabelTagListWidget extends StatelessWidget {
final List<Label> tags; final List<Label> tags;
final double horizontalPadding; final double horizontalPadding;
final double itemSpacing; final double itemSpacing;
final bool autoWrapTagsByMaxWidth;
final bool isDesktop;
const LabelTagListWidget({ const LabelTagListWidget({
super.key, super.key,
required this.tags, required this.tags,
this.horizontalPadding = 4, this.horizontalPadding = 4,
this.itemSpacing = 12, this.itemSpacing = 12,
this.autoWrapTagsByMaxWidth = false,
this.isDesktop = false,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return autoWrapTagsByMaxWidth
? _buildAutoWrapMode()
: _buildFixedDisplayMode();
}
Widget _buildAutoWrapMode() {
return LayoutBuilder( return LayoutBuilder(
builder: (_, constraints) { builder: (_, constraints) {
final maxWidth = constraints.maxWidth; final maxWidth = constraints.maxWidth;
@@ -25,7 +35,7 @@ class LabelTagListWidget extends StatelessWidget {
final tagMaxWidth = final tagMaxWidth =
maxWidth - LabelUtils.defaultPlusMaxWidth - itemSpacing; maxWidth - LabelUtils.defaultPlusMaxWidth - itemSpacing;
final displayedTagsDataResult = LabelUtils.safeComputeDisplayedTags( final result = LabelUtils.safeComputeDisplayedTags(
tags: tags, tags: tags,
maxWidth: maxWidth, maxWidth: maxWidth,
textStyle: ThemeUtils.textStyleContentCaption(), textStyle: ThemeUtils.textStyleContentCaption(),
@@ -38,7 +48,7 @@ class LabelTagListWidget extends StatelessWidget {
return Wrap( return Wrap(
spacing: itemSpacing, spacing: itemSpacing,
runSpacing: 6, runSpacing: 6,
children: displayedTagsDataResult.displayed children: result.displayed
.map( .map(
(tagDisplayed) => LabelWidget( (tagDisplayed) => LabelWidget(
label: tagDisplayed, label: tagDisplayed,
@@ -51,4 +61,35 @@ class LabelTagListWidget extends StatelessWidget {
}, },
); );
} }
Widget _buildFixedDisplayMode() {
final result = LabelUtils.safeComputeDisplayedLabelsByMaxDisplay(
labels: tags,
maxDisplay: isDesktop ? 3 : 1,
);
final displayedWidgets = result.displayed.map((label) {
return LabelWidget(
label: label,
horizontalPadding: horizontalPadding,
isTruncateLabel: true,
);
});
final plusWidget = (result.hiddenCount > 0)
? LabelWidget(
label: LabelUtils.buildPlusLabel(result.hiddenCount),
horizontalPadding: horizontalPadding,
)
: null;
return Wrap(
spacing: itemSpacing,
runSpacing: 6,
children: [
...displayedWidgets,
if (plusWidget != null) plusWidget,
],
);
}
} }
@@ -1,3 +1,4 @@
import 'package:core/utils/platform_info.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:labels/extensions/label_extension.dart'; import 'package:labels/extensions/label_extension.dart';
import 'package:labels/model/label.dart'; import 'package:labels/model/label.dart';
@@ -7,22 +8,29 @@ class LabelWidget extends StatelessWidget {
final Label label; final Label label;
final double horizontalPadding; final double horizontalPadding;
final double? maxWidth; final double? maxWidth;
final bool isTruncateLabel;
const LabelWidget({ const LabelWidget({
super.key, super.key,
required this.label, required this.label,
this.horizontalPadding = 4, this.horizontalPadding = 4,
this.maxWidth, this.maxWidth,
this.isTruncateLabel = false,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final showTooltip =
PlatformInfo.isWeb && label.id?.value != LabelExtension.moreLabelId;
return TagWidget( return TagWidget(
text: label.safeDisplayName, text: label.safeDisplayName,
backgroundColor: label.backgroundColor, backgroundColor: label.backgroundColor,
textColor: label.textColor, textColor: label.textColor,
horizontalPadding: horizontalPadding, horizontalPadding: horizontalPadding,
maxWidth: maxWidth, maxWidth: maxWidth,
isTruncateText: isTruncateLabel,
showTooltip: showTooltip,
); );
} }
} }
@@ -17,6 +17,7 @@ import 'package:tmail_ui_user/features/base/mixin/app_loader_mixin.dart';
import 'package:tmail_ui_user/features/base/widget/keyboard/keyboard_handler_wrapper.dart'; import 'package:tmail_ui_user/features/base/widget/keyboard/keyboard_handler_wrapper.dart';
import 'package:tmail_ui_user/features/base/widget/popup_menu/popup_menu_item_action_widget.dart'; import 'package:tmail_ui_user/features/base/widget/popup_menu/popup_menu_item_action_widget.dart';
import 'package:tmail_ui_user/features/base/widget/scrollbar_list_view.dart'; import 'package:tmail_ui_user/features/base/widget/scrollbar_list_view.dart';
import 'package:tmail_ui_user/features/email/presentation/extensions/presentation_email_extension.dart';
import 'package:tmail_ui_user/features/mailbox_dashboard/domain/model/recent_search.dart'; import 'package:tmail_ui_user/features/mailbox_dashboard/domain/model/recent_search.dart';
import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/extensions/handle_ai_needs_action_extension.dart'; import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/extensions/handle_ai_needs_action_extension.dart';
import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/model/search/email_receive_time_type.dart'; import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/model/search/email_receive_time_type.dart';
@@ -654,7 +655,10 @@ class SearchEmailView extends GetWidget<SearchEmailController>
); );
} }
Widget _buildListEmailBody(BuildContext context, List<PresentationEmail> listPresentationEmail) { Widget _buildListEmailBody(
BuildContext context,
List<PresentationEmail> listPresentationEmail,
) {
return NotificationListener<ScrollNotification>( return NotificationListener<ScrollNotification>(
key: const Key('search_email_list_notification_listener'), key: const Key('search_email_list_notification_listener'),
onNotification: (ScrollNotification scrollInfo) { onNotification: (ScrollNotification scrollInfo) {
@@ -678,11 +682,15 @@ class SearchEmailView extends GetWidget<SearchEmailController>
separatorBuilder: (context, index) { separatorBuilder: (context, index) {
return Obx(() { return Obx(() {
final isMobile = PlatformInfo.isMobile; final isMobile = PlatformInfo.isMobile;
final isLastItem = index == listPresentationEmail.length - 1;
final isInactive = final isInactive =
controller.selectionMode.value == SelectMode.INACTIVE; controller.selectionMode.value == SelectMode.INACTIVE;
final dividerColor = final showDivider = !isLastItem;
isMobile ? null : (isInactive ? null : Colors.white);
final dividerColor = isMobile
? null
: (showDivider && isInactive ? null : Colors.white);
final padding = isMobile final padding = isMobile
? SearchEmailUtils.getPaddingItemListMobile( ? SearchEmailUtils.getPaddingItemListMobile(
@@ -694,6 +702,10 @@ class SearchEmailView extends GetWidget<SearchEmailController>
controller.responsiveUtils, controller.responsiveUtils,
); );
if (!showDivider) {
return const SizedBox.shrink();
}
return Padding( return Padding(
padding: padding, padding: padding,
child: Divider(color: dividerColor), child: Divider(color: dividerColor),
@@ -720,6 +732,10 @@ class SearchEmailView extends GetWidget<SearchEmailController>
final isAINeedsActionEnabled = dashboardController.isAINeedsActionEnabled; final isAINeedsActionEnabled = dashboardController.isAINeedsActionEnabled;
final allLabels = dashboardController.labelController.labels;
final emailLabels = presentationEmail.getLabelList(allLabels);
return EmailTileBuilder( return EmailTileBuilder(
presentationEmail: presentationEmail, presentationEmail: presentationEmail,
selectAllMode: controller.selectionMode.value, selectAllMode: controller.selectionMode.value,
@@ -728,6 +744,7 @@ class SearchEmailView extends GetWidget<SearchEmailController>
isSenderImportantFlagEnabled: isSenderImportantFlagEnabled, isSenderImportantFlagEnabled: isSenderImportantFlagEnabled,
isSearchEmailRunning: true, isSearchEmailRunning: true,
isAINeedsActionEnabled: isAINeedsActionEnabled, isAINeedsActionEnabled: isAINeedsActionEnabled,
labels: emailLabels,
padding: SearchEmailViewStyle.getPaddingSearchResultList( padding: SearchEmailViewStyle.getPaddingSearchResultList(
context, context,
controller.responsiveUtils, controller.responsiveUtils,
@@ -6,6 +6,7 @@ import 'package:model/email/presentation_email.dart';
import 'package:model/mailbox/presentation_mailbox.dart'; import 'package:model/mailbox/presentation_mailbox.dart';
import 'package:model/mailbox/select_mode.dart'; import 'package:model/mailbox/select_mode.dart';
import 'package:tmail_ui_user/features/base/widget/labels/ai_action_tag_widget.dart'; import 'package:tmail_ui_user/features/base/widget/labels/ai_action_tag_widget.dart';
import 'package:tmail_ui_user/features/labels/presentation/widgets/label_list_widget.dart';
import 'package:tmail_ui_user/features/thread/domain/model/search_query.dart'; import 'package:tmail_ui_user/features/thread/domain/model/search_query.dart';
import 'package:tmail_ui_user/features/thread/presentation/mixin/base_email_item_tile.dart'; import 'package:tmail_ui_user/features/thread/presentation/mixin/base_email_item_tile.dart';
import 'package:tmail_ui_user/features/thread/presentation/styles/item_email_tile_styles.dart'; import 'package:tmail_ui_user/features/thread/presentation/styles/item_email_tile_styles.dart';
@@ -22,6 +23,7 @@ class EmailTileBuilder extends StatelessWidget with BaseEmailItemTile {
final bool isShowingEmailContent; final bool isShowingEmailContent;
final bool isSenderImportantFlagEnabled; final bool isSenderImportantFlagEnabled;
final bool isAINeedsActionEnabled; final bool isAINeedsActionEnabled;
final bool autoWrapTagsByMaxWidth;
final List<Label>? labels; final List<Label>? labels;
final OnPressEmailActionClick? emailActionClick; final OnPressEmailActionClick? emailActionClick;
final OnMoreActionClick? onMoreActionClick; final OnMoreActionClick? onMoreActionClick;
@@ -36,6 +38,7 @@ class EmailTileBuilder extends StatelessWidget with BaseEmailItemTile {
this.isSearchEmailRunning = false, this.isSearchEmailRunning = false,
this.isSenderImportantFlagEnabled = true, this.isSenderImportantFlagEnabled = true,
this.isAINeedsActionEnabled = true, this.isAINeedsActionEnabled = true,
this.autoWrapTagsByMaxWidth = false,
this.mailboxContain, this.mailboxContain,
this.padding, this.padding,
this.isDrag = false, this.isDrag = false,
@@ -128,17 +131,7 @@ class EmailTileBuilder extends StatelessWidget with BaseEmailItemTile {
)), )),
Padding( Padding(
padding: const EdgeInsetsDirectional.only(top: 2), padding: const EdgeInsetsDirectional.only(top: 2),
child: Row(children: [ child: _buildPartialContent(context),
Expanded(child: buildEmailPartialContent(
context,
presentationEmail,
isSearchEmailRunning,
searchQuery)),
if (_shouldShowAIAction)
const AiActionTagWidget(
margin: EdgeInsetsDirectional.only(start: 8),
),
])
), ),
], ],
), ),
@@ -146,6 +139,94 @@ class EmailTileBuilder extends StatelessWidget with BaseEmailItemTile {
); );
} }
Widget _buildPartialContent(BuildContext context) {
final hasLabels = labels?.isNotEmpty == true;
final isDesktop = responsiveUtils.isDesktop(context);
final isAutoWrap = autoWrapTagsByMaxWidth;
final hasContent =
presentationEmail.getPartialContent().isNotEmpty;
final partialContent = buildEmailPartialContent(
context,
presentationEmail,
isSearchEmailRunning,
searchQuery,
);
if (!hasLabels) {
return Row(
children: [
if (hasContent)
Flexible(child: partialContent),
if (_shouldShowAIAction)
const AiActionTagWidget(
margin: EdgeInsetsDirectional.only(start: 8),
),
],
);
}
if (!hasContent) {
return Row(
children: [
if (_shouldShowAIAction)
const AiActionTagWidget(
margin: EdgeInsetsDirectional.only(start: 8),
),
Flexible(
child: LabelTagListWidget(
tags: labels!,
autoWrapTagsByMaxWidth: isAutoWrap,
isDesktop: isDesktop,
),
),
],
);
}
if (!isAutoWrap) {
return Row(
children: [
Flexible(child: partialContent),
if (_shouldShowAIAction)
const AiActionTagWidget(
margin: EdgeInsetsDirectional.only(start: 8),
),
const SizedBox(width: 12),
LabelTagListWidget(
tags: labels!,
autoWrapTagsByMaxWidth: isAutoWrap,
isDesktop: isDesktop,
),
],
);
}
return LayoutBuilder(
builder: (_, constraints) {
return Row(
children: [
Expanded(child: partialContent),
if (_shouldShowAIAction)
const AiActionTagWidget(
margin: EdgeInsetsDirectional.only(start: 8),
),
const SizedBox(width: 12),
ConstrainedBox(
constraints: BoxConstraints(maxWidth: constraints.maxWidth / 2),
child: LabelTagListWidget(
tags: labels!,
autoWrapTagsByMaxWidth: isAutoWrap,
isDesktop: isDesktop,
),
),
],
);
},
);
}
bool get _shouldShowAIAction => bool get _shouldShowAIAction =>
isAINeedsActionEnabled && presentationEmail.hasNeedAction; isAINeedsActionEnabled && presentationEmail.hasNeedAction;
} }
@@ -30,6 +30,7 @@ class EmailTileBuilder extends StatefulWidget {
final bool isShowingEmailContent; final bool isShowingEmailContent;
final bool isSenderImportantFlagEnabled; final bool isSenderImportantFlagEnabled;
final bool isAINeedsActionEnabled; final bool isAINeedsActionEnabled;
final bool autoWrapTagsByMaxWidth;
final List<Label>? labels; final List<Label>? labels;
final OnPressEmailActionClick? emailActionClick; final OnPressEmailActionClick? emailActionClick;
final OnMoreActionClick? onMoreActionClick; final OnMoreActionClick? onMoreActionClick;
@@ -43,6 +44,7 @@ class EmailTileBuilder extends StatefulWidget {
this.searchQuery, this.searchQuery,
this.isSearchEmailRunning = false, this.isSearchEmailRunning = false,
this.isSenderImportantFlagEnabled = true, this.isSenderImportantFlagEnabled = true,
this.autoWrapTagsByMaxWidth = false,
this.mailboxContain, this.mailboxContain,
this.padding, this.padding,
this.isDrag = false, this.isDrag = false,
@@ -100,88 +102,78 @@ class _EmailTileBuilderState extends State<EmailTileBuilder> with BaseEmailItem
), ),
), ),
Expanded( Expanded(
child: Column(children: [ child: Column(
Row(children: [ crossAxisAlignment: CrossAxisAlignment.start,
if (!widget.presentationEmail.hasRead) children: [
Padding( Row(children: [
padding: const EdgeInsetsDirectional.only(end: 5), if (!widget.presentationEmail.hasRead)
child: buildIconUnreadStatus(), Padding(
), padding: const EdgeInsetsDirectional.only(end: 5),
Expanded( child: buildIconUnreadStatus(),
child: buildInformationSender(
context,
widget.presentationEmail,
widget.mailboxContain,
widget.isSearchEmailRunning,
widget.searchQuery
)
),
buildIconAnsweredOrForwarded(
width: 16,
height: 16,
presentationEmail: widget.presentationEmail
),
if (widget.presentationEmail.hasAttachment == true)
Padding(
padding: const EdgeInsetsDirectional.only(start: 8),
child: buildIconAttachment(),
),
Padding(
padding: const EdgeInsetsDirectional.only(
end: 4,
start: 8
),
child: buildDateTime(context, widget.presentationEmail)
),
buildIconChevron()
]),
const SizedBox(height: 2),
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (widget.presentationEmail.hasCalendarEvent)
buildCalendarEventIcon(
context: context,
presentationEmail: widget.presentationEmail
), ),
if (widget.presentationEmail.isMarkAsImportant && widget.isSenderImportantFlagEnabled)
buildMarkAsImportantIcon(context),
Expanded( Expanded(
child: buildEmailTitle( child: buildInformationSender(
context, context,
widget.presentationEmail, widget.presentationEmail,
widget.mailboxContain,
widget.isSearchEmailRunning, widget.isSearchEmailRunning,
widget.searchQuery widget.searchQuery
) )
), ),
buildMailboxContain( buildIconAnsweredOrForwarded(
context, width: 16,
widget.isSearchEmailRunning, height: 16,
widget.presentationEmail presentationEmail: widget.presentationEmail
), ),
if (widget.presentationEmail.hasStarred) if (widget.presentationEmail.hasAttachment == true)
Padding( Padding(
padding: const EdgeInsetsDirectional.only(start: 8), padding: const EdgeInsetsDirectional.only(start: 8),
child: buildIconStar(), child: buildIconAttachment(),
), ),
], Padding(
), padding: const EdgeInsetsDirectional.only(
const SizedBox(height: 2), end: 4,
Row(children: [ start: 8
Expanded( ),
child: buildEmailPartialContent( child: buildDateTime(context, widget.presentationEmail)
context,
widget.presentationEmail,
widget.isSearchEmailRunning,
widget.searchQuery
)
),
if (_shouldShowAIAction)
const AiActionTagWidget(
margin: EdgeInsetsDirectional.only(start: 8),
), ),
]), buildIconChevron()
]), ]),
const SizedBox(height: 2),
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (widget.presentationEmail.hasCalendarEvent)
buildCalendarEventIcon(
context: context,
presentationEmail: widget.presentationEmail
),
if (widget.presentationEmail.isMarkAsImportant && widget.isSenderImportantFlagEnabled)
buildMarkAsImportantIcon(context),
Expanded(
child: buildEmailTitle(
context,
widget.presentationEmail,
widget.isSearchEmailRunning,
widget.searchQuery
)
),
buildMailboxContain(
context,
widget.isSearchEmailRunning,
widget.presentationEmail
),
if (widget.presentationEmail.hasStarred)
Padding(
padding: const EdgeInsetsDirectional.only(start: 8),
child: buildIconStar(),
),
],
),
const SizedBox(height: 2),
_buildPartialContentMobile(context),
],
),
) )
] ]
), ),
@@ -197,6 +189,8 @@ class _EmailTileBuilderState extends State<EmailTileBuilder> with BaseEmailItem
isDrag: widget.isDrag, isDrag: widget.isDrag,
isSenderImportantFlagEnabled: widget.isSenderImportantFlagEnabled, isSenderImportantFlagEnabled: widget.isSenderImportantFlagEnabled,
shouldShowAIAction: _shouldShowAIAction, shouldShowAIAction: _shouldShowAIAction,
autoWrapTagsByMaxWidth: widget.autoWrapTagsByMaxWidth,
labels: widget.labels,
padding: widget.padding, padding: widget.padding,
searchQuery: widget.searchQuery, searchQuery: widget.searchQuery,
mailboxContain: widget.mailboxContain, mailboxContain: widget.mailboxContain,
@@ -320,7 +314,7 @@ class _EmailTileBuilderState extends State<EmailTileBuilder> with BaseEmailItem
) )
), ),
const SizedBox(width: 24), const SizedBox(width: 24),
Expanded(child: _buildSubjectAndContent()), Expanded(child: _buildSubjectAndContent(context)),
const SizedBox(width: 16), const SizedBox(width: 16),
ValueListenableBuilder( ValueListenableBuilder(
valueListenable: _hoverNotifier, valueListenable: _hoverNotifier,
@@ -373,8 +367,9 @@ class _EmailTileBuilderState extends State<EmailTileBuilder> with BaseEmailItem
return widget.mailboxContain?.isTrash == true || widget.mailboxContain?.isDrafts == true || widget.mailboxContain?.isSpam == true; return widget.mailboxContain?.isTrash == true || widget.mailboxContain?.isDrafts == true || widget.mailboxContain?.isSpam == true;
} }
Widget _buildSubjectAndContent() { Widget _buildSubjectAndContent(BuildContext context) {
final emailTitle = widget.presentationEmail.getEmailTitle(); final emailTitle = widget.presentationEmail.getEmailTitle();
final emailPartialContent = widget.presentationEmail.getPartialContent();
return Row( return Row(
children: [ children: [
@@ -391,78 +386,40 @@ class _EmailTileBuilderState extends State<EmailTileBuilder> with BaseEmailItem
Expanded( Expanded(
child: LayoutBuilder( child: LayoutBuilder(
builder: (_, constraints) { builder: (_, constraints) {
const horizontalPadding = 12.0;
final maxWidth = constraints.maxWidth; final maxWidth = constraints.maxWidth;
double horizontalPadding = 12;
final maxWidthNoPadding = maxWidth - horizontalPadding * 2; final maxWidthNoPadding = maxWidth - horizontalPadding * 2;
final labelTagsMaxWidth = emailTitle.isNotEmpty final hasLabels = widget.labels?.isNotEmpty == true;
? maxWidthNoPadding * 0.4 final isAutoWrap = widget.autoWrapTagsByMaxWidth;
: maxWidthNoPadding * 0.5; final hasTitle = emailTitle.isNotEmpty;
final hasContent = emailPartialContent.isNotEmpty;
final emailTitleMaxWidth = widget.labels?.isNotEmpty == true final labelTagsMaxWidth =
? maxWidthNoPadding * 0.4 hasTitle ? maxWidthNoPadding * 0.4 : maxWidthNoPadding * 0.5;
: maxWidthNoPadding * 0.5;
final emailTitleMaxWidth =
hasLabels ? maxWidthNoPadding * 0.4 : maxWidthNoPadding * 0.5;
return Row( return Row(
children: [ children: [
if (widget.labels?.isNotEmpty == true) ...[ if (hasLabels)
Container( _buildLabelsDesktop(
constraints: BoxConstraints( context: context,
maxWidth: labelTagsMaxWidth, maxWidth: labelTagsMaxWidth,
), horizontalPadding: horizontalPadding,
padding: EdgeInsetsDirectional.only( isAutoWrap: isAutoWrap,
end: horizontalPadding,
),
child: LabelTagListWidget(tags: widget.labels!),
), ),
if (emailTitle.isNotEmpty) if (hasTitle)
Container( _buildTitleDesktop(
constraints: BoxConstraints( context: context,
maxWidth: emailTitleMaxWidth, maxWidth: emailTitleMaxWidth,
), horizontalPadding: horizontalPadding,
padding: EdgeInsetsDirectional.only( hasContent: hasContent,
end: horizontalPadding,
),
child: buildEmailTitle(
context,
widget.presentationEmail,
widget.isSearchEmailRunning,
widget.searchQuery,
),
),
Expanded(
child: buildEmailPartialContent(
context,
widget.presentationEmail,
widget.isSearchEmailRunning,
widget.searchQuery,
),
), ),
] else ...[ if (hasContent)
if (emailTitle.isNotEmpty) _buildPartialContentDesktop(context),
Container(
constraints: BoxConstraints(
maxWidth: emailTitleMaxWidth,
),
padding: EdgeInsetsDirectional.only(
end: horizontalPadding,
),
child: buildEmailTitle(
context,
widget.presentationEmail,
widget.isSearchEmailRunning,
widget.searchQuery,
),
),
Expanded(
child: buildEmailPartialContent(
context,
widget.presentationEmail,
widget.isSearchEmailRunning,
widget.searchQuery,
),
),
],
], ],
); );
}, },
@@ -472,6 +429,165 @@ class _EmailTileBuilderState extends State<EmailTileBuilder> with BaseEmailItem
); );
} }
Widget _buildLabelsDesktop({
required BuildContext context,
required double maxWidth,
required double horizontalPadding,
required bool isAutoWrap,
}) {
final labels = widget.labels;
if (labels == null || labels.isEmpty) return const SizedBox.shrink();
final child = LabelTagListWidget(
tags: labels,
autoWrapTagsByMaxWidth: widget.autoWrapTagsByMaxWidth,
isDesktop: responsiveUtils.isDesktop(context),
);
if (isAutoWrap) {
return Container(
constraints: BoxConstraints(maxWidth: maxWidth),
padding: EdgeInsetsDirectional.only(end: horizontalPadding),
child: child,
);
}
return Padding(
padding: EdgeInsetsDirectional.only(end: horizontalPadding),
child: child,
);
}
Widget _buildTitleDesktop({
required BuildContext context,
required double maxWidth,
required double horizontalPadding,
bool hasContent = false,
}) {
if (hasContent) {
return Container(
constraints: BoxConstraints(maxWidth: maxWidth),
padding: EdgeInsetsDirectional.only(end: horizontalPadding),
child: buildEmailTitle(
context,
widget.presentationEmail,
widget.isSearchEmailRunning,
widget.searchQuery,
),
);
} else {
return Expanded(
child: Padding(
padding: EdgeInsetsDirectional.only(end: horizontalPadding),
child: buildEmailTitle(
context,
widget.presentationEmail,
widget.isSearchEmailRunning,
widget.searchQuery,
),
),
);
}
}
Widget _buildPartialContentDesktop(BuildContext context) {
return Expanded(
child: buildEmailPartialContent(
context,
widget.presentationEmail,
widget.isSearchEmailRunning,
widget.searchQuery,
),
);
}
Widget _buildPartialContentMobile(BuildContext context) {
final labels = widget.labels;
final hasLabels = labels?.isNotEmpty == true;
final hasContent = widget.presentationEmail.getPartialContent().isNotEmpty;
final isAutoWrap = widget.autoWrapTagsByMaxWidth;
final isDesktop = responsiveUtils.isDesktop(context);
final partialContent = buildEmailPartialContent(
context,
widget.presentationEmail,
widget.isSearchEmailRunning,
widget.searchQuery,
);
if (!hasLabels) {
return Row(
children: [
if (hasContent)
Flexible(child: partialContent),
if (_shouldShowAIAction)
const AiActionTagWidget(
margin: EdgeInsetsDirectional.only(start: 8),
),
],
);
}
if (!hasContent) {
return Row(
children: [
if (_shouldShowAIAction)
const AiActionTagWidget(
margin: EdgeInsetsDirectional.only(start: 8),
),
Flexible(
child: LabelTagListWidget(
tags: labels!,
autoWrapTagsByMaxWidth: isAutoWrap,
isDesktop: isDesktop,
),
),
],
);
}
if (!isAutoWrap) {
return Row(
children: [
Flexible(child: partialContent),
if (_shouldShowAIAction)
const AiActionTagWidget(
margin: EdgeInsetsDirectional.only(start: 8),
),
const SizedBox(width: 12),
LabelTagListWidget(
tags: labels!,
autoWrapTagsByMaxWidth: isAutoWrap,
isDesktop: isDesktop,
),
],
);
}
return LayoutBuilder(
builder: (_, constraints) {
return Row(
children: [
Expanded(child: partialContent),
if (_shouldShowAIAction)
const AiActionTagWidget(
margin: EdgeInsetsDirectional.only(start: 8),
),
const SizedBox(width: 12),
ConstrainedBox(
constraints: BoxConstraints(maxWidth: constraints.maxWidth / 2),
child: LabelTagListWidget(
tags: labels!,
autoWrapTagsByMaxWidth: isAutoWrap,
isDesktop: isDesktop,
),
),
],
);
},
);
}
Widget _buildAvatarIcon({ Widget _buildAvatarIcon({
required BuildContext context, required BuildContext context,
bool isHovered = false, bool isHovered = false,
@@ -3,12 +3,14 @@ import 'package:core/presentation/resources/image_paths.dart';
import 'package:core/presentation/views/button/tmail_button_widget.dart'; import 'package:core/presentation/views/button/tmail_button_widget.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:labels/model/label.dart';
import 'package:model/email/email_action_type.dart'; import 'package:model/email/email_action_type.dart';
import 'package:model/email/presentation_email.dart'; import 'package:model/email/presentation_email.dart';
import 'package:model/extensions/presentation_mailbox_extension.dart'; import 'package:model/extensions/presentation_mailbox_extension.dart';
import 'package:model/mailbox/presentation_mailbox.dart'; import 'package:model/mailbox/presentation_mailbox.dart';
import 'package:model/mailbox/select_mode.dart'; import 'package:model/mailbox/select_mode.dart';
import 'package:tmail_ui_user/features/base/widget/labels/ai_action_tag_widget.dart'; import 'package:tmail_ui_user/features/base/widget/labels/ai_action_tag_widget.dart';
import 'package:tmail_ui_user/features/labels/presentation/widgets/label_list_widget.dart';
import 'package:tmail_ui_user/features/thread/domain/model/search_query.dart'; import 'package:tmail_ui_user/features/thread/domain/model/search_query.dart';
import 'package:tmail_ui_user/features/thread/presentation/mixin/base_email_item_tile.dart'; import 'package:tmail_ui_user/features/thread/presentation/mixin/base_email_item_tile.dart';
import 'package:tmail_ui_user/features/thread/presentation/styles/item_email_tile_styles.dart'; import 'package:tmail_ui_user/features/thread/presentation/styles/item_email_tile_styles.dart';
@@ -24,6 +26,8 @@ class WebTabletBodyEmailItemWidget extends StatefulWidget {
final bool isDrag; final bool isDrag;
final bool isSenderImportantFlagEnabled; final bool isSenderImportantFlagEnabled;
final bool shouldShowAIAction; final bool shouldShowAIAction;
final bool autoWrapTagsByMaxWidth;
final List<Label>? labels;
final EdgeInsetsGeometry? padding; final EdgeInsetsGeometry? padding;
final SearchQuery? searchQuery; final SearchQuery? searchQuery;
final PresentationMailbox? mailboxContain; final PresentationMailbox? mailboxContain;
@@ -39,6 +43,8 @@ class WebTabletBodyEmailItemWidget extends StatefulWidget {
required this.isShowingEmailContent, required this.isShowingEmailContent,
required this.isDrag, required this.isDrag,
required this.isSenderImportantFlagEnabled, required this.isSenderImportantFlagEnabled,
required this.autoWrapTagsByMaxWidth,
required this.labels,
required this.padding, required this.padding,
required this.searchQuery, required this.searchQuery,
required this.mailboxContain, required this.mailboxContain,
@@ -102,6 +108,7 @@ class _WebTabletBodyEmailItemWidgetState
), ),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row(children: [ Row(children: [
if (!widget.presentationEmail.hasRead) if (!widget.presentationEmail.hasRead)
@@ -158,22 +165,7 @@ class _WebTabletBodyEmailItemWidgetState
], ],
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
Row( _buildPartialContent(context),
children: [
Expanded(
child: buildEmailPartialContent(
context,
widget.presentationEmail,
widget.isSearchEmailRunning,
widget.searchQuery,
),
),
if (widget.shouldShowAIAction)
const AiActionTagWidget(
margin: EdgeInsetsDirectional.only(start: 8),
),
],
),
], ],
), ),
) )
@@ -384,4 +376,92 @@ class _WebTabletBodyEmailItemWidgetState
}); });
} }
} }
Widget _buildPartialContent(BuildContext context) {
final labels = widget.labels;
final hasLabels = labels?.isNotEmpty == true;
final isAutoWrap = widget.autoWrapTagsByMaxWidth;
final isDesktop = responsiveUtils.isDesktop(context);
final hasContent = widget.presentationEmail.getPartialContent().isNotEmpty;
final partialContent = buildEmailPartialContent(
context,
widget.presentationEmail,
widget.isSearchEmailRunning,
widget.searchQuery,
);
if (!hasLabels) {
return Row(
children: [
if (hasContent)
Flexible(child: partialContent),
if (widget.shouldShowAIAction)
const AiActionTagWidget(
margin: EdgeInsetsDirectional.only(start: 8),
),
],
);
}
if (!hasContent) {
return Row(
children: [
if (widget.shouldShowAIAction)
const AiActionTagWidget(
margin: EdgeInsetsDirectional.only(start: 8),
),
Flexible(
child: LabelTagListWidget(
tags: labels!,
autoWrapTagsByMaxWidth: isAutoWrap,
isDesktop: isDesktop,
),
),
],
);
}
if (!isAutoWrap) {
return Row(
children: [
Flexible(child: partialContent),
if (widget.shouldShowAIAction)
const AiActionTagWidget(
margin: EdgeInsetsDirectional.only(start: 8),
),
const SizedBox(width: 12),
LabelTagListWidget(
tags: labels!,
autoWrapTagsByMaxWidth: isAutoWrap,
isDesktop: isDesktop,
),
],
);
}
return LayoutBuilder(
builder: (_, constraints) {
return Row(
children: [
Expanded(child: partialContent),
if (widget.shouldShowAIAction)
const AiActionTagWidget(
margin: EdgeInsetsDirectional.only(start: 8),
),
const SizedBox(width: 12),
ConstrainedBox(
constraints: BoxConstraints(maxWidth: constraints.maxWidth / 2),
child: LabelTagListWidget(
tags: labels!,
autoWrapTagsByMaxWidth: isAutoWrap,
isDesktop: isDesktop,
),
),
],
);
},
);
}
} }
@@ -394,18 +394,6 @@ void main() {
expect(result.hiddenCount, 2); expect(result.hiddenCount, 2);
}); });
test('safeComputeDisplayedTags falls back gracefully on exception', () {
final result = LabelUtils.safeComputeDisplayedTags(
tags: [Label(displayName: 'A'), Label(displayName: 'B')],
maxWidth: 100,
textStyle: const TextStyle(),
measureWidth: (_) => throw Exception('boom'),
);
expect(result.displayed.single.safeDisplayName, 'A');
expect(result.hiddenCount, 1);
});
test('respects tagMaxWidth by clamping oversized label width', () { test('respects tagMaxWidth by clamping oversized label width', () {
final tags = [ final tags = [
label('SUPER_LONG_TAG_NAME'), label('SUPER_LONG_TAG_NAME'),
@@ -479,4 +467,220 @@ void main() {
); );
}); });
}); });
group('LabelUtils.safeComputeDisplayedTags', () {
test('should falls back gracefully on exception', () {
final result = LabelUtils.safeComputeDisplayedTags(
tags: [Label(displayName: 'A'), Label(displayName: 'B')],
maxWidth: 100,
textStyle: const TextStyle(),
measureWidth: (_) => throw Exception('boom'),
);
expect(result.displayed.single.safeDisplayName, 'A');
expect(result.hiddenCount, 1);
});
});
group('LabelUtils.computeDisplayedLabelsByMaxDisplay', () {
test('returns empty result when labels list is empty', () {
final result = LabelUtils.computeDisplayedLabelsByMaxDisplay(labels: []);
expect(result.displayed, isEmpty);
expect(result.hiddenCount, 0);
});
test('returns all labels when length <= maxDisplay', () {
final labels = [
label('A'),
label('B'),
];
final result = LabelUtils.computeDisplayedLabelsByMaxDisplay(
labels: labels,
maxDisplay: 3,
);
expect(result.displayed.length, 2);
expect(result.displayed.map((e) => e.displayName), ['A', 'B']);
expect(result.hiddenCount, 0);
});
test('limits displayed to maxDisplay when list exceeds maxDisplay', () {
final labels = [
label('A'),
label('B'),
label('C'),
label('D'),
];
final result = LabelUtils.computeDisplayedLabelsByMaxDisplay(
labels: labels,
maxDisplay: 3,
);
expect(result.displayed.length, 3);
expect(result.displayed.map((e) => e.displayName), ['A', 'B', 'C']);
expect(result.hiddenCount, 1);
});
test('computes hiddenCount correctly for larger list', () {
final labels = [
label('A'),
label('B'),
label('C'),
label('D'),
label('E'),
];
final result = LabelUtils.computeDisplayedLabelsByMaxDisplay(
labels: labels,
maxDisplay: 3,
);
expect(result.displayed.length, 3);
expect(result.displayed.map((e) => e.displayName), ['A', 'B', 'C']);
expect(result.hiddenCount, 2);
});
test('works correctly when maxDisplay = 1', () {
final labels = [
label('A'),
label('B'),
label('C'),
];
final result = LabelUtils.computeDisplayedLabelsByMaxDisplay(
labels: labels,
maxDisplay: 1,
);
expect(result.displayed.length, 1);
expect(result.displayed.first.displayName, 'A');
expect(result.hiddenCount, 2);
});
test('works correctly when maxDisplay is larger than list size', () {
final labels = [
label('A'),
];
final result = LabelUtils.computeDisplayedLabelsByMaxDisplay(
labels: labels,
maxDisplay: 10,
);
expect(result.displayed.length, 1);
expect(result.displayed.map((e) => e.displayName), ['A']);
expect(result.hiddenCount, 0);
});
test('throws RangeError when maxDisplay < 0', () {
final labels = [
label('A'),
label('B'),
label('C'),
];
expect(
() => LabelUtils.computeDisplayedLabelsByMaxDisplay(
labels: labels,
maxDisplay: -1,
),
throwsA(isA<RangeError>()),
);
});
});
group('LabelUtils.safeComputeDisplayedLabelsByMaxDisplay', () {
test('returns empty result when labels list is empty', () {
final result =
LabelUtils.safeComputeDisplayedLabelsByMaxDisplay(labels: []);
expect(result.displayed, isEmpty);
expect(result.hiddenCount, 0);
});
test('returns normal compute result when no exception occurs', () {
final labels = [
label('A'),
label('B'),
label('C'),
];
final result = LabelUtils.safeComputeDisplayedLabelsByMaxDisplay(
labels: labels,
maxDisplay: 2,
);
expect(result.displayed.length, 2);
expect(result.displayed.map((e) => e.displayName), ['A', 'B']);
expect(result.hiddenCount, 1);
});
test(
'fallback when computeDisplayedLabelsByMaxDisplay throws and labels empty',
() {
expect(
() => LabelUtils.safeComputeDisplayedLabelsByMaxDisplay(
labels: [],
maxDisplay: -999,
),
returnsNormally,
);
final result = LabelUtils.safeComputeDisplayedLabelsByMaxDisplay(
labels: [],
maxDisplay: -999,
);
expect(result.displayed, isEmpty);
expect(result.hiddenCount, 0);
});
test('fallback returns first label and hiddenCount when exception occurs',
() {
final labels = [
label('A'),
label('B'),
label('C'),
];
final result = LabelUtils.safeComputeDisplayedLabelsByMaxDisplay(
labels: labels,
maxDisplay: -1,
);
expect(result.displayed.length, 1);
expect(result.displayed.first.displayName, 'A');
expect(result.hiddenCount, 2);
});
test('fallback works correctly when only one label exists', () {
final labels = [label('A')];
final result = LabelUtils.safeComputeDisplayedLabelsByMaxDisplay(
labels: labels,
maxDisplay: -1,
);
expect(result.displayed.length, 1);
expect(result.displayed.first.displayName, 'A');
expect(result.hiddenCount, 0);
});
test('normal path works when maxDisplay > list size', () {
final labels = [
label('A'),
];
final result = LabelUtils.safeComputeDisplayedLabelsByMaxDisplay(
labels: labels,
maxDisplay: 10,
);
expect(result.displayed.length, 1);
expect(result.hiddenCount, 0);
});
});
} }
@@ -0,0 +1,42 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:tmail_ui_user/features/labels/presentation/utils/label_utils.dart';
void main() {
group('LabelUtils.truncateLabel', () {
test('returns original string when length <= maxLength', () {
const name = 'ShortLabel';
final result = LabelUtils.truncateLabel(name, maxLength: 16);
expect(result, name);
});
test('truncates string when length > maxLength', () {
const name = 'ThisIsAVeryLongLabel';
final result = LabelUtils.truncateLabel(name, maxLength: 16);
expect(result, 'ThisIsAVeryLong...');
});
test('works correctly with exact maxLength boundary', () {
const name = '1234567890123456';
final result = LabelUtils.truncateLabel(name, maxLength: 16);
expect(result, name);
});
test('uses custom maxLength', () {
const name = 'ABCDEFGH';
final result = LabelUtils.truncateLabel(name, maxLength: 5);
expect(result, 'ABCD...');
});
test('handles empty string gracefully', () {
const name = '';
final result = LabelUtils.truncateLabel(name, maxLength: 16);
expect(result, '');
});
test('handles maxLength = 1 (still truncates safely)', () {
const name = 'LongName';
final result = LabelUtils.truncateLabel(name, maxLength: 1);
expect(result, '...');
});
});
}