TF-4171 Display tag in mailbox list

This commit is contained in:
dab246
2025-11-25 14:02:50 +07:00
committed by Dat H. Pham
parent f4302e06e8
commit 31ed2feae4
19 changed files with 1865 additions and 38 deletions
+2
View File
@@ -18,6 +18,7 @@ export 'presentation/extensions/map_extensions.dart';
export 'presentation/extensions/either_view_state_extension.dart';
export 'presentation/extensions/media_type_extension.dart';
export 'presentation/extensions/scroll_controller_extension.dart';
export 'presentation/extensions/hex_color_extension.dart';
// Exceptions
export 'domain/exceptions/download_file_exception.dart';
@@ -63,6 +64,7 @@ export 'utils/web_link_generator.dart';
export 'utils/sentry/sentry_manager.dart';
export 'utils/config/env_loader.dart';
export 'utils/sentry/sentry_dio_helper.dart';
export 'utils/widget_utils.dart';
// Views
export 'presentation/views/text/slogan_builder.dart';
@@ -0,0 +1,18 @@
import 'package:flutter/material.dart';
extension HexColorExtension on String {
Color toColor({Color fallback = Colors.transparent}) {
try {
final hex = replaceAll('#', '');
if (hex.length != 6 && hex.length != 8) {
return fallback;
}
final fullHex = hex.length == 6 ? 'FF$hex' : hex;
return Color(int.parse(fullHex, radix: 16));
} catch (_) {
return fallback;
}
}
}
+33
View File
@@ -0,0 +1,33 @@
import 'package:flutter/material.dart';
class WidgetUtils {
WidgetUtils._();
static double measureTextWidth({
required String text,
double fallbackWidth = 0,
TextStyle? style,
Locale? locale,
double? horizontalPadding,
}) {
try {
final textPainter = TextPainter(
text: TextSpan(
text: text,
style: style,
locale: locale,
),
maxLines: 1,
textDirection: TextDirection.ltr,
)..layout();
if (horizontalPadding != null) {
return textPainter.width + horizontalPadding * 2;
} else {
return textPainter.width;
}
} catch (e) {
return fallbackWidth;
}
}
}
@@ -0,0 +1,57 @@
import 'package:core/presentation/extensions/hex_color_extension.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('HexColorExtension.toColor', () {
test('should convert 6-char hex (#RRGGBB) to Color with default alpha FF',
() {
final color = "#FF0000".toColor();
expect(color, const Color(0xFFFF0000));
});
test('should convert 8-char hex (#AARRGGBB) correctly', () {
final color = "#80FF0000".toColor();
expect(color, const Color(0x80FF0000));
});
test('should handle string without # prefix', () {
final color = "00FF00".toColor();
expect(color, const Color(0xFF00FF00));
});
test('should return fallback when hex is invalid', () {
const fallback = Colors.blue;
final color = "INVALID".toColor(fallback: fallback);
expect(color, fallback);
});
test('should return fallback when hex contains non-hex characters', () {
const fallback = Colors.red;
final color = "#GGHHHH".toColor(fallback: fallback);
expect(color, fallback);
});
test('should return fallback when hex length is unsupported', () {
const fallback = Colors.orange;
final color = "#FFF".toColor(fallback: fallback);
expect(color, fallback);
});
test('should return fallback for empty string', () {
const fallback = Colors.purple;
final color = "".toColor(fallback: fallback);
expect(color, fallback);
});
test('should convert lowercase hex', () {
final color = "#ff0000".toColor();
expect(color, const Color(0xFFFF0000));
});
test('should convert mixed-case hex', () {
final color = "#Ff00Ff".toColor();
expect(color, const Color(0xFFFf00Ff));
});
});
}
@@ -0,0 +1,26 @@
import 'package:core/presentation/extensions/color_extension.dart';
import 'package:core/presentation/extensions/hex_color_extension.dart';
import 'package:flutter/material.dart';
import 'package:labels/model/label.dart';
extension LabelExtension on Label {
static const String moreLabelId = 'more-btn';
String get safeDisplayName => displayName ?? '';
Color? get backgroundColor {
if (id?.value == moreLabelId) {
return AppColor.grayBackgroundColor;
} else {
return color?.value.toColor();
}
}
Color get textColor {
if (id?.value == moreLabelId) {
return Colors.black;
} else {
return Colors.white;
}
}
}
+720 -3
View File
@@ -17,6 +17,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "5.13.0"
archive:
dependency: transitive
description:
name: archive
sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd"
url: "https://pub.dev"
source: hosted
version: "4.0.7"
args:
dependency: transitive
description:
@@ -33,6 +41,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.13.0"
barcode:
dependency: transitive
description:
name: barcode
sha256: "7b6729c37e3b7f34233e2318d866e8c48ddb46c1f7ad01ff7bb2a8de1da2b9f4"
url: "https://pub.dev"
source: hosted
version: "2.2.9"
bidi:
dependency: transitive
description:
name: bidi
sha256: "77f475165e94b261745cf1032c751e2032b8ed92ccb2bf5716036db79320637d"
url: "https://pub.dev"
source: hosted
version: "2.0.13"
boolean_selector:
dependency: transitive
description:
@@ -113,6 +137,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.0"
charcode:
dependency: transitive
description:
name: charcode
sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a
url: "https://pub.dev"
source: hosted
version: "1.4.0"
checked_yaml:
dependency: transitive
description:
@@ -153,6 +185,21 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.1.2"
core:
dependency: "direct main"
description:
path: "../core"
relative: true
source: path
version: "1.0.0+1"
cross_file:
dependency: transitive
description:
name: cross_file
sha256: "701dcfc06da0882883a2657c445103380e53e647060ad8d9dfb710c100996608"
url: "https://pub.dev"
source: hosted
version: "0.3.5+1"
crypto:
dependency: transitive
description:
@@ -161,6 +208,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.7"
csslib:
dependency: transitive
description:
name: csslib
sha256: "831883fb353c8bdc1d71979e5b342c7d88acfbc643113c14ae51e2442ea0f20f"
url: "https://pub.dev"
source: hosted
version: "0.17.3"
cupertino_icons:
dependency: transitive
description:
name: cupertino_icons
sha256: d57953e10f9f8327ce64a508a355f0b1ec902193f66288e8cb5070e7c47eeb2d
url: "https://pub.dev"
source: hosted
version: "1.0.6"
dart_style:
dependency: transitive
description:
@@ -177,6 +240,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.10.1"
debounce_throttle:
dependency: transitive
description:
name: debounce_throttle
sha256: c95cf47afda975fc507794a52040a16756fb2f31ad3027d4e691c41862ff5692
url: "https://pub.dev"
source: hosted
version: "2.0.0"
device_info_plus:
dependency: transitive
description:
name: device_info_plus
sha256: "499c61743e13909c13374a8c209075385858c614b9c0f2487b5f9995eeaf7369"
url: "https://pub.dev"
source: hosted
version: "9.0.1"
device_info_plus_platform_interface:
dependency: transitive
description:
name: device_info_plus_platform_interface
sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f
url: "https://pub.dev"
source: hosted
version: "7.0.3"
dio:
dependency: "direct main"
description:
@@ -201,14 +288,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.3.3"
ffi:
dependency: transitive
description:
name: ffi
sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
file:
dependency: transitive
description:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
sha256: "1b92bec4fc2a72f59a8e15af5f52cd441e4a7860b49499d69dfa817af20e925d"
url: "https://pub.dev"
source: hosted
version: "7.0.1"
version: "6.1.4"
fixnum:
dependency: transitive
description:
@@ -217,11 +312,227 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.1"
fk_user_agent:
dependency: transitive
description:
name: fk_user_agent
sha256: fd6c94e120786985a292d12f61422a581f4e851148d5940af38b819357b8ad0d
url: "https://pub.dev"
source: hosted
version: "2.1.0"
flex_color_picker:
dependency: transitive
description:
name: flex_color_picker
sha256: f0e0db8e3e47435cfbe9aa15c71b898fa218be0fc4ae409e1e42d5d5266b2c90
url: "https://pub.dev"
source: hosted
version: "3.2.0"
flex_seed_scheme:
dependency: transitive
description:
name: flex_seed_scheme
sha256: "4cee2f1d07259f77e8b36f4ec5f35499d19e74e17c7dce5b819554914082bc01"
url: "https://pub.dev"
source: hosted
version: "1.5.0"
flutter:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_charset_detector:
dependency: transitive
description:
name: flutter_charset_detector
sha256: "5d4796d43dac2f37e14149b0f0c676fa08e8eb1ada8e99342a838d971d2fb9b1"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
flutter_charset_detector_android:
dependency: transitive
description:
name: flutter_charset_detector_android
sha256: bee057133d5f134fe211523a27200dd22c782b93777f43359a571bd2c77da2cc
url: "https://pub.dev"
source: hosted
version: "2.1.1"
flutter_charset_detector_darwin:
dependency: transitive
description:
name: flutter_charset_detector_darwin
sha256: "8cf51c3e16c2fb4ec4e309f16f6046a0ddf1ff57d1b6b696410d077a9ffbfb15"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
flutter_charset_detector_platform_interface:
dependency: transitive
description:
name: flutter_charset_detector_platform_interface
sha256: "1c09ed7b314a5a9dde76057b98b7d35458ba881eed03d5e5b6f7f74b4869d18c"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
flutter_charset_detector_web:
dependency: transitive
description:
name: flutter_charset_detector_web
sha256: b547194e97e15d2cca17e957ad7f373b48abf35080e645ac5b6976d129b0265d
url: "https://pub.dev"
source: hosted
version: "1.2.0"
flutter_image_compress:
dependency: transitive
description:
name: flutter_image_compress
sha256: f159d2e8c4ed04b8e36994124fd4a5017a0f01e831ae3358c74095c340e9ae5e
url: "https://pub.dev"
source: hosted
version: "2.1.0"
flutter_image_compress_common:
dependency: transitive
description:
name: flutter_image_compress_common
sha256: c5c5d50c15e97dd7dc72ff96bd7077b9f791932f2076c5c5b6c43f2c88607bfb
url: "https://pub.dev"
source: hosted
version: "1.0.6"
flutter_image_compress_macos:
dependency: transitive
description:
name: flutter_image_compress_macos
sha256: "20019719b71b743aba0ef874ed29c50747461e5e8438980dfa5c2031898f7337"
url: "https://pub.dev"
source: hosted
version: "1.0.3"
flutter_image_compress_platform_interface:
dependency: transitive
description:
name: flutter_image_compress_platform_interface
sha256: "579cb3947fd4309103afe6442a01ca01e1e6f93dc53bb4cbd090e8ce34a41889"
url: "https://pub.dev"
source: hosted
version: "1.0.5"
flutter_image_compress_web:
dependency: transitive
description:
name: flutter_image_compress_web
sha256: b9b141ac7c686a2ce7bb9a98176321e1182c9074650e47bb140741a44b6f5a96
url: "https://pub.dev"
source: hosted
version: "0.1.5"
flutter_inappwebview:
dependency: transitive
description:
name: flutter_inappwebview
sha256: e5caf1649d082e333e22031c84a24d345d2e76e136345481d7ab495c2069d8ec
url: "https://pub.dev"
source: hosted
version: "6.1.0"
flutter_inappwebview_android:
dependency: transitive
description:
name: flutter_inappwebview_android
sha256: "62557c15a5c2db5d195cb3892aab74fcaec266d7b86d59a6f0027abd672cddba"
url: "https://pub.dev"
source: hosted
version: "1.1.3"
flutter_inappwebview_internal_annotations:
dependency: transitive
description:
name: flutter_inappwebview_internal_annotations
sha256: "787171d43f8af67864740b6f04166c13190aa74a1468a1f1f1e9ee5b90c359cd"
url: "https://pub.dev"
source: hosted
version: "1.2.0"
flutter_inappwebview_ios:
dependency: transitive
description:
name: flutter_inappwebview_ios
sha256: "5818cf9b26cf0cbb0f62ff50772217d41ea8d3d9cc00279c45f8aabaa1b4025d"
url: "https://pub.dev"
source: hosted
version: "1.1.2"
flutter_inappwebview_macos:
dependency: transitive
description:
name: flutter_inappwebview_macos
sha256: c1fbb86af1a3738e3541364d7d1866315ffb0468a1a77e34198c9be571287da1
url: "https://pub.dev"
source: hosted
version: "1.1.2"
flutter_inappwebview_platform_interface:
dependency: transitive
description:
name: flutter_inappwebview_platform_interface
sha256: cf5323e194096b6ede7a1ca808c3e0a078e4b33cc3f6338977d75b4024ba2500
url: "https://pub.dev"
source: hosted
version: "1.3.0+1"
flutter_inappwebview_web:
dependency: transitive
description:
name: flutter_inappwebview_web
sha256: "55f89c83b0a0d3b7893306b3bb545ba4770a4df018204917148ebb42dc14a598"
url: "https://pub.dev"
source: hosted
version: "1.1.2"
flutter_inappwebview_windows:
dependency: transitive
description:
name: flutter_inappwebview_windows
sha256: "2c1c13c32835c4a0afa49aabc207cd5cf80967c00bfdfe49259d082182d7c81f"
url: "https://pub.dev"
source: hosted
version: "0.1.0"
flutter_keyboard_visibility:
dependency: transitive
description:
name: flutter_keyboard_visibility
sha256: "98664be7be0e3ffca00de50f7f6a287ab62c763fc8c762e0a21584584a3ff4f8"
url: "https://pub.dev"
source: hosted
version: "6.0.0"
flutter_keyboard_visibility_linux:
dependency: transitive
description:
name: flutter_keyboard_visibility_linux
sha256: "6fba7cd9bb033b6ddd8c2beb4c99ad02d728f1e6e6d9b9446667398b2ac39f08"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
flutter_keyboard_visibility_macos:
dependency: transitive
description:
name: flutter_keyboard_visibility_macos
sha256: c5c49b16fff453dfdafdc16f26bdd8fb8d55812a1d50b0ce25fc8d9f2e53d086
url: "https://pub.dev"
source: hosted
version: "1.0.0"
flutter_keyboard_visibility_platform_interface:
dependency: transitive
description:
name: flutter_keyboard_visibility_platform_interface
sha256: e43a89845873f7be10cb3884345ceb9aebf00a659f479d1c8f4293fcb37022a4
url: "https://pub.dev"
source: hosted
version: "2.0.0"
flutter_keyboard_visibility_web:
dependency: transitive
description:
name: flutter_keyboard_visibility_web
sha256: d3771a2e752880c79203f8d80658401d0c998e4183edca05a149f5098ce6e3d1
url: "https://pub.dev"
source: hosted
version: "2.0.0"
flutter_keyboard_visibility_windows:
dependency: transitive
description:
name: flutter_keyboard_visibility_windows
sha256: fc4b0f0b6be9b93ae527f3d527fb56ee2d918cd88bbca438c478af7bcfd0ef73
url: "https://pub.dev"
source: hosted
version: "1.0.0"
flutter_lints:
dependency: "direct dev"
description:
@@ -230,11 +541,32 @@ packages:
url: "https://pub.dev"
source: hosted
version: "5.0.0"
flutter_svg:
dependency: transitive
description:
name: flutter_svg
sha256: d44bf546b13025ec7353091516f6881f1d4c633993cb109c3916c3a0159dadf1
url: "https://pub.dev"
source: hosted
version: "2.1.0"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
flutter_typeahead:
dependency: transitive
description:
name: flutter_typeahead
sha256: ef2dd5a505d2d95a5b4c571c81ad2d6e7955f583dddec49064fec57acffd7a96
url: "https://pub.dev"
source: hosted
version: "5.0.2"
flutter_web_plugins:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
frontend_server_client:
dependency: transitive
description:
@@ -243,6 +575,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.0.0"
get:
dependency: transitive
description:
name: get
sha256: e4e7335ede17452b391ed3b2ede016545706c01a02292a6c97619705e7d2a85e
url: "https://pub.dev"
source: hosted
version: "4.6.6"
glob:
dependency: transitive
description:
@@ -259,6 +599,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.2"
html:
dependency: transitive
description:
name: html
sha256: "58e3491f7bf0b6a4ea5110c0c688877460d1a6366731155c4a4580e7ded773e8"
url: "https://pub.dev"
source: hosted
version: "0.15.3"
html_unescape:
dependency: transitive
description:
name: html_unescape
sha256: "15362d7a18f19d7b742ef8dcb811f5fd2a2df98db9f80ea393c075189e0b61e3"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
http:
dependency: transitive
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.dev"
source: hosted
version: "1.6.0"
http_mock_adapter:
dependency: "direct dev"
description:
@@ -283,6 +647,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.0.2"
image:
dependency: transitive
description:
name: image
sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928"
url: "https://pub.dev"
source: hosted
version: "4.5.4"
intl:
dependency: transitive
description:
name: intl
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
url: "https://pub.dev"
source: hosted
version: "0.20.2"
io:
dependency: transitive
description:
@@ -348,6 +728,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.1"
linkify:
dependency: transitive
description:
name: linkify
sha256: "4139ea77f4651ab9c315b577da2dd108d9aa0bd84b5d03d33323f1970c645832"
url: "https://pub.dev"
source: hosted
version: "5.0.0"
lints:
dependency: transitive
description:
@@ -404,6 +792,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.0"
package_info_plus:
dependency: transitive
description:
name: package_info_plus
sha256: a75164ade98cb7d24cfd0a13c6408927c6b217fa60dee5a7ff5c116a58f28918
url: "https://pub.dev"
source: hosted
version: "8.0.2"
package_info_plus_platform_interface:
dependency: transitive
description:
name: package_info_plus_platform_interface
sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086"
url: "https://pub.dev"
source: hosted
version: "3.2.1"
path:
dependency: transitive
description:
@@ -412,6 +816,134 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.9.1"
path_parsing:
dependency: transitive
description:
name: path_parsing
sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
path_provider:
dependency: transitive
description:
name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
url: "https://pub.dev"
source: hosted
version: "2.1.5"
path_provider_android:
dependency: transitive
description:
name: path_provider_android
sha256: "3b4c1fc3aa55ddc9cd4aa6759984330d5c8e66aa7702a6223c61540dc6380c37"
url: "https://pub.dev"
source: hosted
version: "2.2.19"
path_provider_foundation:
dependency: transitive
description:
name: path_provider_foundation
sha256: "16eef174aacb07e09c351502740fa6254c165757638eba1e9116b0a781201bbd"
url: "https://pub.dev"
source: hosted
version: "2.4.2"
path_provider_linux:
dependency: transitive
description:
name: path_provider_linux
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
url: "https://pub.dev"
source: hosted
version: "2.2.1"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
url: "https://pub.dev"
source: hosted
version: "2.3.0"
pdf:
dependency: transitive
description:
name: pdf
sha256: "28eacad99bffcce2e05bba24e50153890ad0255294f4dd78a17075a2ba5c8416"
url: "https://pub.dev"
source: hosted
version: "3.11.3"
pdf_widget_wrapper:
dependency: transitive
description:
name: pdf_widget_wrapper
sha256: c930860d987213a3d58c7ec3b7ecf8085c3897f773e8dc23da9cae60a5d6d0f5
url: "https://pub.dev"
source: hosted
version: "1.0.4"
petitparser:
dependency: transitive
description:
name: petitparser
sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1"
url: "https://pub.dev"
source: hosted
version: "7.0.1"
platform:
dependency: transitive
description:
name: platform
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
url: "https://pub.dev"
source: hosted
version: "3.1.6"
plugin_platform_interface:
dependency: transitive
description:
name: plugin_platform_interface
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
url: "https://pub.dev"
source: hosted
version: "2.1.8"
pointer_interceptor:
dependency: transitive
description:
name: pointer_interceptor
sha256: "57210410680379aea8b1b7ed6ae0c3ad349bfd56fe845b8ea934a53344b9d523"
url: "https://pub.dev"
source: hosted
version: "0.10.1+2"
pointer_interceptor_ios:
dependency: transitive
description:
name: pointer_interceptor_ios
sha256: a6906772b3205b42c44614fcea28f818b1e5fdad73a4ca742a7bd49818d9c917
url: "https://pub.dev"
source: hosted
version: "0.10.1"
pointer_interceptor_platform_interface:
dependency: transitive
description:
name: pointer_interceptor_platform_interface
sha256: "0597b0560e14354baeb23f8375cd612e8bd4841bf8306ecb71fcd0bb78552506"
url: "https://pub.dev"
source: hosted
version: "0.10.0+1"
pointer_interceptor_web:
dependency: transitive
description:
name: pointer_interceptor_web
sha256: "460b600e71de6fcea2b3d5f662c92293c049c4319e27f0829310e5a953b3ee2a"
url: "https://pub.dev"
source: hosted
version: "0.10.3"
pool:
dependency: transitive
description:
@@ -420,6 +952,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.5.2"
posix:
dependency: transitive
description:
name: posix
sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61"
url: "https://pub.dev"
source: hosted
version: "6.0.3"
printing:
dependency: transitive
description:
name: printing
sha256: "1c99cab90ebcc1fff65831d264627d5b529359d563e53f33ab9b8117f2d280bc"
url: "https://pub.dev"
source: hosted
version: "5.12.0"
pub_semver:
dependency: transitive
description:
@@ -436,6 +984,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.0"
pull_to_refresh:
dependency: transitive
description:
name: pull_to_refresh
sha256: bbadd5a931837b57739cf08736bea63167e284e71fb23b218c8c9a6e042aad12
url: "https://pub.dev"
source: hosted
version: "2.0.0"
qr:
dependency: transitive
description:
name: qr
sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
quiver:
dependency: "direct main"
description:
@@ -444,6 +1008,15 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.2.1"
sanitize_html:
dependency: transitive
description:
path: sanitize_html
ref: support_mail
resolved-ref: fda32cde4d4baadaa988477f498ab6622ee79987
url: "https://github.com/linagora/dart-neats.git"
source: git
version: "2.1.0"
shelf:
dependency: transitive
description:
@@ -460,6 +1033,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.1"
simple_observable:
dependency: transitive
description:
name: simple_observable
sha256: b392795c48f8b5f301b4c8f73e15f56e38fe70f42278c649d8325e859a783301
url: "https://pub.dev"
source: hosted
version: "2.0.0"
sky_engine:
dependency: transitive
description: flutter
@@ -553,6 +1134,110 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.0"
universal_html:
dependency: transitive
description:
name: universal_html
sha256: "56536254004e24d9d8cfdb7dbbf09b74cf8df96729f38a2f5c238163e3d58971"
url: "https://pub.dev"
source: hosted
version: "2.2.4"
universal_io:
dependency: transitive
description:
name: universal_io
sha256: f63cbc48103236abf48e345e07a03ce5757ea86285ed313a6a032596ed9301e2
url: "https://pub.dev"
source: hosted
version: "2.3.1"
url_launcher:
dependency: transitive
description:
name: url_launcher
sha256: "9d06212b1362abc2f0f0d78e6f09f726608c74e3b9462e8368bb03314aa8d603"
url: "https://pub.dev"
source: hosted
version: "6.3.1"
url_launcher_android:
dependency: transitive
description:
name: url_launcher_android
sha256: "81777b08c498a292d93ff2feead633174c386291e35612f8da438d6e92c4447e"
url: "https://pub.dev"
source: hosted
version: "6.3.20"
url_launcher_ios:
dependency: transitive
description:
name: url_launcher_ios
sha256: d80b3f567a617cb923546034cc94bfe44eb15f989fe670b37f26abdb9d939cb7
url: "https://pub.dev"
source: hosted
version: "6.3.4"
url_launcher_linux:
dependency: transitive
description:
name: url_launcher_linux
sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a
url: "https://pub.dev"
source: hosted
version: "3.2.2"
url_launcher_macos:
dependency: transitive
description:
name: url_launcher_macos
sha256: c043a77d6600ac9c38300567f33ef12b0ef4f4783a2c1f00231d2b1941fea13f
url: "https://pub.dev"
source: hosted
version: "3.2.3"
url_launcher_platform_interface:
dependency: transitive
description:
name: url_launcher_platform_interface
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
url_launcher_web:
dependency: transitive
description:
name: url_launcher_web
sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
url_launcher_windows:
dependency: transitive
description:
name: url_launcher_windows
sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f"
url: "https://pub.dev"
source: hosted
version: "3.1.5"
vector_graphics:
dependency: transitive
description:
name: vector_graphics
sha256: a4f059dc26fc8295b5921376600a194c4ec7d55e72f2fe4c7d2831e103d461e6
url: "https://pub.dev"
source: hosted
version: "1.1.19"
vector_graphics_codec:
dependency: transitive
description:
name: vector_graphics_codec
sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146"
url: "https://pub.dev"
source: hosted
version: "1.1.13"
vector_graphics_compiler:
dependency: transitive
description:
name: vector_graphics_compiler
sha256: d354a7ec6931e6047785f4db12a1f61ec3d43b207fc0790f863818543f8ff0dc
url: "https://pub.dev"
source: hosted
version: "1.1.19"
vector_math:
dependency: transitive
description:
@@ -601,6 +1286,38 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.3"
win32:
dependency: transitive
description:
name: win32
sha256: "8b338d4486ab3fbc0ba0db9f9b4f5239b6697fcee427939a40e720cbb9ee0a69"
url: "https://pub.dev"
source: hosted
version: "5.9.0"
win32_registry:
dependency: transitive
description:
name: win32_registry
sha256: "21ec76dfc731550fd3e2ce7a33a9ea90b828fdf19a5c3bcf556fa992cfa99852"
url: "https://pub.dev"
source: hosted
version: "1.1.5"
xdg_directories:
dependency: transitive
description:
name: xdg_directories
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
xml:
dependency: transitive
description:
name: xml
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
url: "https://pub.dev"
source: hosted
version: "6.6.1"
yaml:
dependency: transitive
description:
@@ -611,4 +1328,4 @@ packages:
version: "3.1.3"
sdks:
dart: ">=3.8.0 <4.0.0"
flutter: ">=3.18.0-18.0.pre.54"
flutter: ">=3.32.0"
+3
View File
@@ -11,6 +11,9 @@ dependencies:
flutter:
sdk: flutter
core:
path: ../core
### Dependencies from git ###
jmap_dart_client:
git:
@@ -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({
@@ -0,0 +1,482 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:labels/extensions/label_extension.dart';
import 'package:labels/model/label.dart';
import 'package:tmail_ui_user/features/labels/presentation/utils/label_utils.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const testStyle = TextStyle(
fontWeight: FontWeight.normal,
letterSpacing: 0,
fontSize: 11,
height: 14 / 11,
);
const itemSpacing = 12.0;
const horizontalPadding = 4.0;
Label label(String name) => Label(displayName: name);
group('LabelUtils.computeDisplayedTags', () {
test('returns empty result when tag list is empty', () {
final result = LabelUtils.computeDisplayedTags(
tags: [],
maxWidth: 200,
textStyle: testStyle,
itemSpacing: itemSpacing,
horizontalPadding: horizontalPadding,
measureWidth: (_) => 40,
);
expect(result.displayed, isEmpty);
expect(result.hiddenCount, 0);
});
test('displays a single tag when it fits within max width', () {
final result = LabelUtils.computeDisplayedTags(
tags: [label('Completed')],
maxWidth: 60,
textStyle: testStyle,
itemSpacing: itemSpacing,
horizontalPadding: horizontalPadding,
measureWidth: (_) => 60,
);
expect(result.displayed.length, 1);
expect(result.displayed.first.safeDisplayName, 'Completed');
expect(result.hiddenCount, 0);
});
test('shows first tag when single tag does not fit', () {
final result = LabelUtils.computeDisplayedTags(
tags: [label('Completed')],
maxWidth: 59,
textStyle: testStyle,
itemSpacing: itemSpacing,
horizontalPadding: horizontalPadding,
measureWidth: (_) => 60,
);
expect(result.displayed.single.safeDisplayName, 'Completed');
expect(result.hiddenCount, 0);
});
test('displays all tags when both fit without overflow', () {
final result = LabelUtils.computeDisplayedTags(
tags: [label('A'), label('B')],
maxWidth: 100,
textStyle: testStyle,
itemSpacing: itemSpacing,
horizontalPadding: horizontalPadding,
measureWidth: (_) => 40,
);
expect(
result.displayed.map((e) => e.safeDisplayName),
['A', 'B'],
);
expect(result.hiddenCount, 0);
});
test('shows first tag when others overflow immediately', () {
final result = LabelUtils.computeDisplayedTags(
tags: [label('Completed'), label('Action'), label('Info')],
maxWidth: 50,
textStyle: testStyle,
itemSpacing: itemSpacing,
horizontalPadding: horizontalPadding,
measureWidth: (_) => 40,
);
expect(
result.displayed.map((e) => e.safeDisplayName),
['Completed'],
);
expect(result.hiddenCount, 2);
});
test('shows only first tag when +N does not fit', () {
final result = LabelUtils.computeDisplayedTags(
tags: [label('A'), label('B'), label('C')],
maxWidth: 20,
textStyle: testStyle,
itemSpacing: itemSpacing,
horizontalPadding: horizontalPadding,
measureWidth: (_) => 40,
);
expect(result.displayed.single.safeDisplayName, 'A');
expect(result.hiddenCount, 2);
});
test('shows first tag when no tag fits at all', () {
final result = LabelUtils.computeDisplayedTags(
tags: [label('Completed'), label('Action')],
maxWidth: 10,
textStyle: testStyle,
itemSpacing: itemSpacing,
horizontalPadding: horizontalPadding,
measureWidth: (_) => 50,
);
expect(result.displayed.single.safeDisplayName, 'Completed');
expect(result.hiddenCount, 1);
});
test('shrinks down until first tag and +N fit if possible', () {
final result = LabelUtils.computeDisplayedTags(
tags: [label('Completed'), label('Action'), label('Information')],
maxWidth: 92,
textStyle: testStyle,
itemSpacing: itemSpacing,
horizontalPadding: horizontalPadding,
measureWidth: (_) => 40,
);
expect(
result.displayed.map((e) => e.safeDisplayName),
['Completed', '+2'],
);
expect(result.hiddenCount, 2);
});
test('shows all tags when max width allows full rendering', () {
final result = LabelUtils.computeDisplayedTags(
tags: [label('Completed'), label('Action'), label('Information')],
maxWidth: 999,
textStyle: testStyle,
itemSpacing: itemSpacing,
horizontalPadding: horizontalPadding,
measureWidth: (_) => 100,
);
expect(result.displayed.length, 3);
expect(result.hiddenCount, 0);
});
test('falls back to first tag when second tag is too long', () {
final result = LabelUtils.computeDisplayedTags(
tags: [label('Completed'), label('LONG TEXT'), label('A')],
maxWidth: 60,
textStyle: testStyle,
itemSpacing: itemSpacing,
horizontalPadding: horizontalPadding,
measureWidth: (label) =>
label.safeDisplayName == 'Completed' ? 50 : 200,
);
expect(result.displayed.single.safeDisplayName, 'Completed');
expect(result.hiddenCount, 2);
});
test('still only shows first tag when shrinking cannot allow +N', () {
final result = LabelUtils.computeDisplayedTags(
tags: [label('Completed'), label('Action'), label('Information')],
maxWidth: 90,
textStyle: testStyle,
itemSpacing: itemSpacing,
horizontalPadding: horizontalPadding,
measureWidth: (_) => 60,
);
expect(result.displayed.single.safeDisplayName, 'Completed');
expect(result.hiddenCount, 2);
});
test('shows first tag when +N exactly matches max width', () {
final tags = List.generate(4, (i) => label('T$i'));
final result = LabelUtils.computeDisplayedTags(
tags: tags,
maxWidth: 30,
textStyle: testStyle,
itemSpacing: itemSpacing,
horizontalPadding: horizontalPadding,
measureWidth: (_) => 30,
);
expect(result.displayed.single.safeDisplayName, 'T0');
expect(result.hiddenCount, 3);
});
test('shows first tag and +N when both fit', () {
final result = LabelUtils.computeDisplayedTags(
tags: [label('Completed'), label('Action'), label('Info')],
maxWidth: 100,
textStyle: testStyle,
itemSpacing: itemSpacing,
horizontalPadding: horizontalPadding,
measureWidth: (label) => label.safeDisplayName == 'Completed' ? 40 : 30,
);
expect(
result.displayed.map((e) => e.safeDisplayName),
['Completed', '+2'],
);
expect(result.hiddenCount, 2);
});
test('shows only first tag when shrink still exceeds max width', () {
final result = LabelUtils.computeDisplayedTags(
tags: [label('Completed'), label('Action'), label('Info')],
maxWidth: 39,
textStyle: testStyle,
itemSpacing: itemSpacing,
horizontalPadding: horizontalPadding,
measureWidth: (_) => 40,
);
expect(result.displayed.single.safeDisplayName, 'Completed');
expect(result.hiddenCount, 2);
});
test('handles large hidden counts correctly using +N logic', () {
final tags = List.generate(50, (i) => label('Tag$i'));
final result = LabelUtils.computeDisplayedTags(
tags: tags,
maxWidth: 999,
textStyle: testStyle,
itemSpacing: itemSpacing,
horizontalPadding: horizontalPadding,
measureWidth: (_) => 500,
);
expect(result.displayed.single.safeDisplayName, 'Tag0');
expect(result.hiddenCount, 49);
});
test('treats null displayName as empty and keeps first tag', () {
final tags = [
Label(displayName: null),
Label(displayName: 'A'),
];
final result = LabelUtils.computeDisplayedTags(
tags: tags,
maxWidth: 50,
textStyle: testStyle,
itemSpacing: itemSpacing,
horizontalPadding: horizontalPadding,
measureWidth: (label) => label.safeDisplayName.isEmpty ? 10 : 40,
);
expect(result.displayed.single.safeDisplayName, '');
expect(result.hiddenCount, 1);
});
test('correctly fits tags when spacing is zero', () {
final tags = [label('A'), label('B'), label('C')];
final result = LabelUtils.computeDisplayedTags(
tags: tags,
maxWidth: 80,
textStyle: testStyle,
itemSpacing: 0,
horizontalPadding: horizontalPadding,
measureWidth: (_) => 40,
);
expect(
result.displayed.map((e) => e.safeDisplayName),
['A', '+2'],
);
expect(result.hiddenCount, 2);
});
test('handles RTL labels the same as LTR while keeping first tag', () {
final tags = [
label('שלום'),
label('مرحبا'),
label('Hello'),
];
final result = LabelUtils.computeDisplayedTags(
tags: tags,
maxWidth: 80,
textStyle: testStyle,
itemSpacing: itemSpacing,
horizontalPadding: horizontalPadding,
measureWidth: (_) => 40,
);
expect(result.displayed.single.safeDisplayName, 'שלום');
expect(result.hiddenCount, 2);
});
test('shows first tag when first label is extremely long', () {
final tags = [
label('THIS_IS_A_VERY_LONG_LABEL_THAT_WILL_NEVER_FIT'),
label('A'),
label('B'),
];
final result = LabelUtils.computeDisplayedTags(
tags: tags,
maxWidth: 60,
textStyle: testStyle,
itemSpacing: itemSpacing,
horizontalPadding: horizontalPadding,
measureWidth: (label) =>
label.safeDisplayName.startsWith('THIS') ? 500 : 40,
);
expect(result.displayed.single.safeDisplayName,
'THIS_IS_A_VERY_LONG_LABEL_THAT_WILL_NEVER_FIT');
expect(result.hiddenCount, 2);
});
test('computes remaining count correctly with mixed label widths', () {
final tags = [
label('A'),
label('BBBB'),
label('CC'),
label('DDDDDD'),
];
final result = LabelUtils.computeDisplayedTags(
tags: tags,
maxWidth: 95,
textStyle: testStyle,
itemSpacing: itemSpacing,
horizontalPadding: horizontalPadding,
measureWidth: (label) {
switch (label.safeDisplayName.length) {
case 1:
return 30;
case 2:
return 40;
case 4:
return 60;
default:
return 100;
}
},
);
expect(result.displayed.first.safeDisplayName, 'A');
expect(result.hiddenCount, 3);
});
test('shows first tag when both tag and +1 cannot fit', () {
final result = LabelUtils.computeDisplayedTags(
tags: [label('A')],
maxWidth: 10,
textStyle: testStyle,
itemSpacing: itemSpacing,
horizontalPadding: horizontalPadding,
measureWidth: (_) => 20,
);
expect(result.displayed.single.safeDisplayName, 'A');
expect(result.hiddenCount, 0);
});
test('handles duplicate labels consistently and keeps first tag', () {
final tags = [
label('A'),
label('A'),
label('A'),
];
final result = LabelUtils.computeDisplayedTags(
tags: tags,
maxWidth: 45,
textStyle: testStyle,
itemSpacing: itemSpacing,
horizontalPadding: horizontalPadding,
measureWidth: (_) => 40,
);
expect(result.displayed.single.safeDisplayName, 'A');
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', () {
final tags = [
label('SUPER_LONG_TAG_NAME'),
label('B'),
label('C'),
];
final result = LabelUtils.computeDisplayedTags(
tags: tags,
maxWidth: 80,
textStyle: testStyle,
itemSpacing: itemSpacing,
horizontalPadding: horizontalPadding,
tagMaxWidth: 40,
measureWidth: (_) => 200,
);
expect(result.displayed.single.safeDisplayName, 'SUPER_LONG_TAG_NAME');
expect(result.hiddenCount, 2);
});
test('respects plusMaxWidth to prevent +N from exceeding layout', () {
final tags = [
label('A'),
label('B'),
label('C'),
label('D'),
];
final result = LabelUtils.computeDisplayedTags(
tags: tags,
maxWidth: 62,
textStyle: testStyle,
itemSpacing: itemSpacing,
horizontalPadding: horizontalPadding,
plusMaxWidth: 20,
measureWidth: (label) =>
label.safeDisplayName.startsWith('+') ? 50 : 30,
);
expect(
result.displayed.map((e) => e.safeDisplayName),
['A', '+3'],
);
expect(result.hiddenCount, 3);
});
test('performs efficiently with 1k labels', () {
final tags = List.generate(1000, (i) => label('Tag$i'));
final watch = Stopwatch()..start();
final result = LabelUtils.computeDisplayedTags(
tags: tags,
maxWidth: 300,
textStyle: testStyle,
itemSpacing: itemSpacing,
horizontalPadding: horizontalPadding,
measureWidth: (_) => 30,
);
watch.stop();
expect(result.displayed.isNotEmpty, true);
expect(result.hiddenCount, greaterThan(0));
expect(
watch.elapsedMilliseconds < 50,
true,
reason: 'computeDisplayedTags took too long',
);
});
});
}
@@ -457,6 +457,7 @@ void main() {
when(mailboxDashboardController.spamReportController.spamReportState).thenReturn(Rx(SpamReportState.disabled));
when(mailboxDashboardController.spamReportController.presentationSpamMailbox).thenReturn(Rxn(null));
when(mailboxDashboardController.downloadController.listDownloadTaskState).thenReturn(RxList([]));
when(mailboxDashboardController.labelController.labels).thenReturn(RxList([]));
when(composerManager.composers).thenReturn(RxMap({}));
final widget = makeTestableWidget(child: MailboxDashBoardView());
@@ -533,6 +534,7 @@ void main() {
when(mailboxDashboardController.spamReportController.spamReportState).thenReturn(Rx(SpamReportState.disabled));
when(mailboxDashboardController.spamReportController.presentationSpamMailbox).thenReturn(Rxn(null));
when(mailboxDashboardController.downloadController.listDownloadTaskState).thenReturn(RxList([]));
when(mailboxDashboardController.labelController.labels).thenReturn(RxList([]));
when(composerManager.composers).thenReturn(RxMap({}));
final widget = makeTestableWidget(child: MailboxDashBoardView());
@@ -596,6 +598,7 @@ void main() {
when(mailboxDashboardController.spamReportController.spamReportState).thenReturn(Rx(SpamReportState.disabled));
when(mailboxDashboardController.spamReportController.presentationSpamMailbox).thenReturn(Rxn(null));
when(mailboxDashboardController.downloadController.listDownloadTaskState).thenReturn(RxList([]));
when(mailboxDashboardController.labelController.labels).thenReturn(RxList([]));
when(composerManager.composers).thenReturn(RxMap({}));
final widget = makeTestableWidget(child: MailboxDashBoardView());
@@ -656,6 +659,7 @@ void main() {
when(mailboxDashboardController.spamReportController.spamReportState).thenReturn(Rx(SpamReportState.disabled));
when(mailboxDashboardController.spamReportController.presentationSpamMailbox).thenReturn(Rxn(null));
when(mailboxDashboardController.downloadController.listDownloadTaskState).thenReturn(RxList([]));
when(mailboxDashboardController.labelController.labels).thenReturn(RxList([]));
when(composerManager.composers).thenReturn(RxMap({}));
final widget = makeTestableWidget(child: MailboxDashBoardView());
@@ -712,6 +716,7 @@ void main() {
when(mailboxDashboardController.spamReportController.spamReportState).thenReturn(Rx(SpamReportState.disabled));
when(mailboxDashboardController.spamReportController.presentationSpamMailbox).thenReturn(Rxn(null));
when(mailboxDashboardController.downloadController.listDownloadTaskState).thenReturn(RxList([]));
when(mailboxDashboardController.labelController.labels).thenReturn(RxList([]));
when(composerManager.composers).thenReturn(RxMap({}));
final widget = makeTestableWidget(child: MailboxDashBoardView());
@@ -0,0 +1,99 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:jmap_dart_client/jmap/mail/email/keyword_identifier.dart';
import 'package:labels/model/label.dart';
import 'package:model/email/presentation_email.dart';
import 'package:tmail_ui_user/features/email/presentation/extensions/presentation_email_extension.dart';
void main() {
group('getLabelList', () {
test('returns empty when keywords is null', () {
final email = PresentationEmail(keywords: null);
final labels = [Label(keyword: KeyWordIdentifier.emailFlagged.value)];
final result = email.getLabelList(labels);
expect(result, isEmpty);
});
test('returns empty when keywords is empty', () {
final email = PresentationEmail(keywords: {});
final labels = [Label(keyword: KeyWordIdentifier.emailFlagged.value)];
final result = email.getLabelList(labels);
expect(result, isEmpty);
});
test('returns empty when labels is empty', () {
final email = PresentationEmail(keywords: {
KeyWordIdentifier.emailFlagged: true,
});
final result = email.getLabelList([]);
expect(result, isEmpty);
});
test('returns empty when no enabled keywords', () {
final email = PresentationEmail(keywords: {
KeyWordIdentifier.emailFlagged: false,
KeyWordIdentifier.emailDraft: false,
});
final labels = [
Label(keyword: KeyWordIdentifier.emailFlagged.value),
Label(keyword: KeyWordIdentifier.emailDraft.value),
];
final result = email.getLabelList(labels);
expect(result, isEmpty);
});
test('returns only labels with enabled keywords', () {
final email = PresentationEmail(keywords: {
KeyWordIdentifier.emailFlagged: true,
KeyWordIdentifier.emailSeen: false,
KeyWordIdentifier.emailDraft: true,
});
final labels = [
Label(keyword: KeyWordIdentifier.emailFlagged.value),
Label(keyword: KeyWordIdentifier.emailSeen.value),
Label(keyword: KeyWordIdentifier.emailDraft.value),
];
final result = email.getLabelList(labels);
expect(result.length, 2);
expect(
result.map((l) => l.keyword),
contains(KeyWordIdentifier.emailFlagged.value),
);
expect(
result.map((l) => l.keyword),
contains(KeyWordIdentifier.emailDraft.value),
);
expect(
result.map((l) => l.keyword),
isNot(contains(KeyWordIdentifier.emailSeen.value)),
);
});
test('ignores labels where keyword is null', () {
final email = PresentationEmail(keywords: {
KeyWordIdentifier.emailFlagged: true,
});
final labels = [
Label(),
Label(keyword: KeyWordIdentifier.emailFlagged.value),
];
final result = email.getLabelList(labels);
expect(result.length, 1);
expect(result.first.keyword, KeyWordIdentifier.emailFlagged.value);
});
});
}