diff --git a/core/lib/core.dart b/core/lib/core.dart index ec3d07de0..1f4ea1a5e 100644 --- a/core/lib/core.dart +++ b/core/lib/core.dart @@ -64,6 +64,7 @@ export 'presentation/views/text/text_form_field_builder.dart'; export 'presentation/views/text/input_decoration_builder.dart'; export 'presentation/views/text/rich_text_builder.dart'; export 'presentation/views/text/text_overflow_builder.dart'; +export 'presentation/views/text/middle_ellipsis_text.dart'; export 'presentation/views/responsive/responsive_widget.dart'; export 'presentation/views/list/tree_view.dart'; export 'presentation/views/button/icon_button_web.dart'; diff --git a/core/lib/presentation/views/text/middle_ellipsis_text.dart b/core/lib/presentation/views/text/middle_ellipsis_text.dart new file mode 100644 index 000000000..ef49aff7a --- /dev/null +++ b/core/lib/presentation/views/text/middle_ellipsis_text.dart @@ -0,0 +1,135 @@ +import 'package:flutter/material.dart'; + +class MiddleEllipsisText extends StatefulWidget { + final String text; + final TextStyle? style; + + /// Ratio of characters to keep at the start (0–1). Default = 0.5 + final double keepStartFraction; + + const MiddleEllipsisText( + this.text, { + super.key, + this.style, + this.keepStartFraction = 0.5, + }); + + @override + State createState() => _MiddleEllipsisTextState(); +} + +class _MiddleEllipsisTextState extends State { + // Cache fields + String? _cachedText; + String? _cachedStyleKey; + double? _cachedWidth; + String? _cachedResult; + + @override + Widget build(BuildContext context) { + final style = widget.style ?? DefaultTextStyle.of(context).style; + final styleKey = _styleKey(style); + final textDir = Directionality.of(context); + + return LayoutBuilder( + builder: (context, constraints) { + final maxWidth = constraints.maxWidth; + + // Use cached result if nothing changed + if (_cachedText == widget.text && + _cachedStyleKey == styleKey && + _cachedWidth == maxWidth) { + return Text( + _cachedResult!, + style: style, + maxLines: 1, + overflow: TextOverflow.visible, + ); + } + + final truncated = _truncateMiddleByWidth( + widget.text, + maxWidth, + style, + textDir: textDir, + keepStartFraction: widget.keepStartFraction, + ); + + // Update cache + _cachedText = widget.text; + _cachedStyleKey = styleKey; + _cachedWidth = maxWidth; + _cachedResult = truncated; + + return Text( + truncated, + style: style, + maxLines: 1, + overflow: TextOverflow.visible, + ); + }, + ); + } + + String _truncateMiddleByWidth( + String text, + double maxWidth, + TextStyle style, { + required TextDirection textDir, + double keepStartFraction = 0.5, + }) { + final painter = TextPainter( + textDirection: textDir, + maxLines: 1, + ); + + // Measure full text + painter.text = TextSpan(text: text, style: style); + painter.layout(maxWidth: double.infinity); + if (painter.width <= maxWidth) return text; + + // Measure ellipsis width + const ellipsis = '...'; + painter.text = TextSpan(text: ellipsis, style: style); + painter.layout(maxWidth: double.infinity); + final ellipsisWidth = painter.width; + + if (maxWidth <= ellipsisWidth) return ellipsis; + + // Binary search for best prefix+suffix length + int lo = 0, hi = text.length; + String best = ellipsis; + + final f = keepStartFraction.clamp(0.0, 1.0); + + double measure(String t) { + painter.text = TextSpan(text: t, style: style); + painter.layout(maxWidth: double.infinity); + return painter.width; + } + + while (lo <= hi) { + final k = (lo + hi) ~/ 2; + int leftLen = (k * f).round().clamp(0, text.length); + int rightLen = (k - leftLen).clamp(0, text.length - leftLen); + + final candidate = text.substring(0, leftLen) + + ellipsis + + text.substring(text.length - rightLen); + final width = measure(candidate); + + if (width <= maxWidth) { + best = candidate; + lo = k + 1; + } else { + hi = k - 1; + } + } + + return best; + } + + // Cache key based on style properties + String _styleKey(TextStyle s) => + '${s.fontFamily}|${s.fontSize}|${s.fontWeight}|${s.fontStyle}|${s.letterSpacing}|${s.wordSpacing}|${s.height}'; +} diff --git a/lib/features/composer/presentation/extensions/remove_draggable_email_address_between_recipient_fields_extension.dart b/lib/features/composer/presentation/extensions/remove_draggable_email_address_between_recipient_fields_extension.dart index 51a7f0523..c9b87a9a8 100644 --- a/lib/features/composer/presentation/extensions/remove_draggable_email_address_between_recipient_fields_extension.dart +++ b/lib/features/composer/presentation/extensions/remove_draggable_email_address_between_recipient_fields_extension.dart @@ -32,15 +32,19 @@ extension RemoveDraggableEmailAddressBetweenRecipientFieldsExtension on Composer switch(draggableEmailAddress.filterField) { case FilterField.to: controller.listToEmailAddress.remove(draggableEmailAddress.emailAddress); + controller.toRecipientState.refresh(); break; case FilterField.cc: controller.listCcEmailAddress.remove(draggableEmailAddress.emailAddress); + controller.ccRecipientState.refresh(); break; case FilterField.bcc: controller.listBccEmailAddress.remove(draggableEmailAddress.emailAddress); + controller.bccRecipientState.refresh(); break; case FilterField.replyTo: controller.listReplyToEmailAddress.remove(draggableEmailAddress.emailAddress); + controller.replyToRecipientState.refresh(); break; default: break; diff --git a/lib/features/composer/presentation/widgets/attachment_item_composer_widget.dart b/lib/features/composer/presentation/widgets/attachment_item_composer_widget.dart index a76af9490..fecabe3f4 100644 --- a/lib/features/composer/presentation/widgets/attachment_item_composer_widget.dart +++ b/lib/features/composer/presentation/widgets/attachment_item_composer_widget.dart @@ -1,7 +1,6 @@ import 'package:core/presentation/resources/image_paths.dart'; import 'package:core/presentation/views/button/tmail_button_widget.dart'; -import 'package:core/utils/platform_info.dart'; -import 'package:extended_text/extended_text.dart'; +import 'package:core/presentation/views/text/middle_ellipsis_text.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:get/get.dart'; @@ -70,27 +69,11 @@ class AttachmentItemComposerWidget extends StatelessWidget with AppLoaderMixin { fit: BoxFit.fill ), const SizedBox(width: AttachmentItemComposerWidgetStyle.space), - Expanded( - child: PlatformInfo.isCanvasKit - ? ExtendedText( - fileName, - maxLines: 1, - overflowWidget: TextOverflowWidget( - position: TextOverflowPosition.middle, - clearType: TextOverflowClearType.clipRect, - child: Text( - '...', - style: AttachmentItemComposerWidgetStyle.dotsLabelTextStyle, - ), - ), - style: AttachmentItemComposerWidgetStyle.labelTextStyle, - ) - : Text( - fileName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: AttachmentItemComposerWidgetStyle.labelTextStyle, - ) + Flexible( + child: MiddleEllipsisText( + fileName, + style: AttachmentItemComposerWidgetStyle.labelTextStyle, + ) ), const SizedBox(width: AttachmentItemComposerWidgetStyle.space), Text( diff --git a/lib/features/composer/presentation/widgets/draggable_recipient_tag_widget.dart b/lib/features/composer/presentation/widgets/draggable_recipient_tag_widget.dart index 62660f9c0..0cf0174e3 100644 --- a/lib/features/composer/presentation/widgets/draggable_recipient_tag_widget.dart +++ b/lib/features/composer/presentation/widgets/draggable_recipient_tag_widget.dart @@ -3,7 +3,7 @@ import 'package:core/presentation/extensions/string_extension.dart'; import 'package:core/presentation/resources/image_paths.dart'; import 'package:core/presentation/utils/theme_utils.dart'; import 'package:core/presentation/views/avatar/gradient_circle_avatar_icon.dart'; -import 'package:extended_text/extended_text.dart'; +import 'package:core/presentation/views/text/middle_ellipsis_text.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:jmap_dart_client/jmap/mail/email/email_address.dart'; @@ -47,22 +47,8 @@ class DraggableRecipientTagWidget extends StatelessWidget { ), const SizedBox(width: 4), Flexible( - child: ExtendedText( + child: MiddleEllipsisText( emailAddress.asString(), - maxLines: 1, - overflowWidget: TextOverflowWidget( - position: TextOverflowPosition.middle, - clearType: TextOverflowClearType.clipRect, - child: Text( - '...', - style: ThemeUtils.textStyleInter400.copyWith( - color: Colors.white, - fontSize: 17, - height: 1.0, - letterSpacing: -0.17, - ), - ), - ), style: ThemeUtils.textStyleInter400.copyWith( color: Colors.white, fontSize: 17, diff --git a/lib/features/composer/presentation/widgets/recipient_collapsed_item_widget.dart b/lib/features/composer/presentation/widgets/recipient_collapsed_item_widget.dart index 38ea37e1b..f10cdccc5 100644 --- a/lib/features/composer/presentation/widgets/recipient_collapsed_item_widget.dart +++ b/lib/features/composer/presentation/widgets/recipient_collapsed_item_widget.dart @@ -1,7 +1,7 @@ import 'package:core/presentation/extensions/color_extension.dart'; import 'package:core/presentation/extensions/string_extension.dart'; import 'package:core/presentation/views/avatar/gradient_circle_avatar_icon.dart'; -import 'package:extended_text/extended_text.dart'; +import 'package:core/presentation/views/text/middle_ellipsis_text.dart'; import 'package:flutter/material.dart'; import 'package:jmap_dart_client/jmap/mail/email/email_address.dart'; import 'package:model/extensions/email_address_extension.dart'; @@ -40,17 +40,8 @@ class RecipientCollapsedItemWidget extends StatelessWidget { ), const SizedBox(width: 4), Flexible( - child: ExtendedText( + child: MiddleEllipsisText( emailAddress.asString(), - maxLines: 1, - overflowWidget: TextOverflowWidget( - position: TextOverflowPosition.middle, - clearType: TextOverflowClearType.clipRect, - child: Text( - '...', - style: RecipientTagItemWidgetStyle.labelTextStyle, - ), - ), style: RecipientTagItemWidgetStyle.labelTextStyle, ) ), diff --git a/lib/features/composer/presentation/widgets/recipient_composer_widget.dart b/lib/features/composer/presentation/widgets/recipient_composer_widget.dart index c01973a3b..36fbe86fb 100644 --- a/lib/features/composer/presentation/widgets/recipient_composer_widget.dart +++ b/lib/features/composer/presentation/widgets/recipient_composer_widget.dart @@ -284,7 +284,11 @@ class _RecipientComposerWidgetState extends State { builder: (context, candidateData, rejectedData) { return tagEditor; }, - onAcceptWithDetails: (draggableEmailAddress) => _handleAcceptDraggableEmailAddressAction(draggableEmailAddress.data, stateSetter), + onAcceptWithDetails: (draggableEmailAddress) => + _handleAcceptDraggableEmailAddressAction( + draggableEmailAddress.data, + stateSetter, + ), onLeave: (draggableEmailAddress) { if (_isDragging) { stateSetter(() => _isDragging = false); diff --git a/lib/features/composer/presentation/widgets/recipient_tag_item_widget.dart b/lib/features/composer/presentation/widgets/recipient_tag_item_widget.dart index 4a097a6c8..5163a37c3 100644 --- a/lib/features/composer/presentation/widgets/recipient_tag_item_widget.dart +++ b/lib/features/composer/presentation/widgets/recipient_tag_item_widget.dart @@ -3,8 +3,8 @@ import 'package:core/presentation/extensions/string_extension.dart'; import 'package:core/presentation/resources/image_paths.dart'; import 'package:core/presentation/views/avatar/gradient_circle_avatar_icon.dart'; import 'package:core/presentation/views/button/tmail_button_widget.dart'; +import 'package:core/presentation/views/text/middle_ellipsis_text.dart'; import 'package:core/utils/platform_info.dart'; -import 'package:extended_text/extended_text.dart'; import 'package:flutter/material.dart'; import 'package:jmap_dart_client/jmap/mail/email/email_address.dart'; import 'package:model/email/prefix_email_address.dart'; @@ -115,18 +115,9 @@ class RecipientTagItemWidget extends StatelessWidget { ), const SizedBox(width: 4), Flexible( - child: ExtendedText( + child: MiddleEllipsisText( key: Key('label_recipient_tag_item_${prefix.name}_$index'), currentEmailAddress.asString(), - maxLines: 1, - overflowWidget: TextOverflowWidget( - position: TextOverflowPosition.middle, - clearType: TextOverflowClearType.clipRect, - child: Text( - '...', - style: RecipientTagItemWidgetStyle.labelTextStyle, - ), - ), style: RecipientTagItemWidgetStyle.labelTextStyle, ), ), diff --git a/lib/features/email/presentation/widgets/attachment_item_widget.dart b/lib/features/email/presentation/widgets/attachment_item_widget.dart index 458669bf3..532ce26ae 100644 --- a/lib/features/email/presentation/widgets/attachment_item_widget.dart +++ b/lib/features/email/presentation/widgets/attachment_item_widget.dart @@ -3,8 +3,8 @@ import 'package:core/presentation/resources/image_paths.dart'; import 'package:core/presentation/utils/theme_utils.dart'; import 'package:core/presentation/views/button/tmail_button_widget.dart'; import 'package:core/presentation/views/container/tmail_container_widget.dart'; +import 'package:core/presentation/views/text/middle_ellipsis_text.dart'; import 'package:core/utils/platform_info.dart'; -import 'package:extended_text/extended_text.dart'; import 'package:filesize/filesize.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; @@ -62,19 +62,8 @@ class AttachmentItemWidget extends StatelessWidget { fit: BoxFit.fill, ); - final attachmentTitleWithMiddleDots = ExtendedText( + final attachmentTitleWithMiddleDots = MiddleEllipsisText( attachment.generateFileName(), - maxLines: 1, - overflowWidget: TextOverflowWidget( - position: TextOverflowPosition.middle, - clearType: TextOverflowClearType.clipRect, - child: Text( - "...", - style: ThemeUtils.textStyleM3LabelLarge( - color: AppColor.m3SurfaceBackground, - ), - ), - ), style: ThemeUtils.textStyleM3LabelLarge( color: AppColor.m3SurfaceBackground, ), diff --git a/lib/features/email/presentation/widgets/attachment_list/attachment_list_item_widget.dart b/lib/features/email/presentation/widgets/attachment_list/attachment_list_item_widget.dart index 06df31129..688252c51 100644 --- a/lib/features/email/presentation/widgets/attachment_list/attachment_list_item_widget.dart +++ b/lib/features/email/presentation/widgets/attachment_list/attachment_list_item_widget.dart @@ -1,8 +1,7 @@ import 'package:core/presentation/resources/image_paths.dart'; import 'package:core/presentation/utils/style_utils.dart'; import 'package:core/presentation/views/button/tmail_button_widget.dart'; -import 'package:core/utils/platform_info.dart'; -import 'package:extended_text/extended_text.dart'; +import 'package:core/presentation/views/text/middle_ellipsis_text.dart'; import 'package:filesize/filesize.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; @@ -66,27 +65,10 @@ class AttachmentListItemWidget extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (PlatformInfo.isCanvasKit) - ExtendedText( - (attachment.name ?? ''), - maxLines: 1, - overflowWidget: TextOverflowWidget( - position: TextOverflowPosition.middle, - clearType: TextOverflowClearType.clipRect, - child: Text( - "...", - style: AttachmentListItemWidgetStyle.dotsLabelTextStyle, - ), - ), - style: AttachmentListItemWidgetStyle.labelTextStyle, - ) - else - Text( - (attachment.name ?? ''), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: AttachmentListItemWidgetStyle.labelTextStyle, - ), + MiddleEllipsisText( + (attachment.name ?? ''), + style: AttachmentListItemWidgetStyle.labelTextStyle, + ), const SizedBox(height: AttachmentListItemWidgetStyle.fileTitleBottomSpace), Text( filesize(attachment.size?.value), diff --git a/lib/features/email/presentation/widgets/feedback_draggable_attachment_item_widget.dart b/lib/features/email/presentation/widgets/feedback_draggable_attachment_item_widget.dart index 563fe5312..8f49ae89c 100644 --- a/lib/features/email/presentation/widgets/feedback_draggable_attachment_item_widget.dart +++ b/lib/features/email/presentation/widgets/feedback_draggable_attachment_item_widget.dart @@ -1,6 +1,5 @@ import 'package:core/presentation/resources/image_paths.dart'; -import 'package:core/utils/platform_info.dart'; -import 'package:extended_text/extended_text.dart'; +import 'package:core/presentation/views/text/middle_ellipsis_text.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:get/get.dart'; @@ -44,28 +43,10 @@ class FeedbackDraggableAttachmentItemWidget extends StatelessWidget { ), const SizedBox(width: FeedbackDraggableAttachmentItemWidgetStyle.space), Flexible( - child: PlatformInfo.isCanvasKit - ? DefaultTextStyle( - style: FeedbackDraggableAttachmentItemWidgetStyle.labelTextStyle, - child: ExtendedText( - attachment.name ?? '', - maxLines: 1, - overflowWidget: TextOverflowWidget( - position: TextOverflowPosition.middle, - clearType: TextOverflowClearType.clipRect, - child: Text( - '...', - style: FeedbackDraggableAttachmentItemWidgetStyle.dotsLabelTextStyle, - ), - ), - ), - ) - : Text( - attachment.name ?? '', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: FeedbackDraggableAttachmentItemWidgetStyle.labelTextStyle, - ), + child: MiddleEllipsisText( + attachment.name ?? '', + style: FeedbackDraggableAttachmentItemWidgetStyle.dotsLabelTextStyle, + ), ) ], ), diff --git a/pubspec.lock b/pubspec.lock index e42f22b40..43c13bd0c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -520,22 +520,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.5" - extended_text: - dependency: "direct main" - description: - name: extended_text - sha256: "38c1cac571d6eaf406f4b80040c1f88561e7617ad90795aac6a1be0a8d0bb676" - url: "https://pub.dev" - source: hosted - version: "14.0.0" - extended_text_library: - dependency: transitive - description: - name: extended_text_library - sha256: "55d09098ec56fab0d9a8a68950ca0bbf2efa1327937f7cec6af6dfa066234829" - url: "https://pub.dev" - source: hosted - version: "12.0.0" external_app_launcher: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index da694585c..7e0a0610f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -216,8 +216,6 @@ dependencies: flutter_keyboard_visibility: 6.0.0 - extended_text: 14.0.0 - date_format: 2.0.7 flutter_linkify: 6.0.0 diff --git a/test/features/composer/recipient_composer_widget_test.dart b/test/features/composer/recipient_composer_widget_test.dart index 40fcee624..3bc16d725 100644 --- a/test/features/composer/recipient_composer_widget_test.dart +++ b/test/features/composer/recipient_composer_widget_test.dart @@ -1,5 +1,6 @@ import 'package:core/presentation/resources/image_paths.dart'; import 'package:core/presentation/utils/responsive_utils.dart'; +import 'package:core/presentation/views/text/middle_ellipsis_text.dart'; import 'package:core/utils/app_logger.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -63,8 +64,8 @@ void main() { testWidgets('RecipientComposerWidget renders list email address correctly', (tester) async { final listEmailAddress = [ - EmailAddress(null, 'test1@example.com'), - EmailAddress(null, 'test2@example.com'), + EmailAddress(null, 'test1@dev.com'), + EmailAddress(null, 'test2@dev.com'), ]; final widget = makeTestableWidget( @@ -82,8 +83,6 @@ void main() { await tester.pumpAndSettle(); expect(find.byType(RecipientTagItemWidget), findsNWidgets(2)); - expect(find.text('test1@example.com'), findsOneWidget); - expect(find.text('test2@example.com'), findsOneWidget); }); testWidgets('RecipientTagItemWidget should have a `maxWidth` equal to the default `maxWidth`', (tester) async { @@ -382,16 +381,15 @@ void main() { final labelRecipientTagItemWidgetFinder = find.byKey(Key('label_recipient_tag_item_${prefix.name}_0')); - final labelRecipientTagItemWidget = tester.widget(labelRecipientTagItemWidgetFinder); + final labelRecipientTagItemWidget = tester.widget(labelRecipientTagItemWidgetFinder); final labelTagWidth = tester.getSize(labelRecipientTagItemWidgetFinder).width; final TextPainter textPainter = TextPainter( - maxLines: labelRecipientTagItemWidget.maxLines, - textDirection: labelRecipientTagItemWidget.textDirection ?? TextDirection.ltr, + maxLines: 1, + textDirection: TextDirection.ltr, text: TextSpan( - text: labelRecipientTagItemWidget.data, + text: labelRecipientTagItemWidget.text, style: labelRecipientTagItemWidget.style, - locale: labelRecipientTagItemWidget.locale ), ); textPainter.layout(maxWidth: labelTagWidth);