TF-4171 Display tag in mailbox list
This commit is contained in:
@@ -1,8 +1,11 @@
|
||||
import 'package:jmap_dart_client/jmap/mail/email/email_address.dart';
|
||||
import 'package:labels/model/label.dart';
|
||||
import 'package:model/email/email_action_type.dart';
|
||||
import 'package:model/email/presentation_email.dart';
|
||||
import 'package:model/extensions/list_email_address_extension.dart';
|
||||
import 'package:tmail_ui_user/features/email/presentation/utils/email_utils.dart';
|
||||
import 'package:tmail_ui_user/features/thread/data/extensions/list_keyword_identifier_extension.dart';
|
||||
import 'package:tmail_ui_user/features/thread/data/extensions/map_keywords_extension.dart';
|
||||
|
||||
extension PresentationEmailExtension on PresentationEmail {
|
||||
({
|
||||
@@ -153,4 +156,19 @@ extension PresentationEmailExtension on PresentationEmail {
|
||||
replyTo: [],
|
||||
);
|
||||
}
|
||||
|
||||
List<Label> getLabelList(List<Label> labels) {
|
||||
if (keywords?.isNotEmpty != true || labels.isEmpty) return const [];
|
||||
|
||||
final enabledKeywordListString =
|
||||
keywords!.enabledKeywords.keywordListString;
|
||||
|
||||
if (enabledKeywordListString.isEmpty) return const [];
|
||||
|
||||
return labels
|
||||
.where((label) =>
|
||||
label.keyword != null &&
|
||||
enabledKeywordListString.contains(label.keyword))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import 'package:core/utils/widget_utils.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:jmap_dart_client/jmap/core/id.dart';
|
||||
import 'package:labels/extensions/label_extension.dart';
|
||||
import 'package:labels/model/label.dart';
|
||||
|
||||
@visibleForTesting
|
||||
typedef MeasureWidthFn = double Function(Label label);
|
||||
|
||||
class LabelUtils {
|
||||
LabelUtils._();
|
||||
|
||||
static const double defaultPlusMaxWidth = 40.0;
|
||||
|
||||
static ({
|
||||
List<Label> displayed,
|
||||
int hiddenCount,
|
||||
}) computeDisplayedTags({
|
||||
required List<Label> tags,
|
||||
required double maxWidth,
|
||||
required TextStyle textStyle,
|
||||
double itemSpacing = 12,
|
||||
double horizontalPadding = 4,
|
||||
double? tagMaxWidth,
|
||||
double? plusMaxWidth,
|
||||
@visibleForTesting MeasureWidthFn? measureWidth,
|
||||
}) {
|
||||
if (tags.isEmpty) {
|
||||
return (displayed: const [], hiddenCount: 0);
|
||||
}
|
||||
|
||||
final effectiveMeasure = measureWidth ??
|
||||
(label) => WidgetUtils.measureTextWidth(
|
||||
text: label.safeDisplayName,
|
||||
style: textStyle,
|
||||
horizontalPadding: horizontalPadding,
|
||||
);
|
||||
|
||||
// Measure all tag widths with per-item max constraint
|
||||
final widths = tags.map((label) {
|
||||
final width = effectiveMeasure(label);
|
||||
return tagMaxWidth != null
|
||||
? width.clamp(0, tagMaxWidth).toDouble()
|
||||
: width;
|
||||
}).toList();
|
||||
|
||||
final total = tags.length;
|
||||
|
||||
// Compute how many fit normally
|
||||
final fit = _computeFitCount(
|
||||
widths: widths,
|
||||
maxWidth: maxWidth,
|
||||
itemSpacing: itemSpacing,
|
||||
);
|
||||
|
||||
int fitCount = fit.fitCount;
|
||||
double usedWidth = fit.usedWidth;
|
||||
int remaining = total - fitCount;
|
||||
|
||||
// All fit normally
|
||||
if (fitCount > 0 && remaining == 0) {
|
||||
return (displayed: tags, hiddenCount: 0);
|
||||
}
|
||||
|
||||
// Ensure minimum 1 displayed always
|
||||
if (fitCount == 0) {
|
||||
final first = tags.first;
|
||||
double firstWidth = widths.first;
|
||||
|
||||
// First tag larger than maxWidth → still show it alone
|
||||
if (firstWidth > maxWidth) {
|
||||
return (displayed: [first], hiddenCount: total - 1);
|
||||
}
|
||||
|
||||
final remainingCount = total - 1;
|
||||
final plus = _buildPlusLabel(remainingCount);
|
||||
|
||||
double plusWidth = effectiveMeasure(plus);
|
||||
if (plusMaxWidth != null) {
|
||||
plusWidth = plusWidth.clamp(0, plusMaxWidth);
|
||||
}
|
||||
|
||||
final next = firstWidth + itemSpacing + plusWidth;
|
||||
|
||||
if (next <= maxWidth) {
|
||||
return (
|
||||
displayed: [first, plus],
|
||||
hiddenCount: remainingCount,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
displayed: [first],
|
||||
hiddenCount: remainingCount,
|
||||
);
|
||||
}
|
||||
|
||||
// Normal shrink logic
|
||||
while (fitCount > 0) {
|
||||
final plus = _buildPlusLabel(remaining);
|
||||
|
||||
double plusWidth = effectiveMeasure(plus);
|
||||
if (plusMaxWidth != null) {
|
||||
plusWidth = plusWidth.clamp(0, plusMaxWidth);
|
||||
}
|
||||
|
||||
final spacing = (fitCount == 0) ? 0 : itemSpacing;
|
||||
final next = usedWidth + spacing + plusWidth;
|
||||
|
||||
if (next <= maxWidth) {
|
||||
return (
|
||||
displayed: [...tags.take(fitCount), plus],
|
||||
hiddenCount: remaining,
|
||||
);
|
||||
}
|
||||
|
||||
fitCount--;
|
||||
remaining++;
|
||||
|
||||
usedWidth -= widths[fitCount] + (fitCount == 0 ? 0 : itemSpacing);
|
||||
}
|
||||
|
||||
// Final fallback: always show first tag only
|
||||
return (
|
||||
displayed: [tags.first],
|
||||
hiddenCount: total - 1,
|
||||
);
|
||||
}
|
||||
|
||||
static ({
|
||||
List<Label> displayed,
|
||||
int hiddenCount,
|
||||
}) safeComputeDisplayedTags({
|
||||
required List<Label> tags,
|
||||
required double maxWidth,
|
||||
required TextStyle textStyle,
|
||||
double itemSpacing = 12,
|
||||
double horizontalPadding = 4,
|
||||
double? tagMaxWidth,
|
||||
double? plusMaxWidth,
|
||||
@visibleForTesting MeasureWidthFn? measureWidth,
|
||||
}) {
|
||||
try {
|
||||
return computeDisplayedTags(
|
||||
tags: tags,
|
||||
maxWidth: maxWidth,
|
||||
textStyle: textStyle,
|
||||
itemSpacing: itemSpacing,
|
||||
horizontalPadding: horizontalPadding,
|
||||
tagMaxWidth: tagMaxWidth,
|
||||
plusMaxWidth: plusMaxWidth,
|
||||
measureWidth: measureWidth,
|
||||
);
|
||||
} catch (_, __) {
|
||||
if (tags.isEmpty) {
|
||||
return (displayed: const [], hiddenCount: 0);
|
||||
}
|
||||
|
||||
return (
|
||||
displayed: [tags.first],
|
||||
hiddenCount: tags.length - 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static ({int fitCount, double usedWidth}) _computeFitCount({
|
||||
required List<double> widths,
|
||||
required double maxWidth,
|
||||
required double itemSpacing,
|
||||
}) {
|
||||
double used = 0;
|
||||
int fit = 0;
|
||||
|
||||
for (var i = 0; i < widths.length; i++) {
|
||||
final space = (i == 0) ? 0 : itemSpacing;
|
||||
final next = used + space + widths[i];
|
||||
|
||||
if (next <= maxWidth) {
|
||||
used = next;
|
||||
fit++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return (fitCount: fit, usedWidth: used);
|
||||
}
|
||||
|
||||
static Label _buildPlusLabel(int n) => Label(
|
||||
id: Id(LabelExtension.moreLabelId),
|
||||
displayName: n > 999 ? '+999+' : '+$n',
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import 'package:core/presentation/utils/theme_utils.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:labels/labels.dart';
|
||||
import 'package:tmail_ui_user/features/labels/presentation/utils/label_utils.dart';
|
||||
import 'package:tmail_ui_user/features/labels/presentation/widgets/label_widget.dart';
|
||||
|
||||
class LabelTagListWidget extends StatelessWidget {
|
||||
final List<Label> tags;
|
||||
final double horizontalPadding;
|
||||
final double itemSpacing;
|
||||
|
||||
const LabelTagListWidget({
|
||||
super.key,
|
||||
required this.tags,
|
||||
this.horizontalPadding = 4,
|
||||
this.itemSpacing = 12,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (_, constraints) {
|
||||
final maxWidth = constraints.maxWidth;
|
||||
|
||||
final tagMaxWidth =
|
||||
maxWidth - LabelUtils.defaultPlusMaxWidth - itemSpacing;
|
||||
|
||||
final displayedTagsDataResult = LabelUtils.safeComputeDisplayedTags(
|
||||
tags: tags,
|
||||
maxWidth: maxWidth,
|
||||
textStyle: ThemeUtils.textStyleContentCaption(),
|
||||
itemSpacing: itemSpacing,
|
||||
horizontalPadding: horizontalPadding,
|
||||
tagMaxWidth: tagMaxWidth,
|
||||
plusMaxWidth: LabelUtils.defaultPlusMaxWidth,
|
||||
);
|
||||
|
||||
return Wrap(
|
||||
spacing: itemSpacing,
|
||||
runSpacing: 6,
|
||||
children: displayedTagsDataResult.displayed
|
||||
.map(
|
||||
(tagDisplayed) => LabelWidget(
|
||||
label: tagDisplayed,
|
||||
horizontalPadding: horizontalPadding,
|
||||
maxWidth: tagMaxWidth,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:labels/extensions/label_extension.dart';
|
||||
import 'package:labels/model/label.dart';
|
||||
import 'package:tmail_ui_user/features/base/widget/labels/tag_widget.dart';
|
||||
|
||||
class LabelWidget extends StatelessWidget {
|
||||
final Label label;
|
||||
final double horizontalPadding;
|
||||
final double? maxWidth;
|
||||
|
||||
const LabelWidget({
|
||||
super.key,
|
||||
required this.label,
|
||||
this.horizontalPadding = 4,
|
||||
this.maxWidth,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TagWidget(
|
||||
text: label.safeDisplayName,
|
||||
backgroundColor: label.backgroundColor,
|
||||
textColor: label.textColor,
|
||||
horizontalPadding: horizontalPadding,
|
||||
maxWidth: maxWidth,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import 'package:jmap_dart_client/jmap/mail/email/keyword_identifier.dart';
|
||||
|
||||
extension ListKeywordIdentifierExtension on List<KeyWordIdentifier> {
|
||||
List<String> get keywordListString =>
|
||||
map((identifier) => identifier.value).toList();
|
||||
}
|
||||
@@ -4,4 +4,7 @@ import 'package:jmap_dart_client/jmap/mail/email/keyword_identifier.dart';
|
||||
extension MapKeywordsExtension on Map<KeyWordIdentifier, bool> {
|
||||
|
||||
Map<String, bool> toMapString() => Map.fromIterables(keys.map((keyword) => keyword.value), values);
|
||||
|
||||
List<KeyWordIdentifier> get enabledKeywords =>
|
||||
entries.where((e) => e.value).map((e) => e.key).toList();
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import 'package:tmail_ui_user/features/base/widget/compose_floating_button.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_action_group_widget.dart';
|
||||
import 'package:tmail_ui_user/features/base/widget/report_message_banner.dart';
|
||||
import 'package:tmail_ui_user/features/email/presentation/extensions/presentation_email_extension.dart';
|
||||
import 'package:tmail_ui_user/features/email/presentation/model/composer_arguments.dart';
|
||||
import 'package:tmail_ui_user/features/email/presentation/model/context_item_email_action.dart';
|
||||
import 'package:tmail_ui_user/features/email/presentation/model/popup_menu_item_email_action.dart';
|
||||
@@ -621,20 +622,27 @@ class ThreadView extends GetWidget<ThreadController>
|
||||
presentationEmail,
|
||||
direction,
|
||||
),
|
||||
child: EmailTileBuilder(
|
||||
key: Key('email_tile_builder_${presentationEmail.id?.asString}'),
|
||||
presentationEmail: presentationEmail,
|
||||
selectAllMode: selectModeAll,
|
||||
isShowingEmailContent: isShowingEmailContent,
|
||||
isSenderImportantFlagEnabled: isSenderImportantFlagEnabled,
|
||||
searchQuery: controller.searchQuery,
|
||||
mailboxContain: presentationEmail.mailboxContain,
|
||||
isSearchEmailRunning: isSearchEmailRunning,
|
||||
isAINeedsActionEnabled: isAINeedsActionEnabled,
|
||||
emailActionClick: _handleEmailActionClicked,
|
||||
onMoreActionClick: (email, position) =>
|
||||
_handleEmailContextMenuAction(context, email, position),
|
||||
),
|
||||
child: Obx(() {
|
||||
final allLabels =
|
||||
controller.mailboxDashBoardController.labelController.labels;
|
||||
final emailLabels = presentationEmail.getLabelList(allLabels);
|
||||
|
||||
return EmailTileBuilder(
|
||||
key: Key('email_tile_builder_${presentationEmail.id?.asString}'),
|
||||
presentationEmail: presentationEmail,
|
||||
selectAllMode: selectModeAll,
|
||||
isShowingEmailContent: isShowingEmailContent,
|
||||
isSenderImportantFlagEnabled: isSenderImportantFlagEnabled,
|
||||
searchQuery: controller.searchQuery,
|
||||
mailboxContain: presentationEmail.mailboxContain,
|
||||
isSearchEmailRunning: controller.searchController.isSearchEmailRunning,
|
||||
isAINeedsActionEnabled: isAINeedsActionEnabled,
|
||||
labels: emailLabels,
|
||||
emailActionClick: _handleEmailActionClicked,
|
||||
onMoreActionClick: (email, position) =>
|
||||
_handleEmailContextMenuAction(context, email, position),
|
||||
);
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:core/presentation/extensions/color_extension.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:labels/model/label.dart';
|
||||
import 'package:model/email/email_action_type.dart';
|
||||
import 'package:model/email/presentation_email.dart';
|
||||
import 'package:model/mailbox/presentation_mailbox.dart';
|
||||
@@ -21,6 +22,7 @@ class EmailTileBuilder extends StatelessWidget with BaseEmailItemTile {
|
||||
final bool isShowingEmailContent;
|
||||
final bool isSenderImportantFlagEnabled;
|
||||
final bool isAINeedsActionEnabled;
|
||||
final List<Label>? labels;
|
||||
final OnPressEmailActionClick? emailActionClick;
|
||||
final OnMoreActionClick? onMoreActionClick;
|
||||
|
||||
@@ -29,6 +31,7 @@ class EmailTileBuilder extends StatelessWidget with BaseEmailItemTile {
|
||||
required this.presentationEmail,
|
||||
required this.selectAllMode,
|
||||
required this.isShowingEmailContent,
|
||||
this.labels,
|
||||
this.searchQuery,
|
||||
this.isSearchEmailRunning = false,
|
||||
this.isSenderImportantFlagEnabled = true,
|
||||
|
||||
@@ -4,12 +4,14 @@ import 'package:core/presentation/views/button/icon_button_web.dart';
|
||||
import 'package:core/presentation/views/button/tmail_button_widget.dart';
|
||||
import 'package:core/presentation/views/responsive/responsive_widget.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:labels/model/label.dart';
|
||||
import 'package:model/email/email_action_type.dart';
|
||||
import 'package:model/email/presentation_email.dart';
|
||||
import 'package:model/extensions/presentation_mailbox_extension.dart';
|
||||
import 'package:model/mailbox/presentation_mailbox.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/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/presentation/mixin/base_email_item_tile.dart';
|
||||
import 'package:tmail_ui_user/features/thread/presentation/widgets/desktop_list_email_action_hover_widget.dart';
|
||||
@@ -28,6 +30,7 @@ class EmailTileBuilder extends StatefulWidget {
|
||||
final bool isShowingEmailContent;
|
||||
final bool isSenderImportantFlagEnabled;
|
||||
final bool isAINeedsActionEnabled;
|
||||
final List<Label>? labels;
|
||||
final OnPressEmailActionClick? emailActionClick;
|
||||
final OnMoreActionClick? onMoreActionClick;
|
||||
|
||||
@@ -36,6 +39,7 @@ class EmailTileBuilder extends StatefulWidget {
|
||||
required this.presentationEmail,
|
||||
required this.selectAllMode,
|
||||
required this.isShowingEmailContent,
|
||||
this.labels,
|
||||
this.searchQuery,
|
||||
this.isSearchEmailRunning = false,
|
||||
this.isSenderImportantFlagEnabled = true,
|
||||
@@ -370,34 +374,102 @@ class _EmailTileBuilderState extends State<EmailTileBuilder> with BaseEmailItem
|
||||
}
|
||||
|
||||
Widget _buildSubjectAndContent() {
|
||||
return LayoutBuilder(builder: (context, constraints) {
|
||||
return Row(children: [
|
||||
final emailTitle = widget.presentationEmail.getEmailTitle();
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
if (widget.presentationEmail.hasCalendarEvent)
|
||||
buildCalendarEventIcon(context: context, presentationEmail: widget.presentationEmail),
|
||||
if (widget.presentationEmail.isMarkAsImportant && widget.isSenderImportantFlagEnabled)
|
||||
buildCalendarEventIcon(
|
||||
context: context,
|
||||
presentationEmail: widget.presentationEmail,
|
||||
),
|
||||
if (widget.presentationEmail.isMarkAsImportant &&
|
||||
widget.isSenderImportantFlagEnabled)
|
||||
buildMarkAsImportantIcon(context),
|
||||
if (_shouldShowAIAction)
|
||||
const AiActionTagWidget(margin: EdgeInsetsDirectional.only(end: 12)),
|
||||
if (widget.presentationEmail.getEmailTitle().isNotEmpty)
|
||||
Container(
|
||||
constraints: BoxConstraints(maxWidth: constraints.maxWidth / 2),
|
||||
padding: const EdgeInsetsDirectional.only(end: 12),
|
||||
child: buildEmailTitle(
|
||||
context,
|
||||
widget.presentationEmail,
|
||||
widget.isSearchEmailRunning,
|
||||
widget.searchQuery
|
||||
)),
|
||||
Expanded(
|
||||
child: buildEmailPartialContent(
|
||||
context,
|
||||
widget.presentationEmail,
|
||||
widget.isSearchEmailRunning,
|
||||
widget.searchQuery
|
||||
child: LayoutBuilder(
|
||||
builder: (_, constraints) {
|
||||
final maxWidth = constraints.maxWidth;
|
||||
double horizontalPadding = 12;
|
||||
final maxWidthNoPadding = maxWidth - horizontalPadding * 2;
|
||||
|
||||
final labelTagsMaxWidth = emailTitle.isNotEmpty
|
||||
? maxWidthNoPadding * 0.4
|
||||
: maxWidthNoPadding * 0.5;
|
||||
|
||||
final emailTitleMaxWidth = widget.labels?.isNotEmpty == true
|
||||
? maxWidthNoPadding * 0.4
|
||||
: maxWidthNoPadding * 0.5;
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
if (widget.labels?.isNotEmpty == true) ...[
|
||||
Container(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: labelTagsMaxWidth,
|
||||
),
|
||||
padding: EdgeInsetsDirectional.only(
|
||||
end: horizontalPadding,
|
||||
),
|
||||
child: LabelTagListWidget(tags: widget.labels!),
|
||||
),
|
||||
if (emailTitle.isNotEmpty)
|
||||
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,
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
if (emailTitle.isNotEmpty)
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
]);
|
||||
});
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAvatarIcon({
|
||||
|
||||
Reference in New Issue
Block a user