From 7d7ccaf874e5d0805ab6b680eb31c02d400a8ffa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Poizat?= Date: Thu, 4 Dec 2025 17:12:56 +0100 Subject: [PATCH] Open AI Scribe from text selection --- .../presentation/composer_controller.dart | 42 +++++ .../composer/presentation/composer_view.dart | 145 ++++++++++++------ .../presentation/composer_view_web.dart | 43 ++++++ 3 files changed, 186 insertions(+), 44 deletions(-) diff --git a/lib/features/composer/presentation/composer_controller.dart b/lib/features/composer/presentation/composer_controller.dart index 0f43341bc..984eca5ed 100644 --- a/lib/features/composer/presentation/composer_controller.dart +++ b/lib/features/composer/presentation/composer_controller.dart @@ -86,6 +86,7 @@ import 'package:tmail_ui_user/features/composer/presentation/model/saved_composi import 'package:tmail_ui_user/features/composer/presentation/model/screen_display_mode.dart'; import 'package:tmail_ui_user/features/composer/presentation/styles/composer_style.dart'; import 'package:tmail_ui_user/features/composer/presentation/view/editor_view_mixin.dart'; +import 'package:tmail_ui_user/features/composer/presentation/widgets/mixins/text_selection_mixin.dart'; import 'package:tmail_ui_user/features/composer/presentation/widgets/mobile/from_composer_bottom_sheet_builder.dart'; import 'package:tmail_ui_user/features/composer/presentation/widgets/saving_message_dialog_view.dart'; import 'package:tmail_ui_user/features/composer/presentation/widgets/saving_template_dialog_view.dart'; @@ -158,6 +159,10 @@ class ComposerController extends BaseController final isMarkAsImportant = Rx(false); final isContentHeightExceeded = Rx(false); + final selectedText = Rxn(); + final hasTextSelection = false.obs; + final textSelectionCoordinates = Rxn(); + final LocalFilePickerInteractor _localFilePickerInteractor; final LocalImagePickerInteractor _localImagePickerInteractor; final GetEmailContentInteractor _getEmailContentInteractor; @@ -913,6 +918,43 @@ class ComposerController extends BaseController ); } + + + void showAIScribeMenuForSelectedText(BuildContext context, {Offset? buttonPosition}) { + final selection = selectedText.value; + if (selection == null || selection.isEmpty) { + return; + } + + showAIScribeDialog( + context: context, + imagePaths: imagePaths, + content: selection, + onInsertText: insertTextInEditor, + buttonPosition: buttonPosition, + ); + } + + void handleTextSelection(TextSelectionData? textSelectionData) { + if (textSelectionData != null) { + hasTextSelection.value = textSelectionData.hasSelection; + selectedText.value = textSelectionData.selectedText; + + if (textSelectionData.coordinates != null) { + textSelectionCoordinates.value = Offset( + textSelectionData.coordinates!.x, + textSelectionData.coordinates!.y + 6, + ); + } else { + textSelectionCoordinates.value = null; + } + } else { + hasTextSelection.value = false; + selectedText.value = null; + textSelectionCoordinates.value = null; + } + } + Future _prepareToSendMessages(BuildContext context) async { final arguments = composerArguments.value; final session = mailboxDashBoardController.sessionCurrent; diff --git a/lib/features/composer/presentation/composer_view.dart b/lib/features/composer/presentation/composer_view.dart index 9a701e400..90ba4d277 100644 --- a/lib/features/composer/presentation/composer_view.dart +++ b/lib/features/composer/presentation/composer_view.dart @@ -8,6 +8,7 @@ import 'package:flutter_svg/flutter_svg.dart'; import 'package:get/get.dart'; import 'package:jmap_dart_client/jmap/mail/email/email_address.dart'; import 'package:model/email/prefix_email_address.dart'; +import 'package:scribe/scribe/ai/presentation/widgets/ai_scribe_button.dart'; import 'package:tmail_ui_user/features/composer/presentation/composer_controller.dart'; import 'package:tmail_ui_user/features/composer/presentation/extensions/handle_content_height_exceeded_extension.dart'; import 'package:tmail_ui_user/features/composer/presentation/extensions/handle_edit_recipient_extension.dart'; @@ -235,28 +236,38 @@ class ComposerView extends GetWidget { )) ], ), - Obx(() => Padding( - padding: ComposerStyle.mobileEditorPadding, - child: MobileEditorView( - arguments: controller.composerArguments.value, - contentViewState: controller.emailContentsViewState.value, - onCreatedEditorAction: controller.onCreatedMobileEditorAction, - onLoadCompletedEditorAction: controller.onLoadCompletedMobileEditorAction, - onEditorContentHeightChanged: controller.onEditorContentHeightChangedOnIOS, - ), - )), - Obx(() { - if (controller.isContentHeightExceeded.isTrue && PlatformInfo.isIOS) { - return ViewEntireMessageWithMessageClippedWidget( - buttonActionName: AppLocalizations.of(context).viewEntireMessage.toUpperCase(), - onViewEntireMessageAction: controller.viewEntireContent, - topPadding: 12, - ); - } else { - return const SizedBox.shrink(); - } - }), - SizedBox(height: MediaQuery.viewInsetsOf(context).bottom + 64), + Stack( + children: [ + Column( + children: [ + Obx(() => Padding( + padding: ComposerStyle.mobileEditorPadding, + child: MobileEditorView( + arguments: controller.composerArguments.value, + contentViewState: controller.emailContentsViewState.value, + onCreatedEditorAction: controller.onCreatedMobileEditorAction, + onLoadCompletedEditorAction: controller.onLoadCompletedMobileEditorAction, + onEditorContentHeightChanged: controller.onEditorContentHeightChangedOnIOS, + onTextSelectionChanged: controller.handleTextSelection, + ), + )), + Obx(() { + if (controller.isContentHeightExceeded.isTrue && PlatformInfo.isIOS) { + return ViewEntireMessageWithMessageClippedWidget( + buttonActionName: AppLocalizations.of(context).viewEntireMessage.toUpperCase(), + onViewEntireMessageAction: controller.viewEntireContent, + topPadding: 12, + ); + } else { + return const SizedBox.shrink(); + } + }), + SizedBox(height: MediaQuery.viewInsetsOf(context).bottom + 64), + ], + ), + _buildAIScribeSelectionButton(context), + ], + ) ], ), ), @@ -414,28 +425,38 @@ class ComposerView extends GetWidget { margin: ComposerStyle.insertImageLoadingBarMargin, ), )), - Obx(() => Padding( - padding: ComposerStyle.mobileEditorPadding, - child: MobileEditorView( - arguments: controller.composerArguments.value, - contentViewState: controller.emailContentsViewState.value, - onCreatedEditorAction: controller.onCreatedMobileEditorAction, - onLoadCompletedEditorAction: controller.onLoadCompletedMobileEditorAction, - onEditorContentHeightChanged: controller.onEditorContentHeightChangedOnIOS, - ), - )), - Obx(() { - if (controller.isContentHeightExceeded.isTrue && PlatformInfo.isIOS) { - return ViewEntireMessageWithMessageClippedWidget( - buttonActionName: AppLocalizations.of(context).viewEntireMessage.toUpperCase(), - onViewEntireMessageAction: controller.viewEntireContent, - topPadding: 12, - ); - } else { - return const SizedBox.shrink(); - } - }), - SizedBox(height: MediaQuery.viewInsetsOf(context).bottom + 64), + Stack( + children: [ + Column( + children: [ + Obx(() => Padding( + padding: ComposerStyle.mobileEditorPadding, + child: MobileEditorView( + arguments: controller.composerArguments.value, + contentViewState: controller.emailContentsViewState.value, + onCreatedEditorAction: controller.onCreatedMobileEditorAction, + onLoadCompletedEditorAction: controller.onLoadCompletedMobileEditorAction, + onEditorContentHeightChanged: controller.onEditorContentHeightChangedOnIOS, + onTextSelectionChanged: controller.handleTextSelection, + ), + )), + Obx(() { + if (controller.isContentHeightExceeded.isTrue && PlatformInfo.isIOS) { + return ViewEntireMessageWithMessageClippedWidget( + buttonActionName: AppLocalizations.of(context).viewEntireMessage.toUpperCase(), + onViewEntireMessageAction: controller.viewEntireContent, + topPadding: 12, + ); + } else { + return const SizedBox.shrink(); + } + }), + SizedBox(height: MediaQuery.viewInsetsOf(context).bottom + 64), + ], + ), + _buildAIScribeSelectionButton(context), + ], + ), ], ), ) @@ -533,4 +554,40 @@ class ComposerView extends GetWidget { onEnableAllRecipientsInputAction: controller.handleEnableRecipientsInputOnMobileAction, )); } + + Widget _buildAIScribeSelectionButton(BuildContext context) { + return Obx(() { + if (controller.hasTextSelection.value && + controller.textSelectionCoordinates.value != null) { + final coordinates = controller.textSelectionCoordinates.value!; + // Account for the horizontal padding around the editor + const editorHorizontalPadding = 12.0; + return PositionedDirectional( + start: coordinates.dx + editorHorizontalPadding, + top: coordinates.dy, + child: Builder( + builder: (buttonContext) { + return AIScribeButton( + imagePaths: controller.imagePaths, + onTap: () { + final RenderBox? renderBox = buttonContext.findRenderObject() as RenderBox?; + if (renderBox != null) { + final position = renderBox.localToGlobal(Offset.zero); + controller.showAIScribeMenuForSelectedText( + context, + buttonPosition: position, + ); + } else { + controller.showAIScribeMenuForSelectedText(context); + } + }, + ); + }, + ), + ); + } else { + return const SizedBox.shrink(); + } + }); + } } \ No newline at end of file diff --git a/lib/features/composer/presentation/composer_view_web.dart b/lib/features/composer/presentation/composer_view_web.dart index 67013c5a1..bfa3ddde5 100644 --- a/lib/features/composer/presentation/composer_view_web.dart +++ b/lib/features/composer/presentation/composer_view_web.dart @@ -6,6 +6,7 @@ import 'package:get/get.dart'; import 'package:jmap_dart_client/jmap/mail/email/email_address.dart'; import 'package:model/email/prefix_email_address.dart'; import 'package:pointer_interceptor/pointer_interceptor.dart'; +import 'package:scribe/scribe/ai/presentation/widgets/ai_scribe_button.dart'; import 'package:tmail_ui_user/features/base/mixin/message_dialog_action_manager.dart'; import 'package:tmail_ui_user/features/base/widget/dialog_picker/color_dialog_picker.dart'; import 'package:tmail_ui_user/features/base/widget/keyboard/keyboard_handler_wrapper.dart'; @@ -267,6 +268,7 @@ class ComposerView extends GetWidget { ), onInitialContentLoadComplete: controller.onInitialContentLoadCompleteWeb, onKeyDownEditorAction: controller.onKeyDownEditorAction, + onTextSelectionChanged: controller.handleTextSelection, )); } ), @@ -332,6 +334,7 @@ class ComposerView extends GetWidget { return const SizedBox.shrink(); } }), + _buildAIScribeSelectionButton(context), ], ), ), @@ -509,6 +512,7 @@ class ComposerView extends GetWidget { ), onInitialContentLoadComplete: controller.onInitialContentLoadCompleteWeb, onKeyDownEditorAction: controller.onKeyDownEditorAction, + onTextSelectionChanged: controller.handleTextSelection, ); }); } @@ -604,6 +608,7 @@ class ComposerView extends GetWidget { return const SizedBox.shrink(); } }), + _buildAIScribeSelectionButton(context), ], ), ), @@ -784,6 +789,7 @@ class ComposerView extends GetWidget { ), onInitialContentLoadComplete: controller.onInitialContentLoadCompleteWeb, onKeyDownEditorAction: controller.onKeyDownEditorAction, + onTextSelectionChanged: controller.handleTextSelection, )); } ), @@ -877,6 +883,7 @@ class ComposerView extends GetWidget { return const SizedBox.shrink(); } }), + _buildAIScribeSelectionButton(context), ], ), ) @@ -949,4 +956,40 @@ class ComposerView extends GetWidget { onEnableAllRecipientsInputAction: controller.handleEnableRecipientsInputOnMobileAction, )); } + + Widget _buildAIScribeSelectionButton(BuildContext context) { + return Obx(() { + if (controller.hasTextSelection.value && + controller.textSelectionCoordinates.value != null) { + final coordinates = controller.textSelectionCoordinates.value!; + return PositionedDirectional( + start: coordinates.dx, + top: coordinates.dy, + child: PointerInterceptor( + child: Builder( + builder: (buttonContext) { + return AIScribeButton( + imagePaths: controller.imagePaths, + onTap: () { + final RenderBox? renderBox = buttonContext.findRenderObject() as RenderBox?; + if (renderBox != null) { + final position = renderBox.localToGlobal(Offset.zero); + controller.showAIScribeMenuForSelectedText( + context, + buttonPosition: position, + ); + } else { + controller.showAIScribeMenuForSelectedText(context); + } + }, + ); + }, + ), + ), + ); + } else { + return const SizedBox.shrink(); + } + }); + } } \ No newline at end of file