diff --git a/assets/images/ic_ai_bullets.svg b/assets/images/ic_ai_bullets.svg new file mode 100644 index 0000000000..f43e361920 --- /dev/null +++ b/assets/images/ic_ai_bullets.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/images/ic_ai_change_tons.svg b/assets/images/ic_ai_change_tons.svg new file mode 100644 index 0000000000..98b8dd9c15 --- /dev/null +++ b/assets/images/ic_ai_change_tons.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/images/ic_ai_emojify.svg b/assets/images/ic_ai_emojify.svg new file mode 100644 index 0000000000..edabd66a1c --- /dev/null +++ b/assets/images/ic_ai_emojify.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/images/ic_ai_grammar.svg b/assets/images/ic_ai_grammar.svg new file mode 100644 index 0000000000..52411aa209 --- /dev/null +++ b/assets/images/ic_ai_grammar.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/images/ic_ai_improve.svg b/assets/images/ic_ai_improve.svg new file mode 100644 index 0000000000..3a32c2591a --- /dev/null +++ b/assets/images/ic_ai_improve.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/images/ic_ai_more_casual.svg b/assets/images/ic_ai_more_casual.svg new file mode 100644 index 0000000000..d4f51fb16a --- /dev/null +++ b/assets/images/ic_ai_more_casual.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/images/ic_ai_more_detail.svg b/assets/images/ic_ai_more_detail.svg new file mode 100644 index 0000000000..e793624c9f --- /dev/null +++ b/assets/images/ic_ai_more_detail.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/images/ic_ai_more_polite.svg b/assets/images/ic_ai_more_polite.svg new file mode 100644 index 0000000000..f8c7442b9b --- /dev/null +++ b/assets/images/ic_ai_more_polite.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/images/ic_ai_more_professional.svg b/assets/images/ic_ai_more_professional.svg new file mode 100644 index 0000000000..2cd23f4578 --- /dev/null +++ b/assets/images/ic_ai_more_professional.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/images/ic_ai_shorter.svg b/assets/images/ic_ai_shorter.svg new file mode 100644 index 0000000000..9f01df3f61 --- /dev/null +++ b/assets/images/ic_ai_shorter.svg @@ -0,0 +1,8 @@ + + + + diff --git a/assets/images/ic_ai_translate.svg b/assets/images/ic_ai_translate.svg new file mode 100644 index 0000000000..fd1c1c7680 --- /dev/null +++ b/assets/images/ic_ai_translate.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/images/ic_gradient_sparkle.svg b/assets/images/ic_gradient_sparkle.svg new file mode 100644 index 0000000000..e1c4caf764 --- /dev/null +++ b/assets/images/ic_gradient_sparkle.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/ic_sparkle.svg b/assets/images/ic_sparkle.svg index ce8186fd52..604e314bf7 100644 --- a/assets/images/ic_sparkle.svg +++ b/assets/images/ic_sparkle.svg @@ -1,3 +1,13 @@ - - + + + + + + diff --git a/core/lib/data/network/dio_client.dart b/core/lib/data/network/dio_client.dart index 022f690a60..20c7928bee 100644 --- a/core/lib/data/network/dio_client.dart +++ b/core/lib/data/network/dio_client.dart @@ -40,9 +40,12 @@ class DioClient { CancelToken? cancelToken, ProgressCallback? onSendProgress, ProgressCallback? onReceiveProgress, + bool useJMAPHeader = true, }) async { - final newOptions = options?.appendHeaders({HttpHeaders.acceptHeader : jmapHeader}) - ?? Options(headers: {HttpHeaders.acceptHeader : jmapHeader}) ; + Map defaultHeaders = + useJMAPHeader ? {HttpHeaders.acceptHeader: jmapHeader} : {}; + final newOptions = options?.appendHeaders(defaultHeaders) ?? + Options(headers: defaultHeaders); return await _dio.post(path, data: data, diff --git a/core/lib/presentation/extensions/color_extension.dart b/core/lib/presentation/extensions/color_extension.dart index 12a480dc14..de885f90ab 100644 --- a/core/lib/presentation/extensions/color_extension.dart +++ b/core/lib/presentation/extensions/color_extension.dart @@ -255,10 +255,12 @@ extension AppColor on Color { static const iconFolder = Color(0xFF297EF2); static const folderDivider = Color(0xFFE4E8EC); static const gray424244 = Color(0xFF424244); + static const gray777778 = Color(0xFF777778); static const gray200 = Color(0xFFCCCCCC); static const lightGrayF4F4F4 = Color(0xFFF4F4F4); static const gray959DAD = Color(0xFF959DAD); static const gray9AA7B6 = Color(0xFF9AA7B6); + static const gray9B9B9B = Color(0xFF9B9B9B); static const redFF3347 = Color(0xFFFF3347); static const gray686E76 = Color(0xFF686E76); static const gray900 = Color(0xFF222222); @@ -276,8 +278,11 @@ extension AppColor on Color { static const m3Primary = Color(0xFF0A84FF); static const m3Primary95 = Color(0xFFE3F1FF); static const gray49454F = Color(0xFF49454F); + static const blue00B7FF = Color(0xFF00B7FF); + static const blueD2E9FF = Color(0xFFD2E9FF); static const lightGrayF9FAFB = Color(0xFFF9FAFB); static const black4D4D4D = Color(0xFF4D4D4D); + static const black1A1A1A = Color(0xFF1A1A1A); static const green166534 = Color(0xFF166534); static const lightGreenF0FDF4 = Color(0xFFF0FDF4); static const lightGreenBBF7D0 = Color(0xFFBBF7D0); diff --git a/core/lib/presentation/resources/image_paths.dart b/core/lib/presentation/resources/image_paths.dart index c1e044e02d..7527c56103 100644 --- a/core/lib/presentation/resources/image_paths.dart +++ b/core/lib/presentation/resources/image_paths.dart @@ -262,6 +262,18 @@ class ImagePaths { String get icText => _getImagePath('ic_text.svg'); String get icUser => _getImagePath('ic_user.svg'); String get icSparkle => _getImagePath('ic_sparkle.svg'); + String get icGradientSparkle => _getImagePath('ic_gradient_sparkle.svg'); + String get icAiChangeTons => _getImagePath('ic_ai_change_tons.svg'); + String get icAiGrammar => _getImagePath('ic_ai_grammar.svg'); + String get icAiImprove => _getImagePath('ic_ai_improve.svg'); + String get icAiTranslate => _getImagePath('ic_ai_translate.svg'); + String get icAiBullets => _getImagePath('ic_ai_bullets.svg'); + String get icAiEmojify => _getImagePath('ic_ai_emojify.svg'); + String get icAiShorter => _getImagePath('ic_ai_shorter.svg'); + String get icAiMoreCasual => _getImagePath('ic_ai_more_casual.svg'); + String get icAiMoreDetail => _getImagePath('ic_ai_more_detail.svg'); + String get icAiMoreProfessional => _getImagePath('ic_ai_more_professional.svg'); + String get icAiMorePolite => _getImagePath('ic_ai_more_polite.svg'); String _getImagePath(String imageName) { return AssetsPaths.images + imageName; diff --git a/core/lib/presentation/views/html_viewer/html_content_viewer_on_web_widget.dart b/core/lib/presentation/views/html_viewer/html_content_viewer_on_web_widget.dart index 2b0f444680..59ef80dd59 100644 --- a/core/lib/presentation/views/html_viewer/html_content_viewer_on_web_widget.dart +++ b/core/lib/presentation/views/html_viewer/html_content_viewer_on_web_widget.dart @@ -385,11 +385,7 @@ class _HtmlContentViewerOnWebState extends State } } - String _getRandString(int len) { - var random = math.Random.secure(); - var values = List.generate(len, (i) => random.nextInt(255)); - return base64UrlEncode(values); - } + String _generateHtmlDocument(String content) { final webViewActionScripts = ''' @@ -531,7 +527,7 @@ class _HtmlContentViewerOnWebState extends State } void _setUpWeb() { - _createdViewId = _getRandString(10); + _createdViewId = HtmlUtils.getRandString(10); _htmlData = _generateHtmlDocument(widget.contentHtml); _webInit = Future.value(true); diff --git a/core/lib/utils/html/html_utils.dart b/core/lib/utils/html/html_utils.dart index 00dab4b696..8b6d60960b 100644 --- a/core/lib/utils/html/html_utils.dart +++ b/core/lib/utils/html/html_utils.dart @@ -48,7 +48,7 @@ class HtmlUtils { editor.parentNode.replaceChild(newEditor, editor);''', name: 'unregisterDropListener'); - static const registerSelectionChangeListener = ( + static ({String name, String script})registerSelectionChangeListener(String viewId) => ( script: ''' let lastSelectedText = ''; @@ -58,6 +58,7 @@ class HtmlUtils { window.parent.postMessage( JSON.stringify({ ...data, + viewId: '$viewId', name: 'onSelectionChange', }), "*" @@ -111,6 +112,16 @@ class HtmlUtils { });''', name: 'onSelectionChange'); + static const collapseSelectionToEnd = ( + script: ''' + (() => { + const selection = window.getSelection(); + if (selection) { + selection.collapseToEnd() + } + })();''', + name: 'collapseSelectionToEnd'); + static recalculateEditorHeight({double? maxHeight}) => ( script: ''' const editable = document.querySelector('.note-editable'); @@ -892,4 +903,10 @@ class HtmlUtils { } } } + + static String getRandString(int len) { + var random = Random.secure(); + var values = List.generate(len, (i) => random.nextInt(255)); + return base64UrlEncode(values); + } } diff --git a/lib/features/base/mixin/ai_scribe_mixin.dart b/lib/features/base/mixin/ai_scribe_mixin.dart new file mode 100644 index 0000000000..c02e35dc9a --- /dev/null +++ b/lib/features/base/mixin/ai_scribe_mixin.dart @@ -0,0 +1,42 @@ +import 'package:core/utils/app_logger.dart'; +import 'package:core/utils/platform_info.dart'; +import 'package:jmap_dart_client/jmap/account_id.dart'; +import 'package:jmap_dart_client/jmap/core/session/session.dart'; +import 'package:scribe/scribe.dart'; +import 'package:scribe/scribe/ai/presentation/bindings/ai_scribe_bindings.dart'; +import 'package:tmail_ui_user/features/home/domain/extensions/session_extensions.dart'; + +mixin AiScribeMixin { + AICapability? getAICapability({Session? session, AccountId? accountId}) { + if (PlatformInfo.isMobile) return null; + + if (accountId == null || session == null) { + return null; + } + + return session.getAICapability(accountId); + } + + void injectAIScribeBindings(Session? session, AccountId? accountId) { + try { + final aiCapability = getAICapability( + session: session, + accountId: accountId, + ); + final scribeEndpoint = aiCapability?.scribeEndpoint; + + if (scribeEndpoint == null || scribeEndpoint.isEmpty) return; + + // Validate endpoint format + if (Uri.tryParse(scribeEndpoint)?.hasAbsolutePath != true) { + logError( + 'AiScribeMixin::injectAIScribeBindings(): Invalid endpoint format: $scribeEndpoint'); + return; + } + + AIScribeBindings(scribeEndpoint).dependencies(); + } catch (e) { + logError('AiScribeMixin::injectAIScribeBindings(): $e'); + } + } +} diff --git a/lib/features/composer/presentation/composer_controller.dart b/lib/features/composer/presentation/composer_controller.dart index dae80a8d94..e30528d124 100644 --- a/lib/features/composer/presentation/composer_controller.dart +++ b/lib/features/composer/presentation/composer_controller.dart @@ -27,10 +27,12 @@ import 'package:model/model.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:pointer_interceptor/pointer_interceptor.dart'; import 'package:rich_text_composer/rich_text_composer.dart'; +import 'package:scribe/scribe.dart'; import 'package:super_tag_editor/tag_editor.dart'; import 'package:tmail_ui_user/features/base/base_controller.dart'; import 'package:tmail_ui_user/features/base/before_reconnect_handler.dart'; import 'package:tmail_ui_user/features/base/before_reconnect_manager.dart'; +import 'package:tmail_ui_user/features/base/mixin/ai_scribe_mixin.dart'; import 'package:tmail_ui_user/features/base/mixin/auto_complete_result_mixin.dart'; import 'package:tmail_ui_user/features/base/mixin/message_dialog_action_manager.dart'; import 'package:tmail_ui_user/features/base/state/base_ui_state.dart'; @@ -77,7 +79,6 @@ import 'package:tmail_ui_user/features/composer/presentation/extensions/setup_em import 'package:tmail_ui_user/features/composer/presentation/extensions/setup_list_identities_extension.dart'; import 'package:tmail_ui_user/features/composer/presentation/extensions/setup_selected_identity_extension.dart'; import 'package:tmail_ui_user/features/composer/presentation/extensions/update_screen_display_mode_extension.dart'; -import 'package:tmail_ui_user/features/composer/presentation/mixin/ai_scribe_in_composer_mixin.dart'; import 'package:tmail_ui_user/features/composer/presentation/mixin/drag_drog_file_mixin.dart'; import 'package:tmail_ui_user/features/composer/presentation/model/create_email_request.dart'; import 'package:tmail_ui_user/features/composer/presentation/model/inline_image.dart'; @@ -90,7 +91,6 @@ import 'package:tmail_ui_user/features/composer/presentation/widgets/mobile/from 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'; import 'package:tmail_ui_user/features/composer/presentation/widgets/sending_message_dialog_view.dart'; -import 'package:scribe/scribe.dart'; import 'package:tmail_ui_user/features/email/domain/state/get_email_content_state.dart'; import 'package:tmail_ui_user/features/email/domain/state/save_template_email_state.dart'; import 'package:tmail_ui_user/features/email/domain/state/update_template_email_state.dart'; @@ -103,9 +103,9 @@ import 'package:tmail_ui_user/features/email/presentation/model/composer_argumen import 'package:tmail_ui_user/features/mailbox/domain/model/create_new_mailbox_request.dart'; import 'package:tmail_ui_user/features/mailbox_dashboard/domain/usecases/remove_composer_cache_by_id_on_web_interactor.dart'; import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller.dart'; -import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/extensions/validate_premium_storage_extension.dart'; import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/extensions/open_and_close_composer_extension.dart'; import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/extensions/update_text_formatting_menu_state_extension.dart'; +import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/extensions/validate_premium_storage_extension.dart'; import 'package:tmail_ui_user/features/mailbox_dashboard/presentation/model/draggable_app_state.dart'; import 'package:tmail_ui_user/features/manage_account/domain/state/get_all_identities_state.dart'; import 'package:tmail_ui_user/features/manage_account/domain/usecases/get_all_identities_interactor.dart'; @@ -131,9 +131,12 @@ import 'package:tmail_ui_user/main/universal_import/html_stub.dart' as html; import 'package:tmail_ui_user/main/utils/app_config.dart'; class ComposerController extends BaseController - with DragDropFileMixin, AutoCompleteResultMixin, EditorViewMixin, AIScribeInComposerMixin + with + DragDropFileMixin, + AutoCompleteResultMixin, + EditorViewMixin, + AiScribeMixin implements BeforeReconnectHandler { - final mailboxDashBoardController = Get.find(); final networkConnectionController = Get.find(); final _beforeReconnectManager = Get.find(); @@ -157,6 +160,7 @@ class ComposerController extends BaseController final isEmailChanged = Rx(false); final isMarkAsImportant = Rx(false); final isContentHeightExceeded = Rx(false); + final editorTextSelection = Rxn(); final LocalFilePickerInteractor _localFilePickerInteractor; final LocalImagePickerInteractor _localImagePickerInteractor; @@ -221,11 +225,8 @@ class ComposerController extends BaseController StreamSubscription? _subscriptionOnBlur; StreamSubscription? _composerCacheListener; - @override RichTextMobileTabletController? richTextMobileTabletController; - @override RichTextWebController? richTextWebController; - CustomPopupMenuController? menuMoreOptionController; final ScrollController scrollController = ScrollController(); @@ -262,9 +263,6 @@ class ComposerController extends BaseController TransformHtmlEmailContentInteractor get transformHtmlEmailContentInteractor => _transformHtmlEmailContentInteractor; - @override - GenerateAITextInteractor get generateAITextInteractor => Get.find(); - String get ownEmailAddress => mailboxDashBoardController.ownEmailAddress.value; @@ -867,20 +865,6 @@ class ComposerController extends BaseController } } - @override - Future getTextOnlyContentInEditor() async { - try { - final htmlContent = await getContentInEditor(); - - String textContent = StringConvert.convertHtmlContentToTextContent(htmlContent); - - return textContent; - } catch (e) { - logError('ComposerController::getTextOnlyContentInEditor:Exception = $e'); - return ''; - } - } - 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 cd5f0185f2..69f262cdde 100644 --- a/lib/features/composer/presentation/composer_view.dart +++ b/lib/features/composer/presentation/composer_view.dart @@ -8,8 +8,8 @@ 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/ai_scribe/handle_ai_scribe_in_composer_extension.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'; import 'package:tmail_ui_user/features/composer/presentation/extensions/handle_open_context_menu_extension.dart'; @@ -22,6 +22,7 @@ import 'package:tmail_ui_user/features/composer/presentation/styles/mobile_app_b import 'package:tmail_ui_user/features/composer/presentation/view/mobile/mobile_container_view.dart'; import 'package:tmail_ui_user/features/composer/presentation/view/mobile/mobile_editor_view.dart'; import 'package:tmail_ui_user/features/composer/presentation/view/mobile/tablet_container_view.dart'; +import 'package:tmail_ui_user/features/composer/presentation/widgets/ai_scribe/composer_ai_scribe_selection_overlay.dart'; import 'package:tmail_ui_user/features/composer/presentation/widgets/insert_image_loading_bar_widget.dart'; import 'package:tmail_ui_user/features/composer/presentation/widgets/list_recipients_collapsed_widget.dart'; import 'package:tmail_ui_user/features/composer/presentation/widgets/mobile/app_bar_composer_widget.dart'; @@ -34,7 +35,6 @@ import 'package:tmail_ui_user/features/composer/presentation/widgets/recipient_c import 'package:tmail_ui_user/features/composer/presentation/widgets/subject_composer_widget.dart'; import 'package:tmail_ui_user/features/composer/presentation/widgets/web/from_composer_drop_down_widget.dart'; import 'package:tmail_ui_user/features/email/presentation/widgets/view_entire_message_with_message_clipped_widget.dart'; -import 'package:scribe/scribe.dart'; import 'package:tmail_ui_user/main/localizations/app_localizations.dart'; class ComposerView extends GetWidget { @@ -265,7 +265,7 @@ class ComposerView extends GetWidget { SizedBox(height: MediaQuery.viewInsetsOf(context).bottom + 64), ], ), - _buildAIScribeSelectionButton(context), + ComposerAiScribeSelectionOverlay(controller: controller), ], ) ], @@ -454,7 +454,7 @@ class ComposerView extends GetWidget { SizedBox(height: MediaQuery.viewInsetsOf(context).bottom + 64), ], ), - _buildAIScribeSelectionButton(context), + ComposerAiScribeSelectionOverlay(controller: controller), ], ), ], @@ -470,8 +470,9 @@ class ComposerView extends GetWidget { sendMessageAction: () => controller.handleClickSendButton(context), requestReadReceiptAction: () => controller.toggleRequestReadReceipt(context), toggleMarkAsImportantAction: () => controller.toggleMarkAsImportant(context), - onOpenAIScribe: AIConfig.isAiEnabled ? () => controller.showAIScribeMenuForFullText(context) : null, - aiScribeButtonKey: AIConfig.isAiEnabled ? controller.aiScribeButtonKey : null, + onOpenAiAssistantModal: controller.isAIScribeAvailable + ? controller.openAIAssistantModal + : null, )), ] ) @@ -554,46 +555,4 @@ class ComposerView extends GetWidget { onEnableAllRecipientsInputAction: controller.handleEnableRecipientsInputOnMobileAction, )); } - - Widget _buildAIScribeSelectionButton(BuildContext context) { - if (!AIConfig.isAiEnabled) { - return const SizedBox.shrink(); - } - - return Obx(() { - final textSelection = controller.editorTextSelection.value; - if (textSelection != null && - textSelection.hasSelection && - textSelection.coordinates != null) { - final coordinates = textSelection.coordinates!; - // 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 2759a1e84f..a2db486c3f 100644 --- a/lib/features/composer/presentation/composer_view_web.dart +++ b/lib/features/composer/presentation/composer_view_web.dart @@ -6,16 +6,16 @@ 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'; import 'package:tmail_ui_user/features/composer/presentation/composer_controller.dart'; +import 'package:tmail_ui_user/features/composer/presentation/extensions/ai_scribe/handle_ai_scribe_in_composer_extension.dart'; import 'package:tmail_ui_user/features/composer/presentation/extensions/composer_print_draft_extension.dart'; import 'package:tmail_ui_user/features/composer/presentation/extensions/handle_edit_recipient_extension.dart'; import 'package:tmail_ui_user/features/composer/presentation/extensions/handle_insert_link_composer_extension.dart'; -import 'package:tmail_ui_user/features/composer/presentation/extensions/handle_recipients_collapsed_extensions.dart'; import 'package:tmail_ui_user/features/composer/presentation/extensions/handle_keyboard_shortcut_actions_extension.dart'; +import 'package:tmail_ui_user/features/composer/presentation/extensions/handle_recipients_collapsed_extensions.dart'; import 'package:tmail_ui_user/features/composer/presentation/extensions/mark_as_important_extension.dart'; import 'package:tmail_ui_user/features/composer/presentation/extensions/preview_upload_file_extension.dart'; import 'package:tmail_ui_user/features/composer/presentation/extensions/remove_draggable_email_address_between_recipient_fields_extension.dart'; @@ -25,12 +25,12 @@ import 'package:tmail_ui_user/features/composer/presentation/view/web/desktop_re import 'package:tmail_ui_user/features/composer/presentation/view/web/mobile_responsive_container_view.dart'; import 'package:tmail_ui_user/features/composer/presentation/view/web/tablet_responsive_container_view.dart'; import 'package:tmail_ui_user/features/composer/presentation/view/web/web_editor_view.dart'; +import 'package:tmail_ui_user/features/composer/presentation/widgets/ai_scribe/composer_ai_scribe_selection_overlay.dart'; import 'package:tmail_ui_user/features/composer/presentation/widgets/insert_image_loading_bar_widget.dart'; import 'package:tmail_ui_user/features/composer/presentation/widgets/list_recipients_collapsed_widget.dart'; import 'package:tmail_ui_user/features/composer/presentation/widgets/mobile/from_composer_mobile_widget.dart'; import 'package:tmail_ui_user/features/composer/presentation/widgets/recipient_composer_widget.dart'; import 'package:tmail_ui_user/features/composer/presentation/widgets/subject_composer_widget.dart'; -import 'package:scribe/scribe.dart'; import 'package:tmail_ui_user/features/composer/presentation/widgets/web/attachment_composer_widget.dart'; import 'package:tmail_ui_user/features/composer/presentation/widgets/web/attachment_drop_zone_widget.dart'; import 'package:tmail_ui_user/features/composer/presentation/widgets/web/bottom_bar_composer_widget.dart'; @@ -334,7 +334,7 @@ class ComposerView extends GetWidget { return const SizedBox.shrink(); } }), - _buildAIScribeSelectionButton(context), + ComposerAiScribeSelectionOverlay(controller: controller), ], ), ), @@ -560,8 +560,9 @@ class ComposerView extends GetWidget { toggleMarkAsImportantAction: () => controller.toggleMarkAsImportant(context), saveAsTemplateAction: () => controller.handleClickSaveAsTemplateButton(context), onOpenInsertLink: controller.openInsertLink, - onOpenAIScribe: AIConfig.isAiEnabled ? () => controller.showAIScribeMenuForFullText(context) : null, - aiScribeButtonKey: AIConfig.isAiEnabled ? controller.aiScribeButtonKey : null, + onOpenAiAssistantModal: controller.isAIScribeAvailable + ? controller.openAIAssistantModal + : null, )), ], ), @@ -608,7 +609,7 @@ class ComposerView extends GetWidget { return const SizedBox.shrink(); } }), - _buildAIScribeSelectionButton(context), + ComposerAiScribeSelectionOverlay(controller: controller), ], ), ), @@ -836,8 +837,9 @@ class ComposerView extends GetWidget { toggleMarkAsImportantAction: () => controller.toggleMarkAsImportant(context), saveAsTemplateAction: () => controller.handleClickSaveAsTemplateButton(context), onOpenInsertLink: controller.openInsertLink, - onOpenAIScribe: AIConfig.isAiEnabled ? () => controller.showAIScribeMenuForFullText(context) : null, - aiScribeButtonKey: AIConfig.isAiEnabled ? controller.aiScribeButtonKey : null, + onOpenAiAssistantModal: controller.isAIScribeAvailable + ? controller.openAIAssistantModal + : null, )), ], ), @@ -883,7 +885,7 @@ class ComposerView extends GetWidget { return const SizedBox.shrink(); } }), - _buildAIScribeSelectionButton(context), + ComposerAiScribeSelectionOverlay(controller: controller), ], ), ) @@ -956,46 +958,4 @@ class ComposerView extends GetWidget { onEnableAllRecipientsInputAction: controller.handleEnableRecipientsInputOnMobileAction, )); } - - Widget _buildAIScribeSelectionButton(BuildContext context) { - if (!AIConfig.isAiEnabled) { - return const SizedBox.shrink(); - } - - return Obx(() { - final textSelection = controller.editorTextSelection.value; - if (textSelection != null && - textSelection.hasSelection && - textSelection.coordinates != null) { - final coordinates = textSelection.coordinates!; - 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 diff --git a/lib/features/composer/presentation/extensions/ai_scribe/handle_ai_scribe_in_composer_extension.dart b/lib/features/composer/presentation/extensions/ai_scribe/handle_ai_scribe_in_composer_extension.dart new file mode 100644 index 0000000000..048cc3421e --- /dev/null +++ b/lib/features/composer/presentation/extensions/ai_scribe/handle_ai_scribe_in_composer_extension.dart @@ -0,0 +1,125 @@ +import 'package:core/utils/app_logger.dart'; +import 'package:core/utils/html/html_utils.dart'; +import 'package:core/utils/platform_info.dart'; +import 'package:core/utils/string_convert.dart'; +import 'package:flutter/material.dart'; +import 'package:scribe/scribe.dart'; +import 'package:tmail_ui_user/features/composer/presentation/composer_controller.dart'; +import 'package:tmail_ui_user/features/composer/presentation/mixin/text_selection_mixin.dart'; + +extension HandleAiScribeInComposerExtension on ComposerController { + bool get isAIScribeAvailable { + final aiCapability = getAICapability( + session: mailboxDashBoardController.sessionCurrent, + accountId: mailboxDashBoardController.accountId.value, + ); + return aiCapability?.isScribeEndpointAvailable == true; + } + + Future _getTextOnlyContentInEditor() async { + try { + final htmlContent = await getContentInEditor(); + String textContent = StringConvert.convertHtmlContentToTextContent( + htmlContent, + ); + return textContent; + } catch (e) { + logError('$runtimeType::getTextOnlyContentInEditor:Exception = $e'); + return ''; + } + } + + void insertTextInEditor(String text) { + final htmlContent = text.replaceAll('\n', '
'); + + if (PlatformInfo.isWeb) { + richTextWebController?.editorController.insertHtml(htmlContent); + } else { + richTextMobileTabletController?.htmlEditorApi?.insertHtml(htmlContent); + } + } + + Future collapseSelection() async { + if (PlatformInfo.isWeb) { + await richTextWebController?.editorController.evaluateJavascriptWeb( + HtmlUtils.collapseSelectionToEnd.name, + hasReturnValue: false, + ); + } else { + await richTextMobileTabletController?.htmlEditorApi?.webViewController + .evaluateJavascript( + source: HtmlUtils.collapseSelectionToEnd.script, + ); + } + } + + void clearTextInEditor() { + if (PlatformInfo.isWeb) { + richTextWebController?.editorController.setText(''); + } else { + richTextMobileTabletController?.htmlEditorApi?.setText(''); + } + } + + // Ensure we only insert at cursor position by collapsing selection before inserting + void onInsertTextCallback(String text) async { + await collapseSelection(); + + insertTextInEditor(text); + } + + // If there is a selection, it will replace the selection, else it will replace everything + void onReplaceTextCallback(String text) async { + final selection = editorTextSelection.value?.selectedText; + if (selection == null || selection.isEmpty) { + clearTextInEditor(); + } + + insertTextInEditor(text); + } + + Future openAIAssistantModal(Offset? position, Size? size) async { + final fullText = await _getTextOnlyContentInEditor(); + + await AiScribeModalManager.showAIScribeMenuModal( + imagePaths: imagePaths, + availableCategories: AIScribeMenuCategory.values, + buttonPosition: position, + buttonSize: size, + content: fullText, + preferredPlacement: ModalPlacement.top, + crossAxisAlignment: ModalCrossAxisAlignment.start, + onSelectAiScribeSuggestionAction: handleAiScribeSuggestionAction, + ); + } + + void handleAiScribeSuggestionAction( + AiScribeSuggestionActions action, + String suggestionText, + ) { + switch (action) { + case AiScribeSuggestionActions.replace: + onReplaceTextCallback(suggestionText); + break; + case AiScribeSuggestionActions.insert: + onInsertTextCallback(suggestionText); + break; + } + } + + void handleTextSelection(TextSelectionData? textSelectionData) { + if (textSelectionData != null && textSelectionData.hasSelection) { + editorTextSelection.value = TextSelectionModel( + selectedText: textSelectionData.selectedText, + coordinates: textSelectionData.coordinates != null + ? Offset( + textSelectionData.coordinates!.x, + textSelectionData.coordinates!.y, + ) + : null, + ); + } else { + editorTextSelection.value = null; + } + } +} diff --git a/lib/features/composer/presentation/mixin/ai_scribe_in_composer_mixin.dart b/lib/features/composer/presentation/mixin/ai_scribe_in_composer_mixin.dart deleted file mode 100644 index ea345299d7..0000000000 --- a/lib/features/composer/presentation/mixin/ai_scribe_in_composer_mixin.dart +++ /dev/null @@ -1,84 +0,0 @@ -import 'package:core/presentation/resources/image_paths.dart'; -import 'package:core/utils/platform_info.dart'; -import 'package:flutter/material.dart'; -import 'package:get/get.dart'; -import 'package:scribe/scribe.dart'; -import 'package:scribe/scribe/ai/presentation/widgets/ai_scribe.dart'; -import 'package:tmail_ui_user/features/composer/presentation/controller/rich_text_mobile_tablet_controller.dart'; -import 'package:tmail_ui_user/features/composer/presentation/controller/rich_text_web_controller.dart'; -import 'package:tmail_ui_user/features/composer/presentation/widgets/mixins/text_selection_mixin.dart'; - -mixin AIScribeInComposerMixin { - final editorTextSelection = Rxn(); - final GlobalKey aiScribeButtonKey = GlobalKey(); - - RichTextWebController? get richTextWebController; - RichTextMobileTabletController? get richTextMobileTabletController; - ImagePaths get imagePaths; - GenerateAITextInteractor get generateAITextInteractor; - - Future getTextOnlyContentInEditor(); - - void insertTextInEditor(String text) { - final htmlContent = text.replaceAll('\n', '
'); - - if (PlatformInfo.isWeb) { - richTextWebController?.editorController.insertHtml(htmlContent); - } else { - richTextMobileTabletController?.htmlEditorApi?.insertHtml(htmlContent); - } - } - - void showAIScribeMenuForFullText(BuildContext context) async { - final fullText = await getTextOnlyContentInEditor(); - - final RenderBox? renderBox = aiScribeButtonKey.currentContext?.findRenderObject() as RenderBox?; - Offset? buttonPosition; - if (renderBox != null) { - buttonPosition = renderBox.localToGlobal(Offset.zero); - } - - if (!context.mounted) return; - - showAIScribeDialog( - context: context, - imagePaths: imagePaths, - content: fullText, - onInsertText: insertTextInEditor, - interactor: generateAITextInteractor, - buttonPosition: buttonPosition, - ); - } - - void showAIScribeMenuForSelectedText(BuildContext context, {Offset? buttonPosition}) { - final selection = editorTextSelection.value?.selectedText; - if (selection == null || selection.isEmpty) { - return; - } - - showAIScribeDialog( - context: context, - imagePaths: imagePaths, - content: selection, - onInsertText: insertTextInEditor, - interactor: generateAITextInteractor, - buttonPosition: buttonPosition, - ); - } - - void handleTextSelection(TextSelectionData? textSelectionData) { - if (textSelectionData != null && textSelectionData.hasSelection) { - editorTextSelection.value = EditorTextSelection( - selectedText: textSelectionData.selectedText, - coordinates: textSelectionData.coordinates != null - ? Offset( - textSelectionData.coordinates!.x, - textSelectionData.coordinates!.y, - ) - : null, - ); - } else { - editorTextSelection.value = null; - } - } -} diff --git a/lib/features/composer/presentation/widgets/mixins/text_selection_mixin.dart b/lib/features/composer/presentation/mixin/text_selection_mixin.dart similarity index 89% rename from lib/features/composer/presentation/widgets/mixins/text_selection_mixin.dart rename to lib/features/composer/presentation/mixin/text_selection_mixin.dart index 3437ac3d2f..24fa2bb92f 100644 --- a/lib/features/composer/presentation/widgets/mixins/text_selection_mixin.dart +++ b/lib/features/composer/presentation/mixin/text_selection_mixin.dart @@ -69,18 +69,6 @@ class TextSelectionCoordinates { Offset get position => Offset(x, y); } -class EditorTextSelection { - final String? selectedText; - final Offset? coordinates; - - const EditorTextSelection({ - this.selectedText, - this.coordinates, - }); - - bool get hasSelection => selectedText != null && selectedText!.isNotEmpty; -} - mixin TextSelectionMixin on State { OnTextSelectionChanged? get onSelectionChanged => null; diff --git a/lib/features/composer/presentation/widgets/ai_scribe/composer_ai_scribe_selection_overlay.dart b/lib/features/composer/presentation/widgets/ai_scribe/composer_ai_scribe_selection_overlay.dart new file mode 100644 index 0000000000..89f0606016 --- /dev/null +++ b/lib/features/composer/presentation/widgets/ai_scribe/composer_ai_scribe_selection_overlay.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:scribe/scribe/ai/presentation/widgets/overlay/ai_selection_overlay.dart'; +import 'package:tmail_ui_user/features/composer/presentation/composer_controller.dart'; +import 'package:tmail_ui_user/features/composer/presentation/extensions/ai_scribe/handle_ai_scribe_in_composer_extension.dart'; + +class ComposerAiScribeSelectionOverlay extends StatelessWidget { + const ComposerAiScribeSelectionOverlay({ + super.key, + required this.controller, + }); + + final ComposerController controller; + + @override + Widget build(BuildContext context) { + if (!controller.isAIScribeAvailable) { + return const SizedBox.shrink(); + } + + return Obx(() { + return AiSelectionOverlay( + selection: controller.editorTextSelection.value, + imagePaths: controller.imagePaths, + onSelectAiScribeSuggestionAction: + controller.handleAiScribeSuggestionAction, + ); + }); + } +} diff --git a/lib/features/composer/presentation/widgets/mobile/mobile_editor_widget.dart b/lib/features/composer/presentation/widgets/mobile/mobile_editor_widget.dart index 6a4c2d92c7..79daaa71a3 100644 --- a/lib/features/composer/presentation/widgets/mobile/mobile_editor_widget.dart +++ b/lib/features/composer/presentation/widgets/mobile/mobile_editor_widget.dart @@ -5,7 +5,7 @@ import 'package:core/utils/platform_info.dart'; import 'package:core/utils/html/html_utils.dart'; import 'package:flutter/material.dart'; import 'package:rich_text_composer/rich_text_composer.dart'; -import 'package:tmail_ui_user/features/composer/presentation/widgets/mixins/text_selection_mixin.dart'; +import 'package:tmail_ui_user/features/composer/presentation/mixin/text_selection_mixin.dart'; typedef OnCreatedEditorAction = Function(BuildContext context, HtmlEditorApi editorApi, String content); typedef OnLoadCompletedEditorAction = Function(HtmlEditorApi editorApi, WebUri? url); @@ -37,6 +37,13 @@ class MobileEditorWidget extends StatefulWidget { class _MobileEditorState extends State with TextSelectionMixin { HtmlEditorApi? _editorApi; + late String _createdViewId; + + @override + void initState() { + super.initState(); + _createdViewId = HtmlUtils.getRandString(10); + } @override void Function(TextSelectionData?)? get onSelectionChanged => widget.onTextSelectionChanged; @@ -45,8 +52,11 @@ class _MobileEditorState extends State with TextSelectionMix final webViewController = _editorApi?.webViewController; if (webViewController == null) return; + final registerSelectionChange = + HtmlUtils.registerSelectionChangeListener(_createdViewId); + webViewController.addJavaScriptHandler( - handlerName: HtmlUtils.registerSelectionChangeListener.name, + handlerName: registerSelectionChange.name, callback: (args) { if (!mounted) return; @@ -58,7 +68,7 @@ class _MobileEditorState extends State with TextSelectionMix ); await webViewController.evaluateJavascript( - source: HtmlUtils.registerSelectionChangeListener.script, + source: registerSelectionChange.script, ); } diff --git a/lib/features/composer/presentation/widgets/mobile/tablet_bottom_bar_composer_widget.dart b/lib/features/composer/presentation/widgets/mobile/tablet_bottom_bar_composer_widget.dart index d9d1f3dda6..46229fdeca 100644 --- a/lib/features/composer/presentation/widgets/mobile/tablet_bottom_bar_composer_widget.dart +++ b/lib/features/composer/presentation/widgets/mobile/tablet_bottom_bar_composer_widget.dart @@ -1,6 +1,7 @@ import 'package:core/presentation/resources/image_paths.dart'; import 'package:core/presentation/views/button/tmail_button_widget.dart'; import 'package:flutter/material.dart'; +import 'package:scribe/scribe/ai/presentation/widgets/button/ai_assistant_button.dart'; import 'package:tmail_ui_user/features/composer/presentation/styles/mobile/tablet_bottom_bar_composer_widget_style.dart'; import 'package:tmail_ui_user/main/localizations/app_localizations.dart'; @@ -14,8 +15,7 @@ class TabletBottomBarComposerWidget extends StatelessWidget { final VoidCallback sendMessageAction; final VoidCallback requestReadReceiptAction; final VoidCallback toggleMarkAsImportantAction; - final VoidCallback? onOpenAIScribe; - final GlobalKey? aiScribeButtonKey; + final OnOpenAiAssistantModal? onOpenAiAssistantModal; const TabletBottomBarComposerWidget({ super.key, @@ -27,8 +27,7 @@ class TabletBottomBarComposerWidget extends StatelessWidget { required this.sendMessageAction, required this.requestReadReceiptAction, required this.toggleMarkAsImportantAction, - this.onOpenAIScribe, - this.aiScribeButtonKey, + this.onOpenAiAssistantModal, }); @override @@ -75,18 +74,14 @@ class TabletBottomBarComposerWidget extends StatelessWidget { : AppLocalizations.of(context).turnOnRequestReadReceipt, onTapActionCallback: requestReadReceiptAction, ), - if (onOpenAIScribe != null) ...[ - const SizedBox(width: TabletBottomBarComposerWidgetStyle.space), - TMailButtonWidget.fromIcon( - key: aiScribeButtonKey, - icon: imagePaths.icSparkle, - borderRadius: TabletBottomBarComposerWidgetStyle.iconRadius, - padding: TabletBottomBarComposerWidgetStyle.iconPadding, - iconSize: TabletBottomBarComposerWidgetStyle.iconSize, - tooltipMessage: AppLocalizations.of(context).aiAssistant, - onTapActionCallback: onOpenAIScribe!, + if (onOpenAiAssistantModal != null) + AiAssistantButton( + imagePaths: imagePaths, + margin: const EdgeInsetsDirectional.only( + start: TabletBottomBarComposerWidgetStyle.space, + ), + onOpenAiAssistantModal: onOpenAiAssistantModal!, ), - ], const SizedBox(width: TabletBottomBarComposerWidgetStyle.space), TMailButtonWidget.fromIcon( icon: imagePaths.icSaveToDraft, diff --git a/lib/features/composer/presentation/widgets/web/bottom_bar_composer_widget.dart b/lib/features/composer/presentation/widgets/web/bottom_bar_composer_widget.dart index 808ee035b9..bdd09a662b 100644 --- a/lib/features/composer/presentation/widgets/web/bottom_bar_composer_widget.dart +++ b/lib/features/composer/presentation/widgets/web/bottom_bar_composer_widget.dart @@ -4,6 +4,7 @@ import 'package:core/presentation/views/button/tmail_button_widget.dart'; import 'package:core/utils/platform_info.dart'; import 'package:custom_pop_up_menu/custom_pop_up_menu.dart'; import 'package:flutter/material.dart'; +import 'package:scribe/scribe/ai/presentation/widgets/button/ai_assistant_button.dart'; import 'package:tmail_ui_user/features/base/widget/highlight_svg_icon_on_hover.dart'; import 'package:tmail_ui_user/features/base/widget/popup_item_widget.dart'; import 'package:tmail_ui_user/features/base/widget/popup_menu_overlay_widget.dart'; @@ -31,9 +32,8 @@ class BottomBarComposerWidget extends StatelessWidget { final VoidCallback toggleMarkAsImportantAction; final VoidCallback saveAsTemplateAction; final VoidCallback onOpenInsertLink; - final VoidCallback? onOpenAIScribe; - final GlobalKey? aiScribeButtonKey; final OnMenuChanged? onPopupMenuChanged; + final OnOpenAiAssistantModal? onOpenAiAssistantModal; const BottomBarComposerWidget({ super.key, @@ -56,9 +56,8 @@ class BottomBarComposerWidget extends StatelessWidget { required this.toggleMarkAsImportantAction, required this.saveAsTemplateAction, required this.onOpenInsertLink, - this.onOpenAIScribe, - this.aiScribeButtonKey, this.onPopupMenuChanged, + this.onOpenAiAssistantModal, }); @override @@ -131,25 +130,20 @@ class BottomBarComposerWidget extends StatelessWidget { onTapActionCallback: onOpenInsertLink, ), ), - if (onOpenAIScribe != null) ...[ - const SizedBox(width: BottomBarComposerWidgetStyle.space), + if (onOpenAiAssistantModal != null) AbsorbPointer( absorbing: isCodeViewEnabled, - child: TMailButtonWidget.fromIcon( - key: aiScribeButtonKey, - icon: imagePaths.icSparkle, + child: AiAssistantButton( + imagePaths: imagePaths, iconColor: isCodeViewEnabled ? BottomBarComposerWidgetStyle.disabledIconColor - : BottomBarComposerWidgetStyle.iconColor, - borderRadius: BottomBarComposerWidgetStyle.iconRadius, - backgroundColor: Colors.transparent, - padding: BottomBarComposerWidgetStyle.iconPadding, - iconSize: BottomBarComposerWidgetStyle.iconSize, - tooltipMessage: AppLocalizations.of(context).aiAssistant, - onTapActionCallback: onOpenAIScribe!, + : null, + margin: const EdgeInsetsDirectional.only( + start: BottomBarComposerWidgetStyle.space, + ), + onOpenAiAssistantModal: onOpenAiAssistantModal!, ), ), - ], const SizedBox(width: BottomBarComposerWidgetStyle.space), PopupMenuOverlayWidget( controller: menuMoreOptionController, diff --git a/lib/features/composer/presentation/widgets/web/web_editor_widget.dart b/lib/features/composer/presentation/widgets/web/web_editor_widget.dart index d2ecc749fd..a0e177d0f7 100644 --- a/lib/features/composer/presentation/widgets/web/web_editor_widget.dart +++ b/lib/features/composer/presentation/widgets/web/web_editor_widget.dart @@ -6,7 +6,7 @@ import 'package:core/utils/html/html_template.dart'; import 'package:core/utils/html/html_utils.dart'; import 'package:flutter/material.dart'; import 'package:html_editor_enhanced/html_editor.dart'; -import 'package:tmail_ui_user/features/composer/presentation/widgets/mixins/text_selection_mixin.dart'; +import 'package:tmail_ui_user/features/composer/presentation/mixin/text_selection_mixin.dart'; import 'package:tmail_ui_user/features/composer/presentation/widgets/web/signature_tooltip_widget.dart'; import 'package:tmail_ui_user/main/localizations/app_localizations.dart'; import 'package:universal_html/html.dart' hide VoidCallback; @@ -91,14 +91,20 @@ class _WebEditorState extends State with TextSelectionMixin { double _signatureTooltipLeft = 0; bool _signatureTooltipReady = false; + late String _createdViewId; + @override void Function(TextSelectionData?)? get onSelectionChanged => widget.onTextSelectionChanged; @override void initState() { super.initState(); + _createdViewId = HtmlUtils.getRandString(10); _editorController = widget.editorController; - + + final registerSelectionChange = + HtmlUtils.registerSelectionChangeListener(_createdViewId); + _editorListener = (event) { try { if (event is MessageEvent) { @@ -106,7 +112,8 @@ class _WebEditorState extends State with TextSelectionMixin { if (data['name'] == HtmlUtils.registerDropListener.name) { _editorController.evaluateJavascriptWeb(HtmlUtils.removeLineHeight1px.name); - } else if (data['name'] == HtmlUtils.registerSelectionChangeListener.name) { + } else if (data['name'] == registerSelectionChange.name + && data['viewId'] == _createdViewId) { handleSelectionChange(data); } } @@ -182,8 +189,12 @@ class _WebEditorState extends State with TextSelectionMixin { script: HtmlUtils.unregisterDropListener.script, ), WebScript( - name: HtmlUtils.registerSelectionChangeListener.name, - script: HtmlUtils.registerSelectionChangeListener.script, + name: HtmlUtils.registerSelectionChangeListener(_createdViewId).name, + script: HtmlUtils.registerSelectionChangeListener(_createdViewId).script, + ), + WebScript( + name: HtmlUtils.collapseSelectionToEnd.name, + script: HtmlUtils.collapseSelectionToEnd.script, ), WebScript( name: HtmlUtils.recalculateEditorHeight(maxHeight: maxHeight).name, @@ -205,7 +216,7 @@ class _WebEditorState extends State with TextSelectionMixin { _editorController.evaluateJavascriptWeb( HtmlUtils.registerDropListener.name); _editorController.evaluateJavascriptWeb( - HtmlUtils.registerSelectionChangeListener.name); + HtmlUtils.registerSelectionChangeListener(_createdViewId).name); _editorListenerRegistered = true; } }, diff --git a/lib/features/home/domain/converter/capability_properties_converter.dart b/lib/features/home/domain/converter/capability_properties_converter.dart index 7c80cdb26f..e9534cde6c 100644 --- a/lib/features/home/domain/converter/capability_properties_converter.dart +++ b/lib/features/home/domain/converter/capability_properties_converter.dart @@ -12,6 +12,7 @@ import 'package:jmap_dart_client/jmap/core/capability/web_socket_ticket_capabili import 'package:jmap_dart_client/jmap/core/capability/websocket_capability.dart'; import 'package:model/saas/saas_account_capability.dart'; import 'package:model/support/contact_support_capability.dart'; +import 'package:scribe/scribe/ai/presentation/model/ai_capability.dart'; class CapabilityPropertiesConverter { @@ -38,6 +39,8 @@ class CapabilityPropertiesConverter { return properties.toJson(); } else if (properties is SaaSAccountCapability) { return properties.toJson(); + } else if (properties is AICapability) { + return properties.toJson(); } else if (properties is DefaultCapability) { return properties.properties; } else if (properties is EmptyCapability) { diff --git a/lib/features/home/domain/extensions/session_extensions.dart b/lib/features/home/domain/extensions/session_extensions.dart index 67af48f844..88530af63b 100644 --- a/lib/features/home/domain/extensions/session_extensions.dart +++ b/lib/features/home/domain/extensions/session_extensions.dart @@ -15,6 +15,8 @@ import 'package:jmap_dart_client/jmap/core/unsigned_int.dart'; import 'package:model/download_all/download_all_capability.dart'; import 'package:model/mailbox/mailbox_constants.dart'; import 'package:model/model.dart'; +import 'package:scribe/scribe/ai/presentation/model/ai_capability.dart'; +import 'package:scribe/scribe/ai/presentation/utils/ai_scribe_constants.dart'; import 'package:server_settings/server_settings/capability_server_settings.dart'; import 'package:tmail_ui_user/features/home/data/model/session_hive_obj.dart'; import 'package:tmail_ui_user/features/home/domain/converter/session_account_converter.dart'; @@ -33,6 +35,7 @@ extension SessionExtensions on Session { linagoraDownloadAllCapability: DownloadAllCapability.deserialize, capabilityServerSettings: SettingsCapability.deserialize, linagoraSaaSCapability: SaaSAccountCapability.deserialize, + AiScribeConstants.aiCapability: AICapability.fromJson, }; Map toJson() { @@ -165,4 +168,18 @@ extension SessionExtensions on Session { return null; } } + + AICapability? getAICapability(AccountId accountId) { + try { + final aiCapability = getCapabilityProperties( + accountId, + AiScribeConstants.aiCapability, + ); + log('SessionExtensions::getAICapability:aiCapability = $aiCapability'); + return aiCapability; + } catch (e) { + logError('SessionExtensions::getAICapability():[Exception] $e'); + return null; + } + } } \ No newline at end of file diff --git a/lib/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller.dart b/lib/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller.dart index 67ebc80126..5fead10f9d 100644 --- a/lib/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller.dart +++ b/lib/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller.dart @@ -31,6 +31,7 @@ import 'package:receive_sharing_intent/receive_sharing_intent.dart'; import 'package:rxdart/transformers.dart'; import 'package:server_settings/server_settings/tmail_server_settings_extension.dart'; import 'package:tmail_ui_user/features/base/action/ui_action.dart'; +import 'package:tmail_ui_user/features/base/mixin/ai_scribe_mixin.dart'; import 'package:tmail_ui_user/features/base/mixin/contact_support_mixin.dart'; import 'package:tmail_ui_user/features/base/mixin/message_dialog_action_manager.dart'; import 'package:tmail_ui_user/features/base/mixin/own_email_address_mixin.dart'; @@ -216,7 +217,8 @@ import 'package:uuid/uuid.dart'; class MailboxDashBoardController extends ReloadableController with ContactSupportMixin, OwnEmailAddressMixin, - SaaSPremiumMixin { + SaaSPremiumMixin, + AiScribeMixin { final RemoveEmailDraftsInteractor _removeEmailDraftsInteractor = Get.find(); final EmailReceiveManager _emailReceiveManager = Get.find(); @@ -857,6 +859,7 @@ class MailboxDashBoardController extends ReloadableController injectVacationBindings(session, currentAccountId); injectWebSocket(session, currentAccountId); injectPreferencesBindings(); + injectAIScribeBindings(session, currentAccountId); if (PlatformInfo.isMobile) { injectFCMBindings(session, currentAccountId); } diff --git a/lib/features/manage_account/presentation/manage_account_dashboard_controller.dart b/lib/features/manage_account/presentation/manage_account_dashboard_controller.dart index 6b9db045a8..d74c43917c 100644 --- a/lib/features/manage_account/presentation/manage_account_dashboard_controller.dart +++ b/lib/features/manage_account/presentation/manage_account_dashboard_controller.dart @@ -14,6 +14,7 @@ import 'package:model/model.dart'; import 'package:rule_filter/rule_filter/capability_rule_filter.dart'; import 'package:server_settings/server_settings/capability_server_settings.dart'; import 'package:tmail_ui_user/features/base/action/ui_action.dart'; +import 'package:tmail_ui_user/features/base/mixin/ai_scribe_mixin.dart'; import 'package:tmail_ui_user/features/base/mixin/own_email_address_mixin.dart'; import 'package:tmail_ui_user/features/base/reloadable/reloadable_controller.dart'; import 'package:tmail_ui_user/features/base/widget/dialog_picker/color_dialog_picker.dart'; @@ -55,7 +56,7 @@ import 'package:tmail_ui_user/main/routes/route_utils.dart'; import 'package:tmail_ui_user/main/utils/app_config.dart'; class ManageAccountDashBoardController extends ReloadableController - with OwnEmailAddressMixin { + with OwnEmailAddressMixin, AiScribeMixin { GetAllVacationInteractor? _getAllVacationInteractor; UpdateVacationInteractor? _updateVacationInteractor; @@ -152,6 +153,7 @@ class ManageAccountDashBoardController extends ReloadableController _setUpMinInputLengthAutocomplete(); _bindingInteractorForMenuItemView(sessionCurrent, accountId.value); _getVacationResponse(); + injectAIScribeBindings(sessionCurrent, accountId.value); paywallController = PaywallController( ownEmailAddress: ownEmailAddress.value, ); diff --git a/lib/l10n/intl_messages.arb b/lib/l10n/intl_messages.arb index 164bb43fb6..902ec63aa4 100644 --- a/lib/l10n/intl_messages.arb +++ b/lib/l10n/intl_messages.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2025-12-10T10:22:35.345340", + "@@last_modified": "2025-12-15T14:32:52.563522", "initializing_data": "Initializing data...", "@initializing_data": { "type": "text", diff --git a/lib/main/bindings/main_bindings.dart b/lib/main/bindings/main_bindings.dart index 30b9b67dbc..fc5618e0fa 100644 --- a/lib/main/bindings/main_bindings.dart +++ b/lib/main/bindings/main_bindings.dart @@ -1,6 +1,5 @@ import 'package:core/utils/platform_info.dart'; import 'package:get/get.dart'; -import 'package:scribe/scribe/ai/presentation/bindings/ai_scribe_bindings.dart'; import 'package:tmail_ui_user/main/bindings/core/core_bindings.dart'; import 'package:tmail_ui_user/main/bindings/credential/credential_bindings.dart'; import 'package:tmail_ui_user/main/bindings/deep_link/deep_link_bindings.dart'; @@ -22,7 +21,6 @@ class MainBindings extends Bindings { CredentialBindings().dependencies(); SessionBindings().dependencies(); NetWorkConnectionBindings().dependencies(); - AIScribeBindings().dependencies(); if (PlatformInfo.isMobile) { DeepLinkBindings().dependencies(); } diff --git a/lib/main/localizations/app_localizations.dart b/lib/main/localizations/app_localizations.dart index bb0127f1f1..ade2495b22 100644 --- a/lib/main/localizations/app_localizations.dart +++ b/lib/main/localizations/app_localizations.dart @@ -5436,11 +5436,4 @@ class AppLocalizations { name: 'startToAddFavoritesEmails', ); } - - String get aiAssistant { - return Intl.message( - 'AI assistant', - name: 'aiAssistant', - ); - } } diff --git a/scribe/lib/scribe.dart b/scribe/lib/scribe.dart index 29ea60b769..c8be702847 100644 --- a/scribe/lib/scribe.dart +++ b/scribe/lib/scribe.dart @@ -1,11 +1,43 @@ library scribe; -export 'scribe/ai/domain/usecases/generate_ai_text_interactor.dart'; -export 'scribe/ai/domain/repository/ai_scribe_repository.dart'; +export 'scribe/ai/data/datasource/ai_datasource.dart'; +export 'scribe/ai/data/repository/ai_repository_impl.dart'; export 'scribe/ai/domain/model/ai_response.dart'; +export 'scribe/ai/domain/repository/ai_scribe_repository.dart'; export 'scribe/ai/domain/state/generate_ai_text_state.dart'; +export 'scribe/ai/domain/usecases/generate_ai_text_interactor.dart'; +export 'scribe/ai/localizations/scribe_localizations.dart'; export 'scribe/ai/presentation/model/ai_action.dart'; +export 'scribe/ai/presentation/model/ai_capability.dart'; export 'scribe/ai/presentation/model/ai_scribe_menu_action.dart'; -export 'scribe/ai/data/repository/ai_repository_impl.dart'; -export 'scribe/ai/data/datasource/ai_datasource.dart'; -export 'scribe/ai/data/config/ai_config.dart'; +export 'scribe/ai/presentation/model/context_menu/ai_scribe_action_context_menu_action.dart'; +export 'scribe/ai/presentation/model/context_menu/ai_scribe_category_context_menu_action.dart'; +export 'scribe/ai/presentation/model/context_menu/ai_scribe_context_menu_action.dart'; +export 'scribe/ai/presentation/model/context_menu/ai_scribe_suggestion_actions.dart'; +export 'scribe/ai/presentation/model/modal/anchored_modal_layout_input.dart'; +export 'scribe/ai/presentation/model/modal/anchored_modal_layout_result.dart'; +export 'scribe/ai/presentation/model/modal/anchored_suggestion_layout_result.dart'; +export 'scribe/ai/presentation/model/modal/modal_cross_axis_alignment.dart'; +export 'scribe/ai/presentation/model/modal/modal_placement.dart'; +export 'scribe/ai/presentation/model/text_selection_model.dart'; +export 'scribe/ai/presentation/styles/ai_scribe_styles.dart'; +export 'scribe/ai/presentation/utils/ai_scribe_constants.dart'; +export 'scribe/ai/presentation/utils/context_menu/hover_submenu_controller.dart'; +export 'scribe/ai/presentation/utils/context_menu/popup_submenu_controller.dart'; +export 'scribe/ai/presentation/utils/modal/ai_scribe_modal_manager.dart'; +export 'scribe/ai/presentation/utils/modal/anchored_modal_layout_calculator.dart'; +export 'scribe/ai/presentation/widgets/button/ai_assistant_button.dart'; +export 'scribe/ai/presentation/widgets/button/inline_ai_assist_button.dart'; +export 'scribe/ai/presentation/widgets/context_menu/ai_scribe_context_menu.dart'; +export 'scribe/ai/presentation/widgets/context_menu/ai_scribe_context_menu_item.dart'; +export 'scribe/ai/presentation/widgets/context_menu/ai_scribe_submenu.dart'; +export 'scribe/ai/presentation/widgets/context_menu/ai_scribe_submenu_item.dart'; +export 'scribe/ai/presentation/widgets/modal/ai_scribe_modal_widget.dart'; +export 'scribe/ai/presentation/widgets/modal/ai_scribe_suggestion_widget.dart'; +export 'scribe/ai/presentation/widgets/modal/suggestion/ai_scribe_suggestion_error.dart'; +export 'scribe/ai/presentation/widgets/modal/suggestion/ai_scribe_suggestion_header.dart'; +export 'scribe/ai/presentation/widgets/modal/suggestion/ai_scribe_suggestion_loading.dart'; +export 'scribe/ai/presentation/widgets/modal/suggestion/ai_scribe_suggestion_success.dart'; +export 'scribe/ai/presentation/widgets/modal/suggestion/ai_scribe_suggestion_success_list_actions.dart'; +export 'scribe/ai/presentation/widgets/overlay/ai_selection_overlay.dart'; +export 'scribe/ai/presentation/widgets/search/ai_scribe_bar.dart'; diff --git a/scribe/lib/scribe/ai/data/config/ai_config.dart b/scribe/lib/scribe/ai/data/config/ai_config.dart deleted file mode 100644 index 568d417f19..0000000000 --- a/scribe/lib/scribe/ai/data/config/ai_config.dart +++ /dev/null @@ -1,11 +0,0 @@ -import 'package:flutter_dotenv/flutter_dotenv.dart'; - -class AIConfig { - const AIConfig._(); - - static bool get isAiEnabled => dotenv.get('AI_ENABLED', fallback: 'false') == 'true'; - - static String get aiApiKey => dotenv.get('AI_API_KEY', fallback: ''); - - static String get aiApiUrl => dotenv.get('AI_API_URL', fallback: ''); -} diff --git a/scribe/lib/scribe/ai/data/datasource/ai_datasource.dart b/scribe/lib/scribe/ai/data/datasource/ai_datasource.dart index 70f7409c93..73af09b4dd 100644 --- a/scribe/lib/scribe/ai/data/datasource/ai_datasource.dart +++ b/scribe/lib/scribe/ai/data/datasource/ai_datasource.dart @@ -1,5 +1,5 @@ import 'package:scribe/scribe/ai/domain/model/ai_response.dart'; abstract class AIDataSource { - Future request(String prompt); + Future generateMessage(String prompt); } diff --git a/scribe/lib/scribe/ai/data/datasource_impl/ai_datasource_impl.dart b/scribe/lib/scribe/ai/data/datasource_impl/ai_datasource_impl.dart index b3bc0c553c..00130be627 100644 --- a/scribe/lib/scribe/ai/data/datasource_impl/ai_datasource_impl.dart +++ b/scribe/lib/scribe/ai/data/datasource_impl/ai_datasource_impl.dart @@ -1,35 +1,17 @@ import 'package:dio/dio.dart'; -import 'package:scribe/scribe/ai/data/config/ai_config.dart'; import 'package:scribe/scribe/ai/data/datasource/ai_datasource.dart'; -import 'package:scribe/scribe/ai/data/model/ai_message.dart'; -import 'package:scribe/scribe/ai/data/model/ai_api_request.dart'; import 'package:scribe/scribe/ai/data/network/ai_api.dart'; import 'package:scribe/scribe/ai/domain/model/ai_response.dart'; class AIDataSourceImpl implements AIDataSource { final AIApi _aiApi; - AIDataSourceImpl({ - required Dio dio, - }) : _aiApi = AIApi( - dio: dio, - apiKey: AIConfig.aiApiKey, - baseUrl: AIConfig.aiApiUrl, - ); + AIDataSourceImpl(this._aiApi); @override - Future request(String prompt) async { + Future generateMessage(String prompt) async { try { - final aiRequest = AIAPIRequest( - messages: [ - AIMessage( - role: 'user', - content: prompt, - ), - ], - ); - - final apiResponse = await _aiApi.chatCompletion(aiRequest); + final apiResponse = await _aiApi.generateMessage(prompt); return AIResponse(result: apiResponse.content); } on DioError catch (e) { throw Exception('Failed to generate AI text: ${e.message}'); diff --git a/scribe/lib/scribe/ai/data/model/ai_api_request.dart b/scribe/lib/scribe/ai/data/model/ai_api_request.dart index ba27b29a40..822f2b2aef 100644 --- a/scribe/lib/scribe/ai/data/model/ai_api_request.dart +++ b/scribe/lib/scribe/ai/data/model/ai_api_request.dart @@ -1,8 +1,6 @@ import 'package:scribe/scribe/ai/data/model/ai_message.dart'; class AIAPIRequest { - static const String _defaultModel = 'gpt-oss-120b'; - final List messages; const AIAPIRequest({ @@ -11,7 +9,6 @@ class AIAPIRequest { Map toJson() { return { - 'model': _defaultModel, 'messages': messages.map((m) => m.toJson()).toList(), }; } diff --git a/scribe/lib/scribe/ai/data/model/ai_api_response.dart b/scribe/lib/scribe/ai/data/model/ai_api_response.dart index 118bb4a8da..c5ca7261bc 100644 --- a/scribe/lib/scribe/ai/data/model/ai_api_response.dart +++ b/scribe/lib/scribe/ai/data/model/ai_api_response.dart @@ -6,11 +6,10 @@ part 'ai_api_response.g.dart'; class AIApiResponse { final List choices; - const AIApiResponse({ - required this.choices - }); + const AIApiResponse({required this.choices}); - factory AIApiResponse.fromJson(Map json) => _$AIApiResponseFromJson(json); + factory AIApiResponse.fromJson(Map json) => + _$AIApiResponseFromJson(json); Map toJson() => _$AIApiResponseToJson(this); @@ -21,9 +20,7 @@ class AIApiResponse { class Choice { final Message message; - const Choice({ - required this.message - }); + const Choice({required this.message}); factory Choice.fromJson(Map json) => _$ChoiceFromJson(json); @@ -34,11 +31,10 @@ class Choice { class Message { final String content; - const Message({ - required this.content - }); + const Message({required this.content}); - factory Message.fromJson(Map json) => _$MessageFromJson(json); + factory Message.fromJson(Map json) => + _$MessageFromJson(json); Map toJson() => _$MessageToJson(this); } diff --git a/scribe/lib/scribe/ai/data/model/ai_message.dart b/scribe/lib/scribe/ai/data/model/ai_message.dart index 9f318800ce..67d2482a85 100644 --- a/scribe/lib/scribe/ai/data/model/ai_message.dart +++ b/scribe/lib/scribe/ai/data/model/ai_message.dart @@ -12,7 +12,13 @@ class AIMessage { required this.content, }); - factory AIMessage.fromJson(Map json) => _$AIMessageFromJson(json); + factory AIMessage.fromJson(Map json) => + _$AIMessageFromJson(json); Map toJson() => _$AIMessageToJson(this); + + factory AIMessage.ofUser(String content) => AIMessage( + role: 'user', + content: content, + ); } diff --git a/scribe/lib/scribe/ai/data/network/ai_api.dart b/scribe/lib/scribe/ai/data/network/ai_api.dart index f89ff71816..374effbae0 100644 --- a/scribe/lib/scribe/ai/data/network/ai_api.dart +++ b/scribe/lib/scribe/ai/data/network/ai_api.dart @@ -1,39 +1,27 @@ -import 'package:dio/dio.dart'; -import 'package:scribe/scribe/ai/data/model/ai_api_response.dart'; +import 'package:core/data/network/dio_client.dart'; import 'package:scribe/scribe/ai/data/model/ai_api_request.dart'; +import 'package:scribe/scribe/ai/data/model/ai_api_response.dart'; +import 'package:scribe/scribe/ai/data/model/ai_message.dart'; class AIApi { - final Dio _dio; - final String _apiKey; - final String _baseUrl; + final DioClient _dioClient; + final String aiEndpoint; + + AIApi(this._dioClient, this.aiEndpoint); - AIApi({ - required Dio dio, - required String apiKey, - required String baseUrl, - }) : _dio = dio, - _apiKey = apiKey, - _baseUrl = baseUrl; + Future generateMessage(String prompt) async { + final aiRequest = _generateRequest(prompt); - Future chatCompletion(AIAPIRequest request) async { - final response = await _dio.post( - '$_baseUrl/chat/completions', - options: Options( - headers: { - 'Authorization': 'Bearer $_apiKey', - 'Content-Type': 'application/json', - }, - ), - data: request.toJson(), + final response = await _dioClient.post( + aiEndpoint, + data: aiRequest.toJson(), + useJMAPHeader: false, ); - if (response.statusCode == 200) { - if (response.data == null || response.data.isEmpty) { - throw Exception('AI API returned empty response'); - } - return AIApiResponse.fromJson(response.data); - } else { - throw Exception('AI API returned status code: ${response.statusCode}'); - } + return AIApiResponse.fromJson(response); + } + + AIAPIRequest _generateRequest(String prompt) { + return AIAPIRequest(messages: [AIMessage.ofUser(prompt)]); } } diff --git a/scribe/lib/scribe/ai/data/network/ai_api_exception.dart b/scribe/lib/scribe/ai/data/network/ai_api_exception.dart new file mode 100644 index 0000000000..f31f09e5cc --- /dev/null +++ b/scribe/lib/scribe/ai/data/network/ai_api_exception.dart @@ -0,0 +1,26 @@ +class AIApiException implements Exception { + final String message; + final int? statusCode; + + AIApiException(this.message, {this.statusCode}); + + @override + String toString() { + if (statusCode != null) { + return 'AIApiException: $message (status code: $statusCode)'; + } + return 'AIApiException: $message'; + } +} + +class AIApiNotAvailableException extends AIApiException { + AIApiNotAvailableException() : super('AI API is not available'); +} + +class AIApiEmptyResponseException extends AIApiException { + AIApiEmptyResponseException() : super('AI API returned empty response'); +} + +class GenerateAITextInteractorIsNotRegisteredException extends AIApiException { + GenerateAITextInteractorIsNotRegisteredException() : super('GenerateAITextInteractor is not registered'); +} \ No newline at end of file diff --git a/scribe/lib/scribe/ai/data/repository/ai_repository_impl.dart b/scribe/lib/scribe/ai/data/repository/ai_repository_impl.dart index a88c5ec6bd..f3d6115f14 100644 --- a/scribe/lib/scribe/ai/data/repository/ai_repository_impl.dart +++ b/scribe/lib/scribe/ai/data/repository/ai_repository_impl.dart @@ -3,12 +3,12 @@ import 'package:scribe/scribe/ai/domain/model/ai_response.dart'; import 'package:scribe/scribe/ai/domain/repository/ai_scribe_repository.dart'; class AIScribeRepositoryImpl implements AIScribeRepository { - final AIDataSource _dataSource; + final AIDataSource _aiDataSource; - AIScribeRepositoryImpl(this._dataSource); + AIScribeRepositoryImpl(this._aiDataSource); @override - Future generateText(String prompt) async { - return _dataSource.request(prompt); + Future generateMessage(String prompt) { + return _aiDataSource.generateMessage(prompt); } } diff --git a/scribe/lib/scribe/ai/domain/constants/ai_prompts.dart b/scribe/lib/scribe/ai/domain/constants/ai_prompts.dart index df86643a9e..0c76dfa621 100644 --- a/scribe/lib/scribe/ai/domain/constants/ai_prompts.dart +++ b/scribe/lib/scribe/ai/domain/constants/ai_prompts.dart @@ -6,8 +6,10 @@ import 'package:scribe/scribe/ai/presentation/model/ai_scribe_menu_action.dart'; class AIPrompts { static String buildPrompt(AIAction action, String? text) { return switch (action) { - PredefinedAction(action: final menuAction) => buildPredefinedPrompt(menuAction, text ?? ''), - CustomPromptAction(prompt: final customPrompt) => buildCustomPrompt(customPrompt, text), + PredefinedAction(action: final menuAction) => + buildPredefinedPrompt(menuAction, text ?? ''), + CustomPromptAction(prompt: final customPrompt) => + buildCustomPrompt(customPrompt, text), }; } diff --git a/scribe/lib/scribe/ai/domain/repository/ai_scribe_repository.dart b/scribe/lib/scribe/ai/domain/repository/ai_scribe_repository.dart index 8942eaa7d6..2412bf7580 100644 --- a/scribe/lib/scribe/ai/domain/repository/ai_scribe_repository.dart +++ b/scribe/lib/scribe/ai/domain/repository/ai_scribe_repository.dart @@ -1,5 +1,5 @@ import 'package:scribe/scribe/ai/domain/model/ai_response.dart'; abstract class AIScribeRepository { - Future generateText(String prompt); + Future generateMessage(String prompt); } diff --git a/scribe/lib/scribe/ai/domain/state/generate_ai_text_state.dart b/scribe/lib/scribe/ai/domain/state/generate_ai_text_state.dart index 0b181bcf19..eb989dca2a 100644 --- a/scribe/lib/scribe/ai/domain/state/generate_ai_text_state.dart +++ b/scribe/lib/scribe/ai/domain/state/generate_ai_text_state.dart @@ -1,4 +1,5 @@ -import 'package:core/core.dart'; +import 'package:core/presentation/state/failure.dart'; +import 'package:core/presentation/state/success.dart'; import 'package:scribe/scribe/ai/domain/model/ai_response.dart'; class GenerateAITextLoading extends LoadingState {} diff --git a/scribe/lib/scribe/ai/domain/usecases/generate_ai_text_interactor.dart b/scribe/lib/scribe/ai/domain/usecases/generate_ai_text_interactor.dart index 5539dc7415..496642539b 100644 --- a/scribe/lib/scribe/ai/domain/usecases/generate_ai_text_interactor.dart +++ b/scribe/lib/scribe/ai/domain/usecases/generate_ai_text_interactor.dart @@ -1,4 +1,5 @@ -import 'package:core/core.dart'; +import 'package:core/presentation/state/failure.dart'; +import 'package:core/presentation/state/success.dart'; import 'package:dartz/dartz.dart'; import 'package:scribe/scribe/ai/domain/constants/ai_prompts.dart'; import 'package:scribe/scribe/ai/domain/repository/ai_scribe_repository.dart'; @@ -16,9 +17,7 @@ class GenerateAITextInteractor { ) async { try { final prompt = AIPrompts.buildPrompt(action, selectedText); - - final response = await _repository.generateText(prompt); - + final response = await _repository.generateMessage(prompt); return Right(GenerateAITextSuccess(response)); } catch (e) { return Left(GenerateAITextFailure(e)); diff --git a/scribe/lib/scribe/ai/l10n/intl_en.arb b/scribe/lib/scribe/ai/l10n/intl_en.arb index 72657c82ad..45384b6447 100644 --- a/scribe/lib/scribe/ai/l10n/intl_en.arb +++ b/scribe/lib/scribe/ai/l10n/intl_en.arb @@ -113,5 +113,11 @@ "type": "text", "placeholders_order": [], "placeholders": {} + }, + "replaceButton": "Replace", + "@replaceButton": { + "type": "text", + "placeholders_order": [], + "placeholders": {} } } \ No newline at end of file diff --git a/scribe/lib/scribe/ai/l10n/intl_fr.arb b/scribe/lib/scribe/ai/l10n/intl_fr.arb index 6d910f946f..42626526e4 100644 --- a/scribe/lib/scribe/ai/l10n/intl_fr.arb +++ b/scribe/lib/scribe/ai/l10n/intl_fr.arb @@ -113,5 +113,11 @@ "type": "text", "placeholders_order": [], "placeholders": {} + }, + "replaceButton": "Remplacer", + "@replaceButton": { + "type": "text", + "placeholders_order": [], + "placeholders": {} } } diff --git a/scribe/lib/scribe/ai/l10n/intl_messages.arb b/scribe/lib/scribe/ai/l10n/intl_messages.arb index 89da188c00..b47b4bd078 100644 --- a/scribe/lib/scribe/ai/l10n/intl_messages.arb +++ b/scribe/lib/scribe/ai/l10n/intl_messages.arb @@ -1,5 +1,5 @@ { - "@@last_modified": "2025-12-10T10:22:36.990738", + "@@last_modified": "2025-12-18T02:49:48.638213", "categoryCorrectGrammar": "Correct grammar", "@categoryCorrectGrammar": { "type": "text", @@ -113,5 +113,29 @@ "type": "text", "placeholders_order": [], "placeholders": {} + }, + "aiAssistant": "AI assistant", + "@aiAssistant": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "generatingResponse": "Generating response", + "@generatingResponse": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "replace": "Replace", + "@replace": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "insert": "Insert", + "@insert": { + "type": "text", + "placeholders_order": [], + "placeholders": {} } } \ No newline at end of file diff --git a/scribe/lib/scribe/ai/l10n/intl_ru.arb b/scribe/lib/scribe/ai/l10n/intl_ru.arb index bb406d97b4..8e1e9fc16f 100644 --- a/scribe/lib/scribe/ai/l10n/intl_ru.arb +++ b/scribe/lib/scribe/ai/l10n/intl_ru.arb @@ -113,5 +113,11 @@ "type": "text", "placeholders_order": [], "placeholders": {} + }, + "replaceButton": "заменить", + "@replaceButton": { + "type": "text", + "placeholders_order": [], + "placeholders": {} } } diff --git a/scribe/lib/scribe/ai/l10n/intl_vi.arb b/scribe/lib/scribe/ai/l10n/intl_vi.arb index 3fa174a214..04b7e9da13 100644 --- a/scribe/lib/scribe/ai/l10n/intl_vi.arb +++ b/scribe/lib/scribe/ai/l10n/intl_vi.arb @@ -113,5 +113,11 @@ "type": "text", "placeholders_order": [], "placeholders": {} + }, + "replaceButton": "Thay thế", + "@replaceButton": { + "type": "text", + "placeholders_order": [], + "placeholders": {} } } diff --git a/scribe/lib/scribe/ai/localizations/scribe_localizations.dart b/scribe/lib/scribe/ai/localizations/scribe_localizations.dart index 5c6e4f90a6..6856da55d0 100644 --- a/scribe/lib/scribe/ai/localizations/scribe_localizations.dart +++ b/scribe/lib/scribe/ai/localizations/scribe_localizations.dart @@ -3,13 +3,13 @@ import 'package:intl/intl.dart'; import 'package:scribe/scribe/ai/l10n/messages_all.dart'; class ScribeLocalizations { - - static ScribeLocalizations? of(BuildContext context) { - return Localizations.of(context, ScribeLocalizations); + static ScribeLocalizations of(BuildContext context) { + return Localizations.of(context, ScribeLocalizations)!; } static Future load(Locale locale) async { - final name = locale.countryCode == null ? locale.languageCode : locale.toString(); + final name = + locale.countryCode == null ? locale.languageCode : locale.toString(); final localeName = Intl.canonicalizedLocale(name); @@ -19,118 +19,188 @@ class ScribeLocalizations { }); } - static const LocalizationsDelegate delegate = _ScribeLocalizationsDelegate(); + static const LocalizationsDelegate delegate = + _ScribeLocalizationsDelegate(); // Menu Categories String get categoryCorrectGrammar { - return Intl.message('Correct grammar', - name: 'categoryCorrectGrammar'); + return Intl.message( + 'Correct grammar', + name: 'categoryCorrectGrammar', + ); } String get categoryImprove { - return Intl.message('Improve', - name: 'categoryImprove'); + return Intl.message( + 'Improve', + name: 'categoryImprove', + ); } String get categoryChangeTone { - return Intl.message('Change tone', - name: 'categoryChangeTone'); + return Intl.message( + 'Change tone', + name: 'categoryChangeTone', + ); } String get categoryTranslate { - return Intl.message('Translate', - name: 'categoryTranslate'); + return Intl.message( + 'Translate', + name: 'categoryTranslate', + ); } // Menu Actions - Improve String get actionMakeShorter { - return Intl.message('Make it shorter', - name: 'actionMakeShorter'); + return Intl.message( + 'Make it shorter', + name: 'actionMakeShorter', + ); } String get actionExpandContext { - return Intl.message('Expand context', - name: 'actionExpandContext'); + return Intl.message( + 'Expand context', + name: 'actionExpandContext', + ); } String get actionEmojify { - return Intl.message('Emojify', - name: 'actionEmojify'); + return Intl.message( + 'Emojify', + name: 'actionEmojify', + ); } String get actionTransformToBullets { - return Intl.message('Transform to bullets', - name: 'actionTransformToBullets'); + return Intl.message( + 'Transform to bullets', + name: 'actionTransformToBullets', + ); } // Menu Actions - Change Tone String get actionMoreProfessional { - return Intl.message('More professional', - name: 'actionMoreProfessional'); + return Intl.message( + 'More professional', + name: 'actionMoreProfessional', + ); } String get actionMoreCasual { - return Intl.message('More casual', - name: 'actionMoreCasual'); + return Intl.message( + 'More casual', + name: 'actionMoreCasual', + ); } String get actionMorePolite { - return Intl.message('More polite', - name: 'actionMorePolite'); + return Intl.message( + 'More polite', + name: 'actionMorePolite', + ); } // Menu Actions - Translate String get languageFrench { - return Intl.message('French', - name: 'languageFrench'); + return Intl.message( + 'French', + name: 'languageFrench', + ); } String get languageEnglish { - return Intl.message('English', - name: 'languageEnglish'); + return Intl.message( + 'English', + name: 'languageEnglish', + ); } String get languageRussian { - return Intl.message('Russian', - name: 'languageRussian'); + return Intl.message( + 'Russian', + name: 'languageRussian', + ); } String get languageVietnamese { - return Intl.message('Vietnamese', - name: 'languageVietnamese'); + return Intl.message( + 'Vietnamese', + name: 'languageVietnamese', + ); } // Input Bar String get inputPlaceholder { - return Intl.message('Help me write', - name: 'inputPlaceholder'); + return Intl.message( + 'Help me write', + name: 'inputPlaceholder', + ); } String get customPromptAction { - return Intl.message('Help me write', - name: 'customPromptAction'); + return Intl.message( + 'Help me write', + name: 'customPromptAction', + ); } // Suggestion Dialog String get failedToGenerate { - return Intl.message('Failed to generate AI response', - name: 'failedToGenerate'); + return Intl.message( + 'Failed to generate AI response', + name: 'failedToGenerate', + ); } String get insertButton { - return Intl.message('Insert', - name: 'insertButton'); + return Intl.message( + 'Insert', + name: 'insertButton', + ); + } + + String get aiAssistant { + return Intl.message( + 'AI assistant', + name: 'aiAssistant', + ); + } + + String get generatingResponse { + return Intl.message( + 'Generating response', + name: 'generatingResponse', + ); + } + + String get replace { + return Intl.message( + 'Replace', + name: 'replace', + ); + } + + String get insert { + return Intl.message( + 'Insert', + name: 'insert', + ); } } -class _ScribeLocalizationsDelegate extends LocalizationsDelegate { +class _ScribeLocalizationsDelegate + extends LocalizationsDelegate { const _ScribeLocalizationsDelegate(); @override - bool isSupported(Locale locale) => ['en', 'fr', 'ru', 'vi'].contains(locale.languageCode); + bool isSupported(Locale locale) => + ['en', 'fr', 'ru', 'vi'].contains(locale.languageCode); @override - Future load(Locale locale) => ScribeLocalizations.load(locale); + Future load(Locale locale) => + ScribeLocalizations.load(locale); @override bool shouldReload(_ScribeLocalizationsDelegate old) => false; diff --git a/scribe/lib/scribe/ai/presentation/bindings/ai_scribe_bindings.dart b/scribe/lib/scribe/ai/presentation/bindings/ai_scribe_bindings.dart index 90c0ea2334..1c42c897c5 100644 --- a/scribe/lib/scribe/ai/presentation/bindings/ai_scribe_bindings.dart +++ b/scribe/lib/scribe/ai/presentation/bindings/ai_scribe_bindings.dart @@ -1,14 +1,17 @@ -import 'package:dio/dio.dart'; +import 'package:core/data/network/dio_client.dart'; import 'package:get/get.dart'; -import 'package:scribe/scribe/ai/data/datasource/ai_datasource.dart'; +import 'package:scribe/scribe.dart'; import 'package:scribe/scribe/ai/data/datasource_impl/ai_datasource_impl.dart'; -import 'package:scribe/scribe/ai/data/repository/ai_repository_impl.dart'; -import 'package:scribe/scribe/ai/domain/repository/ai_scribe_repository.dart'; -import 'package:scribe/scribe/ai/domain/usecases/generate_ai_text_interactor.dart'; +import 'package:scribe/scribe/ai/data/network/ai_api.dart'; class AIScribeBindings extends Bindings { + final String aiEndpoint; + + AIScribeBindings(this.aiEndpoint); + @override void dependencies() { + _bindingsAPI(); _bindingsDataSourceImpl(); _bindingsDataSource(); _bindingsRepositoryImpl(); @@ -16,29 +19,27 @@ class AIScribeBindings extends Bindings { _bindingsInteractor(); } + void _bindingsAPI() { + Get.put(AIApi(Get.find(), aiEndpoint)); + } + void _bindingsDataSourceImpl() { - Get.lazyPut(() => AIDataSourceImpl( - dio: Dio(), // Dedicated Dio instance without authorization interceptors - )); + Get.put(AIDataSourceImpl(Get.find())); } void _bindingsDataSource() { - Get.lazyPut(() => Get.find()); + Get.put(Get.find()); } void _bindingsRepositoryImpl() { - Get.lazyPut(() => AIScribeRepositoryImpl( - Get.find(), - )); + Get.put(AIScribeRepositoryImpl(Get.find())); } void _bindingsRepository() { - Get.lazyPut(() => Get.find()); + Get.put(Get.find()); } void _bindingsInteractor() { - Get.lazyPut(() => GenerateAITextInteractor( - Get.find(), - )); + Get.put(GenerateAITextInteractor(Get.find())); } } diff --git a/scribe/lib/scribe/ai/presentation/model/ai_action.dart b/scribe/lib/scribe/ai/presentation/model/ai_action.dart index c457dd59be..943bf26f8d 100644 --- a/scribe/lib/scribe/ai/presentation/model/ai_action.dart +++ b/scribe/lib/scribe/ai/presentation/model/ai_action.dart @@ -1,11 +1,9 @@ -import 'package:flutter/material.dart'; -import 'package:scribe/scribe/ai/localizations/scribe_localizations.dart'; -import 'package:scribe/scribe/ai/presentation/model/ai_scribe_menu_action.dart'; +import 'package:scribe/scribe.dart'; sealed class AIAction { const AIAction(); - String getLabel(BuildContext context); + String getLabel(ScribeLocalizations localizations); } class PredefinedAction extends AIAction { @@ -14,7 +12,8 @@ class PredefinedAction extends AIAction { const PredefinedAction(this.action); @override - String getLabel(BuildContext context) => action.getFullLabel(context); + String getLabel(ScribeLocalizations localizations) => + action.getFullLabel(localizations); } class CustomPromptAction extends AIAction { @@ -23,7 +22,7 @@ class CustomPromptAction extends AIAction { const CustomPromptAction(this.prompt); @override - String getLabel(BuildContext context) { - return ScribeLocalizations.of(context)?.customPromptAction ?? 'Help me write'; + String getLabel(ScribeLocalizations localizations) { + return localizations.customPromptAction; } } diff --git a/scribe/lib/scribe/ai/presentation/model/ai_capability.dart b/scribe/lib/scribe/ai/presentation/model/ai_capability.dart new file mode 100644 index 0000000000..334c8395bb --- /dev/null +++ b/scribe/lib/scribe/ai/presentation/model/ai_capability.dart @@ -0,0 +1,22 @@ +import 'package:jmap_dart_client/jmap/core/capability/capability_properties.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'ai_capability.g.dart'; + +@JsonSerializable(includeIfNull: false) +class AICapability extends CapabilityProperties { + AICapability({this.scribeEndpoint}); + + final String? scribeEndpoint; + + factory AICapability.fromJson(Map json) => + _$AICapabilityFromJson(json); + + Map toJson() => _$AICapabilityToJson(this); + + bool get isScribeEndpointAvailable => + scribeEndpoint?.trim().isNotEmpty == true; + + @override + List get props => [scribeEndpoint]; +} diff --git a/scribe/lib/scribe/ai/presentation/model/ai_scribe_menu_action.dart b/scribe/lib/scribe/ai/presentation/model/ai_scribe_menu_action.dart index ab16fa9f86..26481c10c3 100644 --- a/scribe/lib/scribe/ai/presentation/model/ai_scribe_menu_action.dart +++ b/scribe/lib/scribe/ai/presentation/model/ai_scribe_menu_action.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:core/presentation/resources/image_paths.dart'; import 'package:scribe/scribe/ai/localizations/scribe_localizations.dart'; enum AIScribeMenuAction { @@ -15,8 +15,7 @@ enum AIScribeMenuAction { translateRussian, translateVietnamese; - String getLabel(BuildContext context) { - final localizations = ScribeLocalizations.of(context)!; + String getLabel(ScribeLocalizations localizations) { switch (this) { case AIScribeMenuAction.correctGrammar: return localizations.categoryCorrectGrammar; @@ -66,24 +65,44 @@ enum AIScribeMenuAction { } } - String getFullLabel(BuildContext context) { - final categoryLabel = category.getLabel(context); + String getFullLabel(ScribeLocalizations localizations) { + final categoryLabel = category.getLabel(localizations); if (category.hasSubmenu) { - return '$categoryLabel > ${getLabel(context)}'; + return '$categoryLabel > ${getLabel(localizations)}'; } else { - return getLabel(context); + return getLabel(localizations); + } + } + + String? getIcon(ImagePaths imagePaths) { + switch (this) { + case AIScribeMenuAction.improveMakeShorter: + return imagePaths.icAiShorter; + case AIScribeMenuAction.improveExpandContext: + return imagePaths.icAiMoreDetail; + case AIScribeMenuAction.improveEmojify: + return imagePaths.icAiEmojify; + case AIScribeMenuAction.improveTransformToBullets: + return imagePaths.icAiBullets; + case AIScribeMenuAction.changeToneProfessional: + return imagePaths.icAiMoreProfessional; + case AIScribeMenuAction.changeToneCasual: + return imagePaths.icAiMoreCasual; + case AIScribeMenuAction.changeTonePolite: + return imagePaths.icAiMorePolite; + default: + return null; } } } enum AIScribeMenuCategory { correctGrammar, - improve, + translate, changeTone, - translate; + improve; - String getLabel(BuildContext context) { - final localizations = ScribeLocalizations.of(context)!; + String getLabel(ScribeLocalizations localizations) { switch (this) { case AIScribeMenuCategory.correctGrammar: return localizations.categoryCorrectGrammar; @@ -96,6 +115,19 @@ enum AIScribeMenuCategory { } } + String getIcon(ImagePaths imagePaths) { + switch (this) { + case AIScribeMenuCategory.correctGrammar: + return imagePaths.icAiGrammar; + case AIScribeMenuCategory.improve: + return imagePaths.icAiImprove; + case AIScribeMenuCategory.changeTone: + return imagePaths.icAiChangeTons; + case AIScribeMenuCategory.translate: + return imagePaths.icAiTranslate; + } + } + List get actions { switch (this) { case AIScribeMenuCategory.correctGrammar: diff --git a/scribe/lib/scribe/ai/presentation/model/context_menu/ai_scribe_action_context_menu_action.dart b/scribe/lib/scribe/ai/presentation/model/context_menu/ai_scribe_action_context_menu_action.dart new file mode 100644 index 0000000000..5475230707 --- /dev/null +++ b/scribe/lib/scribe/ai/presentation/model/context_menu/ai_scribe_action_context_menu_action.dart @@ -0,0 +1,26 @@ +import 'package:core/presentation/resources/image_paths.dart'; +import 'package:scribe/scribe.dart'; + +class AiScribeActionContextMenuAction + extends AiScribeContextMenuAction { + AiScribeActionContextMenuAction( + super.action, + this.localizations, + this.imagePaths, + ); + + final ScribeLocalizations localizations; + final ImagePaths imagePaths; + + @override + String get actionName => action.getLabel(localizations); + + @override + String? get actionIcon => action.getIcon(imagePaths); + + @override + bool get hasSubmenu => false; + + @override + List? get submenuActions => null; +} diff --git a/scribe/lib/scribe/ai/presentation/model/context_menu/ai_scribe_category_context_menu_action.dart b/scribe/lib/scribe/ai/presentation/model/context_menu/ai_scribe_category_context_menu_action.dart new file mode 100644 index 0000000000..93f16959b0 --- /dev/null +++ b/scribe/lib/scribe/ai/presentation/model/context_menu/ai_scribe_category_context_menu_action.dart @@ -0,0 +1,32 @@ +import 'package:core/presentation/resources/image_paths.dart'; +import 'package:scribe/scribe.dart'; + +class AiScribeCategoryContextMenuAction + extends AiScribeContextMenuAction { + AiScribeCategoryContextMenuAction( + super.action, + this.localizations, + this.imagePaths, + ); + + final ScribeLocalizations localizations; + final ImagePaths imagePaths; + + @override + String get actionName => action.getLabel(localizations); + + @override + String? get actionIcon => action.getIcon(imagePaths); + + @override + bool get hasSubmenu => action.hasSubmenu; + + @override + List? get submenuActions => action.actions + .map((action) => AiScribeActionContextMenuAction( + action, + localizations, + imagePaths, + )) + .toList(); +} diff --git a/scribe/lib/scribe/ai/presentation/model/context_menu/ai_scribe_context_menu_action.dart b/scribe/lib/scribe/ai/presentation/model/context_menu/ai_scribe_context_menu_action.dart new file mode 100644 index 0000000000..ebcfd3eb9b --- /dev/null +++ b/scribe/lib/scribe/ai/presentation/model/context_menu/ai_scribe_context_menu_action.dart @@ -0,0 +1,13 @@ +abstract class AiScribeContextMenuAction { + final T action; + + AiScribeContextMenuAction(this.action); + + String get actionName; + + String? get actionIcon; + + bool get hasSubmenu; + + List? get submenuActions; +} diff --git a/scribe/lib/scribe/ai/presentation/model/context_menu/ai_scribe_suggestion_actions.dart b/scribe/lib/scribe/ai/presentation/model/context_menu/ai_scribe_suggestion_actions.dart new file mode 100644 index 0000000000..49940c115f --- /dev/null +++ b/scribe/lib/scribe/ai/presentation/model/context_menu/ai_scribe_suggestion_actions.dart @@ -0,0 +1,15 @@ +import 'package:scribe/scribe/ai/localizations/scribe_localizations.dart'; + +enum AiScribeSuggestionActions { + replace, + insert; + + String getLabel(ScribeLocalizations localizations) { + switch (this) { + case AiScribeSuggestionActions.replace: + return localizations.replace; + case AiScribeSuggestionActions.insert: + return localizations.insert; + } + } +} diff --git a/scribe/lib/scribe/ai/presentation/model/modal/anchored_modal_layout_input.dart b/scribe/lib/scribe/ai/presentation/model/modal/anchored_modal_layout_input.dart new file mode 100644 index 0000000000..8560b06616 --- /dev/null +++ b/scribe/lib/scribe/ai/presentation/model/modal/anchored_modal_layout_input.dart @@ -0,0 +1,20 @@ +import 'package:flutter/animation.dart'; +import 'package:scribe/scribe.dart'; + +class AnchoredModalLayoutInput { + final Size screenSize; + final Offset anchorPosition; + final Size anchorSize; + final Size menuSize; + final ModalPlacement? preferredPlacement; + final ModalCrossAxisAlignment crossAxisAlignment; + + const AnchoredModalLayoutInput({ + required this.screenSize, + required this.anchorPosition, + required this.anchorSize, + required this.menuSize, + this.preferredPlacement, + this.crossAxisAlignment = ModalCrossAxisAlignment.center, + }); +} diff --git a/scribe/lib/scribe/ai/presentation/model/modal/anchored_modal_layout_result.dart b/scribe/lib/scribe/ai/presentation/model/modal/anchored_modal_layout_result.dart new file mode 100644 index 0000000000..3e0252eaf9 --- /dev/null +++ b/scribe/lib/scribe/ai/presentation/model/modal/anchored_modal_layout_result.dart @@ -0,0 +1,12 @@ +import 'package:flutter/animation.dart'; +import 'package:scribe/scribe/ai/presentation/model/modal/modal_placement.dart'; + +class AnchoredModalLayoutResult { + final Offset position; + final ModalPlacement placement; + + const AnchoredModalLayoutResult({ + required this.position, + required this.placement, + }); +} diff --git a/scribe/lib/scribe/ai/presentation/model/modal/anchored_suggestion_layout_result.dart b/scribe/lib/scribe/ai/presentation/model/modal/anchored_suggestion_layout_result.dart new file mode 100644 index 0000000000..134b02574d --- /dev/null +++ b/scribe/lib/scribe/ai/presentation/model/modal/anchored_suggestion_layout_result.dart @@ -0,0 +1,11 @@ +class AnchoredSuggestionLayoutResult { + final double availableHeight; + final double left; + final double bottom; + + const AnchoredSuggestionLayoutResult({ + required this.left, + required this.bottom, + required this.availableHeight, + }); +} diff --git a/scribe/lib/scribe/ai/presentation/model/modal/modal_cross_axis_alignment.dart b/scribe/lib/scribe/ai/presentation/model/modal/modal_cross_axis_alignment.dart new file mode 100644 index 0000000000..22de2c1161 --- /dev/null +++ b/scribe/lib/scribe/ai/presentation/model/modal/modal_cross_axis_alignment.dart @@ -0,0 +1,5 @@ +enum ModalCrossAxisAlignment { + start, + center, + end, +} diff --git a/scribe/lib/scribe/ai/presentation/model/modal/modal_placement.dart b/scribe/lib/scribe/ai/presentation/model/modal/modal_placement.dart new file mode 100644 index 0000000000..a788fd6551 --- /dev/null +++ b/scribe/lib/scribe/ai/presentation/model/modal/modal_placement.dart @@ -0,0 +1 @@ +enum ModalPlacement { right, bottom, top, left } diff --git a/scribe/lib/scribe/ai/presentation/model/text_selection_model.dart b/scribe/lib/scribe/ai/presentation/model/text_selection_model.dart new file mode 100644 index 0000000000..995f914335 --- /dev/null +++ b/scribe/lib/scribe/ai/presentation/model/text_selection_model.dart @@ -0,0 +1,13 @@ +import 'package:flutter/cupertino.dart'; + +class TextSelectionModel { + final String? selectedText; + final Offset? coordinates; + + const TextSelectionModel({ + this.selectedText, + this.coordinates, + }); + + bool get hasSelection => selectedText != null && selectedText!.isNotEmpty; +} diff --git a/scribe/lib/scribe/ai/presentation/styles/ai_scribe_styles.dart b/scribe/lib/scribe/ai/presentation/styles/ai_scribe_styles.dart index a0cb83eab2..17b5bb6b04 100644 --- a/scribe/lib/scribe/ai/presentation/styles/ai_scribe_styles.dart +++ b/scribe/lib/scribe/ai/presentation/styles/ai_scribe_styles.dart @@ -1,82 +1,184 @@ -import 'package:flutter/material.dart'; import 'package:core/presentation/extensions/color_extension.dart'; import 'package:core/presentation/utils/theme_utils.dart'; +import 'package:flutter/material.dart'; + +abstract final class AIScribeColors { + // Backgrounds + static const Color background = Colors.white; + static const Color mainActionButtonBackground = Color(0xFFD2E9FF); + static const Color sendPromptBackground = AppColor.blue700; + static const Color sendPromptBackgroundDisabled = Color(0xFFD2E9FF); + + // Icons + static const Color scribeIcon = AppColor.primaryMain; + static const Color aiAssistantIcon = AppColor.primaryMain; -class AIScribeColors { - static const textPrimary = AppColor.textPrimary; - static const background = Colors.white; + // Overlays + static final Color dialogBarrier = Colors.black.withValues(alpha: 0.12); } -class AIScribeShadows { - static List get elevation8 => [ +abstract final class AIScribeShadows { + static final List sparkleIcon = [ BoxShadow( - color: Colors.black.withValues(alpha: 0.16), + color: AppColor.gray424244.withValues(alpha: 0.08), + blurRadius: 3, + offset: const Offset(0, 1.5), + ), + BoxShadow( + color: Colors.black.withValues(alpha: 0.06), blurRadius: 12, - offset: const Offset(0, 2), + offset: const Offset(0, 3), + ), + ]; + + static final List modal = [ + BoxShadow( + color: AppColor.gray424244.withValues(alpha: 0.12), + spreadRadius: 0.5, ), BoxShadow( - color: Colors.black.withValues(alpha: 0.08), - blurRadius: 4, - offset: const Offset(0, 1), + color: AppColor.gray424244.withValues(alpha: 0.11), + spreadRadius: 2, + blurRadius: 26, + offset: const Offset(0, 6), ), ]; } -class AIScribeTextStyles { - static TextStyle menuItem = ThemeUtils.textStyleBodyBody3(color: AIScribeColors.textPrimary); - static TextStyle menuHint = ThemeUtils.textStyleBodyBody3(color: AIScribeColors.textPrimary.withValues(alpha: 0.6)); - static TextStyle suggestionTitle = ThemeUtils.textStyleInter700(); - static TextStyle suggestionContent = ThemeUtils.textStyleBodyBody3(color: AIScribeColors.textPrimary); -} +abstract final class AIScribeTextStyles { + static final TextStyle menuItem = ThemeUtils.textStyleInter400.copyWith( + fontSize: 14, + height: 21.01 / 14, + letterSpacing: -0.15, + color: AppColor.gray424244.withValues(alpha: 0.9), + ); + + static final TextStyle searchBarHint = + ThemeUtils.textStyleInter500().copyWith( + fontSize: 14, + height: 22 / 14, + letterSpacing: 0.4, + color: AppColor.gray9B9B9B.withValues(alpha: 0.85), + ); -class AIScribeButtonStyles { - static TextStyle mainActionButtonText = ThemeUtils.textStyleInter500().copyWith(color: AppColor.blue700); - static const Color mainActionButtonBackgroundColor = Color(0xFFD2E9FF); - static const EdgeInsetsGeometry mainActionButtonPadding = EdgeInsetsDirectional.symmetric(vertical: 8, horizontal: 16); + static final TextStyle searchBar = ThemeUtils.textStyleInter400.copyWith( + fontSize: 14, + height: 24 / 14, + letterSpacing: 0.4, + color: Colors.black.withValues(alpha: 0.85), + ); - static const Color sendCustomPromptBackgroundColor = AppColor.blue700; - static const Color sendCustomPromptBackgroundColorDisabled = Color(0xFFD2E9FF); + static final TextStyle suggestionTitle = + ThemeUtils.textStyleInter700().copyWith( + fontSize: 14, + height: 22 / 14, + letterSpacing: 0.4, + color: AppColor.black1A1A1A.withValues(alpha: 0.85), + ); + + static final TextStyle suggestionLoading = + ThemeUtils.textStyleInter400.copyWith( + fontSize: 14, + height: 22 / 14, + letterSpacing: 0.4, + color: AppColor.black1A1A1A.withValues(alpha: 0.85), + ); + + static final TextStyle suggestionContent = + ThemeUtils.textStyleInter400.copyWith( + fontSize: 14, + height: 22 / 14, + letterSpacing: 0.4, + color: Colors.black.withValues(alpha: 0.85), + ); + + static final TextStyle mainActionButton = + ThemeUtils.textStyleInter500().copyWith( + color: AppColor.blue700, + ); } -class AIScribeSizes { +abstract final class AIScribeSizes { // Border radius - static const double menuBorderRadius = 12.0; - static const double menuItemBorderRadius = 6.0; - static const double scribeButtonBorderRadius = 100.0; - - // Dialog dimensions - static const double menuWidth = 200.0; - static const double barWidth = 440.0; - static const double modalMaxHeight = 400.0; - static const double modalMaxWidthLargeScreen = 500.0; - static const double mobileWidthPercentage = 0.9; - static const double mobileBreakpoint = 600.0; - static const double infoHeight = 120.0; - - // Heights - static const double menuItemHeight = 40.0; - static const double barHeight = 48.0; - static const double submenuMaxHeight = 300.0; + static const double menuRadius = 6; + static const double menuItemRadius = 6; + static const double searchBarRadius = 10; + static const double scribeButtonRadius = 100; + static const double aiAssistantIconRadius = 8; + + // Width / height + static const double menuItemHeight = 40; + static const double searchBarMinHeight = 48; + static const double searchBarMaxHeight = 100; + static const double searchBarWidth = 405; + + static const double submenuWidth = 191; + static const double submenuMaxHeight = 352; + static const double contextMenuWidth = 191; + static const double contextMenuHeight = 352; + + static const double modalMaxHeight = 256; + static const double modalMaxWidth = 405; + static const double suggestionModalMaxWidth = 482; + static const double suggestionModalMinHeight = 96; + static const double suggestionModalMaxHeight = 587; + + static const double infoHeight = 120; + + // Breakpoints + static const double mobileBreakpoint = 600; + static const double mobileFactor = 0.9; // Spacing - static const double screenEdgePadding = 16.0; - static const double submenuSpacing = 6.0; - static const double fieldSpacing = 8.0; + static const double screenEdgePadding = 16; + static const double fieldSpacing = 8; + static const double submenuSpacing = 6; + static const double modalSpacing = 26; + static const double modalWithoutContentSpacing = 12; // Elevation - static const double dialogElevation = 8.0; + static const double dialogElevation = 8; // Icon sizes - static const double iconSize = 18.0; - static const double sendIconSize = 16.0; - static const double scribeIconSize = 12.0; - - // Padding (using EdgeInsets) - static const EdgeInsetsGeometry menuItemPadding = EdgeInsetsDirectional.symmetric(horizontal: 16, vertical: 10); - static const EdgeInsetsGeometry barPadding = EdgeInsetsDirectional.symmetric(horizontal: 16, vertical: 8); - static const EdgeInsetsGeometry suggestionContentPadding = EdgeInsetsDirectional.symmetric(horizontal: 16, vertical: 8); - static const EdgeInsetsGeometry suggestionInfoPadding = EdgeInsets.only(bottom: 16); - static const EdgeInsetsGeometry suggestionHeaderPadding = EdgeInsets.fromLTRB(16, 8, 8, 8); - static const EdgeInsetsGeometry suggestionFooterPadding = EdgeInsets.symmetric(horizontal: 16, vertical: 16); - static const EdgeInsetsGeometry scribeButtonPadding = EdgeInsets.all(6); + static const double icon = 18; + static const double sendIcon = 16; + static const double scribeIcon = 12; + static const double aiAssistantIcon = 24; + + // Padding + static const EdgeInsetsGeometry menuItemPadding = + EdgeInsetsDirectional.only(start: 16, end: 10); + + static const EdgeInsetsGeometry menuCategoryItemPadding = + EdgeInsetsDirectional.symmetric(horizontal: 14); + + static const EdgeInsetsGeometry searchBarPadding = + EdgeInsetsDirectional.symmetric(horizontal: 16); + + static const EdgeInsetsGeometry suggestionContentPadding = + EdgeInsetsDirectional.all(16); + + static const EdgeInsetsGeometry suggestionInfoPadding = + EdgeInsetsDirectional.only(bottom: 16); + + static const EdgeInsetsGeometry suggestionHeaderPadding = + EdgeInsetsDirectional.only(start: 16, top: 8, end: 8, bottom: 8); + + static const EdgeInsetsGeometry suggestionFooterPadding = + EdgeInsetsDirectional.symmetric(horizontal: 16, vertical: 16); + + static const EdgeInsetsGeometry scribeButtonPadding = + EdgeInsetsDirectional.all(6); + + static const EdgeInsetsGeometry mainActionButtonPadding = + EdgeInsetsDirectional.symmetric(horizontal: 16, vertical: 8); + + static const EdgeInsetsGeometry contextMenuPadding = + EdgeInsetsDirectional.symmetric(vertical: 8); + + static const EdgeInsetsGeometry aiAssistantIconPadding = + EdgeInsetsDirectional.all(5); + + static const EdgeInsetsGeometry sendIconPadding = + EdgeInsetsDirectional.all(8); } diff --git a/scribe/lib/scribe/ai/presentation/utils/ai_scribe_constants.dart b/scribe/lib/scribe/ai/presentation/utils/ai_scribe_constants.dart new file mode 100644 index 0000000000..fe72ccd330 --- /dev/null +++ b/scribe/lib/scribe/ai/presentation/utils/ai_scribe_constants.dart @@ -0,0 +1,8 @@ +import 'package:jmap_dart_client/jmap/core/capability/capability_identifier.dart'; + +class AiScribeConstants { + AiScribeConstants._(); + + static final CapabilityIdentifier aiCapability = + CapabilityIdentifier(Uri.parse('com:linagora:params:jmap:aibot')); +} diff --git a/scribe/lib/scribe/ai/presentation/utils/context_menu/hover_submenu_controller.dart b/scribe/lib/scribe/ai/presentation/utils/context_menu/hover_submenu_controller.dart new file mode 100644 index 0000000000..43b04f7a3c --- /dev/null +++ b/scribe/lib/scribe/ai/presentation/utils/context_menu/hover_submenu_controller.dart @@ -0,0 +1,44 @@ +import 'dart:async'; + +import 'package:flutter/cupertino.dart'; + +typedef OnHoverShowSubmenu = void Function(GlobalKey key); + +class HoverSubmenuController { + HoverSubmenuController({ + this.exitDelay = const Duration(milliseconds: 120), + }); + + final Duration exitDelay; + + final ValueNotifier isHovering = ValueNotifier(false); + + int _hoverRefCount = 0; + Timer? _exitTimer; + + void enter() { + _exitTimer?.cancel(); + _hoverRefCount++; + if (!isHovering.value) { + isHovering.value = true; + } + } + + void exit() { + _hoverRefCount = (_hoverRefCount - 1).clamp(0, 999); + + if (_hoverRefCount == 0) { + _exitTimer?.cancel(); + _exitTimer = Timer(exitDelay, () { + if (_hoverRefCount == 0) { + isHovering.value = false; + } + }); + } + } + + void dispose() { + _exitTimer?.cancel(); + isHovering.dispose(); + } +} diff --git a/scribe/lib/scribe/ai/presentation/utils/context_menu/popup_submenu_controller.dart b/scribe/lib/scribe/ai/presentation/utils/context_menu/popup_submenu_controller.dart new file mode 100644 index 0000000000..78d94b9a40 --- /dev/null +++ b/scribe/lib/scribe/ai/presentation/utils/context_menu/popup_submenu_controller.dart @@ -0,0 +1,90 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +enum SubmenuDirection { left, right, auto } + +class PopupSubmenuController { + OverlayEntry? _submenuEntry; + + bool get isShowing => _submenuEntry != null; + + void show({ + required BuildContext context, + required Rect anchor, + required Rect anchorMenu, + required Widget submenu, + SubmenuDirection direction = SubmenuDirection.auto, + double submenuWidth = 191, + double submenuMaxHeight = 352, + double offset = 4, + double menuFieldSpacing = 8, + }) { + hide(); + + final overlayState = Overlay.maybeOf(context, rootOverlay: true); + if (overlayState == null) return; + + final mediaSize = MediaQuery.sizeOf(context); + final screenWidth = mediaSize.width; + final screenHeight = mediaSize.height; + + final rightPosition = anchor.right + offset; + final leftPosition = anchor.left - submenuWidth - offset; + + bool canShowRight = rightPosition + submenuWidth <= screenWidth; + bool canShowLeft = leftPosition >= 0; + + double? finalLeft; + + if (direction == SubmenuDirection.right) { + finalLeft = rightPosition; + } else if (direction == SubmenuDirection.left) { + finalLeft = leftPosition; + } else { + if (canShowRight) { + finalLeft = rightPosition; + } else if (canShowLeft) { + finalLeft = leftPosition; + } else { + finalLeft = rightPosition; + } + } + + final bottom = screenHeight - anchorMenu.bottom + menuFieldSpacing; + + final clampedLeft = finalLeft + .clamp(0.0, math.max(0.0, screenWidth - submenuWidth)) + .toDouble(); + final availableHeight = math.max(0.0, screenHeight - anchor.top); + final finalHeight = math.min(submenuMaxHeight, availableHeight); + + _submenuEntry = OverlayEntry( + builder: (_) { + return PositionedDirectional( + start: clampedLeft, + bottom: bottom, + child: MouseRegion( + onExit: (_) => hide(), + child: Container( + width: submenuWidth, + constraints: BoxConstraints(maxHeight: finalHeight), + child: submenu, + ), + ), + ); + }, + ); + + overlayState.insert(_submenuEntry!); + } + + void hide() { + _submenuEntry?.remove(); + _submenuEntry = null; + } + + void dispose() { + hide(); + } +} diff --git a/scribe/lib/scribe/ai/presentation/utils/modal/ai_scribe_modal_manager.dart b/scribe/lib/scribe/ai/presentation/utils/modal/ai_scribe_modal_manager.dart new file mode 100644 index 0000000000..1db93eabdb --- /dev/null +++ b/scribe/lib/scribe/ai/presentation/utils/modal/ai_scribe_modal_manager.dart @@ -0,0 +1,73 @@ +import 'package:core/presentation/resources/image_paths.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:scribe/scribe.dart'; + +class AiScribeModalManager { + AiScribeModalManager._(); + + static Future showAIScribeMenuModal({ + required ImagePaths imagePaths, + required List availableCategories, + required OnSelectAiScribeSuggestionAction onSelectAiScribeSuggestionAction, + String? content, + Offset? buttonPosition, + Size? buttonSize, + ModalPlacement? preferredPlacement, + ModalCrossAxisAlignment crossAxisAlignment = ModalCrossAxisAlignment.center, + }) async { + final PopupSubmenuController submenuController = PopupSubmenuController(); + + final aiAction = await Get.dialog( + AiScribeModalWidget( + imagePaths: imagePaths, + content: content, + availableCategories: availableCategories, + buttonPosition: buttonPosition, + buttonSize: buttonSize, + preferredPlacement: preferredPlacement, + crossAxisAlignment: crossAxisAlignment, + submenuController: submenuController, + ), + barrierColor: AIScribeColors.dialogBarrier, + ).whenComplete(submenuController.dispose); + + if (aiAction != null) { + await showAIScribeSuggestionModal( + aiAction: aiAction, + imagePaths: imagePaths, + content: content, + buttonPosition: buttonPosition, + buttonSize: buttonSize, + preferredPlacement: preferredPlacement, + crossAxisAlignment: crossAxisAlignment, + onSelectAiScribeSuggestionAction: onSelectAiScribeSuggestionAction, + ); + } + } + + static Future showAIScribeSuggestionModal({ + required AIAction aiAction, + required ImagePaths imagePaths, + required OnSelectAiScribeSuggestionAction onSelectAiScribeSuggestionAction, + String? content, + Offset? buttonPosition, + Size? buttonSize, + ModalPlacement? preferredPlacement, + ModalCrossAxisAlignment crossAxisAlignment = ModalCrossAxisAlignment.center, + }) async { + await Get.dialog( + AiScribeSuggestionWidget( + aiAction: aiAction, + imagePaths: imagePaths, + content: content, + buttonPosition: buttonPosition, + buttonSize: buttonSize, + preferredPlacement: preferredPlacement, + crossAxisAlignment: crossAxisAlignment, + onSelectAiScribeSuggestionAction: onSelectAiScribeSuggestionAction, + ), + barrierColor: AIScribeColors.dialogBarrier, + ); + } +} diff --git a/scribe/lib/scribe/ai/presentation/utils/modal/anchored_modal_layout_calculator.dart b/scribe/lib/scribe/ai/presentation/utils/modal/anchored_modal_layout_calculator.dart new file mode 100644 index 0000000000..a968977ec4 --- /dev/null +++ b/scribe/lib/scribe/ai/presentation/utils/modal/anchored_modal_layout_calculator.dart @@ -0,0 +1,234 @@ +import 'package:flutter/animation.dart'; +import 'package:scribe/scribe.dart'; + +class AnchoredModalLayoutCalculator { + static AnchoredModalLayoutResult calculate({ + required AnchoredModalLayoutInput input, + double padding = 8, + double gap = 8, + double verticalOffset = 6, + }) { + final screen = input.screenSize; + final anchor = input.anchorPosition; + final size = input.anchorSize; + final menu = input.menuSize; + + final fallbackOrder = [ + ModalPlacement.right, + ModalPlacement.bottom, + ModalPlacement.top, + ModalPlacement.left, + ]; + + late ModalPlacement placement; + + if (input.preferredPlacement != null && + _canPlace( + placement: input.preferredPlacement!, + screen: screen, + anchor: anchor, + anchorSize: size, + menu: menu, + gap: gap, + )) { + placement = input.preferredPlacement!; + } else { + placement = fallbackOrder.firstWhere( + (p) => _canPlace( + placement: p, + screen: screen, + anchor: anchor, + anchorSize: size, + menu: menu, + gap: gap, + ), + orElse: () => ModalPlacement.left, + ); + } + + late double left; + late double top; + + switch (placement) { + case ModalPlacement.right: + left = anchor.dx + size.width + gap; + top = _resolveAlignedPosition( + alignment: input.crossAxisAlignment, + anchorStart: anchor.dy, + anchorSize: size.height, + menuSize: menu.height, + ) + + verticalOffset; + break; + + case ModalPlacement.bottom: + left = _resolveAlignedPosition( + alignment: input.crossAxisAlignment, + anchorStart: anchor.dx, + anchorSize: size.width, + menuSize: menu.width, + ); + top = anchor.dy + size.height + gap + verticalOffset; + break; + + case ModalPlacement.top: + left = _resolveAlignedPosition( + alignment: input.crossAxisAlignment, + anchorStart: anchor.dx, + anchorSize: size.width, + menuSize: menu.width, + ); + top = anchor.dy - menu.height - gap + verticalOffset; + break; + case ModalPlacement.left: + left = anchor.dx - menu.width - gap; + top = _resolveAlignedPosition( + alignment: input.crossAxisAlignment, + anchorStart: anchor.dy, + anchorSize: size.height, + menuSize: menu.height, + ) + + verticalOffset; + break; + } + + left = left.clamp(padding, screen.width - menu.width - padding); + top = top.clamp(padding, screen.height - menu.height - padding); + + return AnchoredModalLayoutResult( + position: Offset(left, top), + placement: placement, + ); + } + + static bool _canPlace({ + required ModalPlacement placement, + required Size screen, + required Offset anchor, + required Size anchorSize, + required Size menu, + required double gap, + }) { + switch (placement) { + case ModalPlacement.right: + return screen.width - (anchor.dx + anchorSize.width) >= + menu.width + gap; + case ModalPlacement.left: + return anchor.dx >= menu.width + gap; + case ModalPlacement.bottom: + return screen.height - (anchor.dy + anchorSize.height) >= + menu.height + gap; + case ModalPlacement.top: + return anchor.dy >= menu.height + gap; + } + } + + static double _resolveAlignedPosition({ + required ModalCrossAxisAlignment alignment, + required double anchorStart, + required double anchorSize, + required double menuSize, + }) { + switch (alignment) { + case ModalCrossAxisAlignment.start: + return anchorStart; + case ModalCrossAxisAlignment.center: + return anchorStart + anchorSize / 2 - menuSize / 2; + case ModalCrossAxisAlignment.end: + return anchorStart + anchorSize - menuSize; + } + } + + static AnchoredSuggestionLayoutResult calculateAnchoredSuggestLayout({ + required Size screenSize, + required Offset anchorPosition, + required Size anchorSize, + required Size menuSize, + ModalPlacement? preferredPlacement, + double gap = 8, + double verticalOffset = 6, + double padding = 8, + }) { + final isTop = preferredPlacement == ModalPlacement.top; + + if (isTop) { + final availableHeight = anchorPosition.dy - padding - gap; + final positionBottom = screenSize.height - anchorPosition.dy + gap; + + return AnchoredSuggestionLayoutResult( + availableHeight: availableHeight, + left: anchorPosition.dx, + bottom: positionBottom, + ); + } + + late ModalPlacement placement; + + if (preferredPlacement != null && + _canPlace( + placement: preferredPlacement, + screen: screenSize, + anchor: anchorPosition, + anchorSize: anchorSize, + menu: menuSize, + gap: gap, + )) { + placement = preferredPlacement; + } else { + final fallbackOrder = [ + ModalPlacement.right, + ModalPlacement.bottom, + ModalPlacement.top, + ModalPlacement.left, + ]; + + placement = fallbackOrder.firstWhere( + (p) => _canPlace( + placement: p, + screen: screenSize, + anchor: anchorPosition, + anchorSize: anchorSize, + menu: menuSize, + gap: gap, + ), + orElse: () => ModalPlacement.right, + ); + } + + late double left; + late double bottom; + + switch (placement) { + case ModalPlacement.right: + left = anchorPosition.dx + anchorSize.width + gap; + bottom = anchorPosition.dy + verticalOffset; + break; + + case ModalPlacement.bottom: + left = anchorPosition.dx; + bottom = anchorPosition.dy + anchorSize.height + gap + verticalOffset; + break; + + case ModalPlacement.top: + left = anchorPosition.dx; + bottom = anchorPosition.dy - menuSize.height - gap + verticalOffset; + break; + case ModalPlacement.left: + left = anchorPosition.dx - menuSize.width - gap; + bottom = anchorPosition.dy + verticalOffset; + break; + } + + left = left.clamp(padding, screenSize.width - menuSize.width - padding); + bottom = bottom.clamp( + padding, + screenSize.height - menuSize.height - padding, + ); + + return AnchoredSuggestionLayoutResult( + availableHeight: menuSize.height, + left: left, + bottom: bottom, + ); + } +} diff --git a/scribe/lib/scribe/ai/presentation/widgets/ai_scribe.dart b/scribe/lib/scribe/ai/presentation/widgets/ai_scribe.dart deleted file mode 100644 index 48effa9471..0000000000 --- a/scribe/lib/scribe/ai/presentation/widgets/ai_scribe.dart +++ /dev/null @@ -1,228 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:pointer_interceptor/pointer_interceptor.dart'; -import 'package:core/presentation/resources/image_paths.dart'; -import 'package:core/utils/app_logger.dart'; -import 'package:scribe/scribe/ai/domain/state/generate_ai_text_state.dart'; -import 'package:scribe/scribe/ai/domain/usecases/generate_ai_text_interactor.dart'; -import 'package:scribe/scribe/ai/presentation/model/ai_action.dart'; -import 'package:scribe/scribe/ai/presentation/model/ai_scribe_menu_action.dart'; -import 'package:scribe/scribe/ai/presentation/styles/ai_scribe_styles.dart'; -import 'package:scribe/scribe/ai/presentation/widgets/ai_scribe_bar.dart'; -import 'package:scribe/scribe/ai/presentation/widgets/ai_scribe_menu.dart'; -import 'package:scribe/scribe/ai/presentation/widgets/ai_scribe_suggestion.dart'; - -typedef AIScribeResultCallback = void Function(String result); - -Future showAIScribeDialog({ - required BuildContext context, - required ImagePaths imagePaths, - required String content, - required AIScribeResultCallback onInsertText, - required GenerateAITextInteractor interactor, - List? availableCategories, - Offset? buttonPosition, -}) async { - final hasContent = content.isNotEmpty; - - final selectedAction = await showDialog( - context: context, - barrierDismissible: true, - builder: (context) { - final dialogContent = Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (hasContent) ...[ - Material( - color: Colors.white, - elevation: AIScribeSizes.dialogElevation, - borderRadius: BorderRadius.circular(AIScribeSizes.menuBorderRadius), - child: SizedBox( - width: AIScribeSizes.menuWidth, - child: AIScribeMenu( - onActionSelected: (action) { - Navigator.of(context).pop(PredefinedAction(action)); - }, - availableCategories: availableCategories, - ), - ), - ), - const SizedBox(height: AIScribeSizes.fieldSpacing), - ], - Material( - color: Colors.white, - elevation: AIScribeSizes.dialogElevation, - borderRadius: BorderRadius.circular(AIScribeSizes.menuBorderRadius), - child: SizedBox( - width: AIScribeSizes.barWidth, - child: AIScribeBar( - onCustomPrompt: (customPrompt) { - Navigator.of(context).pop(CustomPromptAction(customPrompt)); - }, - imagePaths: imagePaths - ), - ), - ), - ], - ); - - if (buttonPosition != null) { - final categories = availableCategories ?? AIScribeMenuCategory.values; - final modalHeight = hasContent - ? (categories.length * AIScribeSizes.menuItemHeight) + AIScribeSizes.fieldSpacing + AIScribeSizes.barHeight - : AIScribeSizes.barHeight; - - final position = calculateModalPosition( - context: context, - buttonPosition: buttonPosition, - modalWidth: AIScribeSizes.barWidth, - modalHeight: modalHeight, - ); - - return PointerInterceptor( - child: GestureDetector( - onTap: () { - Navigator.of(context).pop(); - }, - behavior: HitTestBehavior.opaque, - child: Stack( - children: [ - Positioned( - left: position.left, - bottom: position.bottom, - child: dialogContent, - ), - ], - ), - ), - ); - } - - return Center(child: dialogContent); - }, - ); - - if (selectedAction == null) { - log('showAIScribeDialog: No action selected'); - return; - } - - if (!context.mounted) return; - - await showDialog( - context: context, - barrierDismissible: true, - builder: (context) { - final suggestionFuture = _executeAIRequest(interactor, selectedAction, content); - final title = selectedAction.getLabel(context); - - final modalContent = AIScribeSuggestion( - title: title, - suggestionFuture: suggestionFuture, - onClose: () => Navigator.of(context).pop(), - onInsert: (result) { - onInsertText(result); - Navigator.of(context).pop(); - }, - imagePaths: imagePaths, - ); - - if (buttonPosition != null) { - final screenSize = MediaQuery.of(context).size; - final modalWidth = screenSize.width < AIScribeSizes.mobileBreakpoint - ? screenSize.width * AIScribeSizes.mobileWidthPercentage - : AIScribeSizes.modalMaxWidthLargeScreen; - - final position = calculateModalPosition( - context: context, - buttonPosition: buttonPosition, - modalWidth: modalWidth, - modalHeight: AIScribeSizes.modalMaxHeight, - ); - - return PointerInterceptor( - child: GestureDetector( - onTap: () { - Navigator.of(context).pop(); - }, - behavior: HitTestBehavior.opaque, - child: Stack( - children: [ - Positioned( - left: position.left, - bottom: position.bottom, - child: modalContent, - ), - ], - ), - ), - ); - } - - return Center(child: modalContent); - }, - ); -} - -double calculateMenuDialogHeight({ - required bool hasContent, - required int categoryCount, -}) { - return hasContent - ? (categoryCount * AIScribeSizes.menuItemHeight) + AIScribeSizes.fieldSpacing + AIScribeSizes.barHeight - : AIScribeSizes.barHeight; -} - -({double left, double bottom}) calculateModalPosition({ - required BuildContext context, - required Offset buttonPosition, - required double modalWidth, - double? modalHeight, -}) { - final screenSize = MediaQuery.of(context).size; - - // Calculate position above the button - double left = buttonPosition.dx; - double bottom = screenSize.height - buttonPosition.dy + AIScribeSizes.fieldSpacing; - - // Ensure modal doesn't go off screen horizontally - if (left + modalWidth > screenSize.width) { - left = screenSize.width - modalWidth - AIScribeSizes.screenEdgePadding; - } - if (left < AIScribeSizes.screenEdgePadding) { - left = AIScribeSizes.screenEdgePadding; - } - - // Ensure modal doesn't go off screen vertically (if height is provided) - if (modalHeight != null) { - if (bottom + modalHeight > screenSize.height) { - bottom = screenSize.height - modalHeight - AIScribeSizes.screenEdgePadding; - } - if (bottom < AIScribeSizes.screenEdgePadding) { - bottom = AIScribeSizes.screenEdgePadding; - } - } - - return (left: left, bottom: bottom); -} - -Future _executeAIRequest( - GenerateAITextInteractor interactor, - AIAction action, - String content, -) async { - final result = await interactor.execute(action, content); - - return result.fold( - (failure) { - throw Exception('Failed to generate AI response: $failure'); - }, - (success) { - if (success is GenerateAITextSuccess) { - return success.response.result; - } else { - throw Exception('Unexpected success state: $success'); - } - }, - ); -} \ No newline at end of file diff --git a/scribe/lib/scribe/ai/presentation/widgets/ai_scribe_bar.dart b/scribe/lib/scribe/ai/presentation/widgets/ai_scribe_bar.dart deleted file mode 100644 index 4972fe6814..0000000000 --- a/scribe/lib/scribe/ai/presentation/widgets/ai_scribe_bar.dart +++ /dev/null @@ -1,93 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:core/presentation/resources/image_paths.dart'; -import 'package:core/presentation/views/button/tmail_button_widget.dart'; -import 'package:scribe/scribe/ai/localizations/scribe_localizations.dart'; -import 'package:scribe/scribe/ai/presentation/styles/ai_scribe_styles.dart'; - -typedef OnCustomPromptCallback = void Function(String customPrompt); - -class AIScribeBar extends StatefulWidget { - final OnCustomPromptCallback onCustomPrompt; - final ImagePaths imagePaths; - - const AIScribeBar({ - super.key, - required this.onCustomPrompt, - required this.imagePaths, - }); - - @override - State createState() => _AIScribeBarState(); -} - -class _AIScribeBarState extends State { - final TextEditingController _controller = TextEditingController(); - final ValueNotifier _isButtonEnabled = ValueNotifier(false); - - @override - void initState() { - super.initState(); - _controller.addListener(_onTextChanged); - } - - @override - void dispose() { - _controller.removeListener(_onTextChanged); - _controller.dispose(); - _isButtonEnabled.dispose(); - super.dispose(); - } - - void _onTextChanged() { - _isButtonEnabled.value = _controller.text.trim().isNotEmpty; - } - - void _onSendPressed() { - final prompt = _controller.text.trim(); - if (prompt.isNotEmpty) { - widget.onCustomPrompt(prompt); - _controller.clear(); - } - } - - @override - Widget build(BuildContext context) { - return Container( - height: AIScribeSizes.barHeight, - padding: AIScribeSizes.barPadding, - child: Row( - children: [ - Expanded( - child: TextField( - controller: _controller, - decoration: InputDecoration( - hintText: ScribeLocalizations.of(context)!.inputPlaceholder, - hintStyle: AIScribeTextStyles.menuHint, - border: InputBorder.none, - contentPadding: EdgeInsets.zero, - isDense: true, - ), - style: AIScribeTextStyles.menuItem, - onSubmitted: (_) => _onSendPressed(), - ), - ), - const SizedBox(width: AIScribeSizes.fieldSpacing), - ValueListenableBuilder( - valueListenable: _isButtonEnabled, - builder: (context, isEnabled, child) { - return TMailButtonWidget.fromIcon( - icon: widget.imagePaths.icSend, - iconSize: AIScribeSizes.sendIconSize, - iconColor: Colors.white, - backgroundColor: isEnabled - ? AIScribeButtonStyles.sendCustomPromptBackgroundColor - : AIScribeButtonStyles.sendCustomPromptBackgroundColorDisabled, - onTapActionCallback: isEnabled ? _onSendPressed : null, - ); - }, - ) - ], - ), - ); - } -} diff --git a/scribe/lib/scribe/ai/presentation/widgets/ai_scribe_button.dart b/scribe/lib/scribe/ai/presentation/widgets/ai_scribe_button.dart deleted file mode 100644 index 9d9bfb8daf..0000000000 --- a/scribe/lib/scribe/ai/presentation/widgets/ai_scribe_button.dart +++ /dev/null @@ -1,28 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:core/presentation/resources/image_paths.dart'; -import 'package:core/presentation/views/button/tmail_button_widget.dart'; -import 'package:scribe/scribe/ai/presentation/styles/ai_scribe_styles.dart'; - -class AIScribeButton extends StatelessWidget { - final ImagePaths imagePaths; - final VoidCallback onTap; - - const AIScribeButton({ - super.key, - required this.imagePaths, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - return TMailButtonWidget.fromIcon( - icon: imagePaths.icSparkle, - padding: AIScribeSizes.scribeButtonPadding, - backgroundColor: Colors.white, - iconSize: AIScribeSizes.scribeIconSize, - borderRadius: AIScribeSizes.scribeButtonBorderRadius, - onTapActionCallback: onTap, - boxShadow: AIScribeShadows.elevation8, - ); - } -} diff --git a/scribe/lib/scribe/ai/presentation/widgets/ai_scribe_menu.dart b/scribe/lib/scribe/ai/presentation/widgets/ai_scribe_menu.dart deleted file mode 100644 index bc098f7ddf..0000000000 --- a/scribe/lib/scribe/ai/presentation/widgets/ai_scribe_menu.dart +++ /dev/null @@ -1,269 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:pointer_interceptor/pointer_interceptor.dart'; -import 'package:scribe/scribe/ai/presentation/model/ai_scribe_menu_action.dart'; -import 'package:scribe/scribe/ai/presentation/styles/ai_scribe_styles.dart'; - -class AIScribeMenu extends StatefulWidget { - final Function(AIScribeMenuAction) onActionSelected; - final bool useSubmenuItemStyle; - final List? availableCategories; - - const AIScribeMenu({ - super.key, - required this.onActionSelected, - this.useSubmenuItemStyle = true, - this.availableCategories, - }); - - @override - State createState() => _AIScribeMenuContentState(); -} - -class _AIScribeMenuContentState extends State { - AIScribeMenuCategory? _hoveredCategory; - final GlobalKey _menuKey = GlobalKey(); - OverlayEntry? _submenuOverlay; - Timer? _closeTimer; - - @override - void dispose() { - _closeTimer?.cancel(); - _removeSubmenu(); - super.dispose(); - } - - void _removeSubmenu() { - _submenuOverlay?.remove(); - _submenuOverlay = null; - } - - void _showSubmenu(AIScribeMenuCategory category) { - if (!category.hasSubmenu) return; - - _removeSubmenu(); - - // Get the entire menu's bounds - final menuRenderBox = _menuKey.currentContext?.findRenderObject() as RenderBox?; - if(menuRenderBox == null) return; - - final menuPosition = menuRenderBox.localToGlobal(Offset.zero); - final menuSize = menuRenderBox.size; - - _submenuOverlay = OverlayEntry( - builder: (context) => _SubmenuPanel( - category: category, - position: menuPosition, - parentSize: menuSize, - onActionSelected: (action) { - _closeTimer?.cancel(); - _removeSubmenu(); - widget.onActionSelected(action); - }, - onHover: _cancelClose, - onDismiss: () { - setState(() { - _hoveredCategory = null; - }); - _removeSubmenu(); - }, - ), - ); - - Overlay.of(context).insert(_submenuOverlay!); - } - - void _handleCategoryHover(AIScribeMenuCategory category, bool isHovering) { - if (isHovering) { - _closeTimer?.cancel(); - _closeTimer = null; - - setState(() { - _hoveredCategory = category; - _showSubmenu(category); - }); - } else { - // Delay closing to allow mouse to reach submenu - _closeTimer?.cancel(); - _closeTimer = Timer(const Duration(milliseconds: 150), () { - if (mounted && _hoveredCategory == category) { - setState(() { - _hoveredCategory = null; - _removeSubmenu(); - }); - } - }); - } - } - - void _cancelClose() { - _closeTimer?.cancel(); - _closeTimer = null; - } - - @override - Widget build(BuildContext context) { - final categories = widget.availableCategories ?? AIScribeMenuCategory.values; - return Column( - key: _menuKey, - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: categories.map(_buildCategoryItem).toList(), - ); - } - - Widget _buildCategoryItem(AIScribeMenuCategory category) { - if (category.hasSubmenu) { - return Container( - child: _buildMenuItem( - label: category.getLabel(context), - hasSubmenu: true, - isHovered: _hoveredCategory == category, - onHover: (isHovering) => _handleCategoryHover(category, isHovering), - ), - ); - } else { - // For categories without submenu (like Correct Grammar) - return _buildMenuItem( - label: category.getLabel(context), - onTap: () { - if (category.actions.isNotEmpty) { - widget.onActionSelected(category.actions.first); - } - }, - ); - } - } - - Widget _buildMenuItem({ - required String label, - VoidCallback? onTap, - void Function(bool)? onHover, - bool hasSubmenu = false, - bool isHovered = false - }) { - return SizedBox( - height: AIScribeSizes.menuItemHeight, - child: MouseRegion( - onEnter: onHover != null ? (_) => onHover(true) : null, - onExit: onHover != null ? (_) => onHover(false) : null, - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(AIScribeSizes.menuItemBorderRadius), - child: Padding( - padding: AIScribeSizes.menuItemPadding, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Text( - label, - style: AIScribeTextStyles.menuItem, - ), - ), - if (hasSubmenu) - const Icon( - Icons.chevron_right, - size: AIScribeSizes.iconSize, - color: AIScribeColors.textPrimary, - ), - ], - ), - ), - ), - ), - ); - } -} - -/// Submenu panel that appears to the right of the main menu -class _SubmenuPanel extends StatefulWidget { - final AIScribeMenuCategory category; - final Offset position; - final Size parentSize; - final Function(AIScribeMenuAction) onActionSelected; - final VoidCallback onHover; - final VoidCallback onDismiss; - - const _SubmenuPanel({ - required this.category, - required this.position, - required this.parentSize, - required this.onActionSelected, - required this.onHover, - required this.onDismiss, - }); - - @override - State<_SubmenuPanel> createState() => _SubmenuPanelState(); -} - -class _SubmenuPanelState extends State<_SubmenuPanel> { - @override - Widget build(BuildContext context) { - final submenuHeight = widget.category.actions.length * AIScribeSizes.menuItemHeight; - - // Position submenu to the right of the parent item - final left = widget.position.dx + widget.parentSize.width + AIScribeSizes.submenuSpacing; - final top = widget.position.dy + widget.parentSize.height - submenuHeight; - - final screenSize = MediaQuery.of(context).size; - // If we exceed screen to the right, we position left instead of right - final adjustedLeft = (left + AIScribeSizes.menuWidth > screenSize.width) - ? widget.position.dx - AIScribeSizes.menuWidth - AIScribeSizes.submenuSpacing - : left; - - // If we exceed screen to the top, we position at top - final adjustedTop = (top < 0.0) - ? 0.0 - : top; - - return Stack( - children: [ - Positioned( - left: adjustedLeft, - top: adjustedTop, - child: MouseRegion( - onEnter: (_) => widget.onHover(), - onExit: (_) => widget.onDismiss(), - child: PointerInterceptor( - child: Material( - color: Colors.white, - elevation: AIScribeSizes.dialogElevation, - borderRadius: BorderRadius.circular(AIScribeSizes.menuBorderRadius), - child: Container( - width: AIScribeSizes.menuWidth, - constraints: const BoxConstraints(maxHeight: AIScribeSizes.submenuMaxHeight), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: widget.category.actions.map((action) { - return SizedBox( - height: AIScribeSizes.menuItemHeight, - child: InkWell( - onTap: () => widget.onActionSelected(action), - borderRadius: BorderRadius.circular(AIScribeSizes.menuItemBorderRadius), - child: Padding( - padding: AIScribeSizes.menuItemPadding, - child: Align( - alignment: Alignment.centerLeft, - child: Text( - action.getLabel(context), - style: AIScribeTextStyles.menuItem, - ), - ), - ), - ), - ); - }).toList(), - ), - ), - ), - ), - ), - ), - ], - ); - } -} diff --git a/scribe/lib/scribe/ai/presentation/widgets/ai_scribe_suggestion.dart b/scribe/lib/scribe/ai/presentation/widgets/ai_scribe_suggestion.dart deleted file mode 100644 index 520a9c8578..0000000000 --- a/scribe/lib/scribe/ai/presentation/widgets/ai_scribe_suggestion.dart +++ /dev/null @@ -1,198 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:pointer_interceptor/pointer_interceptor.dart'; -import 'package:core/presentation/resources/image_paths.dart'; -import 'package:core/presentation/views/button/tmail_button_widget.dart'; -import 'package:scribe/scribe/ai/localizations/scribe_localizations.dart'; -import 'package:scribe/scribe/ai/presentation/styles/ai_scribe_styles.dart'; - -typedef OnInsertTextCallback = void Function(String text); - -class AIScribeSuggestion extends StatefulWidget { - final String title; - final Future suggestionFuture; - final VoidCallback onClose; - final OnInsertTextCallback onInsert; - final ImagePaths imagePaths; - - const AIScribeSuggestion({ - super.key, - required this.title, - required this.suggestionFuture, - required this.onClose, - required this.onInsert, - required this.imagePaths, - }); - - @override - State createState() => _AIScribeSuggestionModalState(); -} - -class _AIScribeSuggestionModalState extends State { - @override - Widget build(BuildContext context) { - final screenWidth = MediaQuery.of(context).size.width; - final modalWidth = screenWidth < AIScribeSizes.mobileBreakpoint - ? screenWidth * AIScribeSizes.mobileWidthPercentage - : AIScribeSizes.modalMaxWidthLargeScreen; - - return PointerInterceptor( - child: Material( - borderRadius: BorderRadius.circular(AIScribeSizes.menuBorderRadius), - child: Container( - width: modalWidth, - constraints: const BoxConstraints( - maxHeight: AIScribeSizes.modalMaxHeight, - ), - decoration: BoxDecoration( - color: AIScribeColors.background, - borderRadius: BorderRadius.circular(AIScribeSizes.menuBorderRadius), - ), - child: FutureBuilder( - future: widget.suggestionFuture, - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.waiting) { - return _buildLoadingState(); - } else if (snapshot.hasError) { - return _buildErrorState(); - } else if (snapshot.hasData) { - return _buildSuccessState(snapshot.data!); - } else { - return _buildErrorState(); - } - }, - ), - ), - ), - ); - } - - Widget _buildLoadingState() { - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _buildHeader(), - Flexible( - fit: FlexFit.loose, - child: Container( - height: AIScribeSizes.infoHeight, - padding: AIScribeSizes.suggestionInfoPadding, - child: const Center( - child: CircularProgressIndicator(), - ), - ), - ), - ], - ); - } - - Widget _buildSuccessState(String suggestion) { - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _buildHeader(), - Flexible( - fit: FlexFit.loose, - child: Container( - padding: AIScribeSizes.suggestionContentPadding, - child: SingleChildScrollView( - child: SelectableText( - suggestion, - style: AIScribeTextStyles.suggestionContent, - ), - ), - ), - ), - _buildFooter(suggestion), - ], - ); - } - - Widget _buildErrorState() { - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _buildHeader(), - Flexible( - fit: FlexFit.loose, - child: Container( - height: AIScribeSizes.infoHeight, - padding: AIScribeSizes.suggestionInfoPadding, - child: Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon( - Icons.error_outline, - size: AIScribeSizes.iconSize, - color: Colors.red, - ), - const SizedBox(height: AIScribeSizes.fieldSpacing), - Text( - ScribeLocalizations.of(context)!.failedToGenerate, - style: AIScribeTextStyles.suggestionContent, - textAlign: TextAlign.center, - ) - ], - ), - ), - ), - ], - ); - } - - Widget _buildHeader() { - return Container( - padding: AIScribeSizes.suggestionHeaderPadding, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - widget.title, - style: AIScribeTextStyles.suggestionTitle - ), - IconButton( - icon: const Icon( - Icons.close, - size: AIScribeSizes.iconSize, - ), - iconSize: AIScribeSizes.iconSize, - color: Colors.grey[600], - onPressed: widget.onClose, - ), - ], - ), - ); - } - - Widget _buildFooter(String suggestion) { - return Container( - padding: AIScribeSizes.suggestionFooterPadding, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - IconButton( - icon: const Icon( - Icons.content_copy, - size: AIScribeSizes.iconSize, - ), - color: Colors.grey[600], - onPressed: () { - Clipboard.setData(ClipboardData(text: suggestion)); - }, - ), - TMailButtonWidget( - text: ScribeLocalizations.of(context)!.insertButton, - textStyle: AIScribeButtonStyles.mainActionButtonText, - padding: AIScribeButtonStyles.mainActionButtonPadding, - backgroundColor: AIScribeButtonStyles.mainActionButtonBackgroundColor, - onTapActionCallback: () => widget.onInsert(suggestion), - ) - ], - ), - ); - } -} diff --git a/scribe/lib/scribe/ai/presentation/widgets/button/ai_assistant_button.dart b/scribe/lib/scribe/ai/presentation/widgets/button/ai_assistant_button.dart new file mode 100644 index 0000000000..4ba49932c1 --- /dev/null +++ b/scribe/lib/scribe/ai/presentation/widgets/button/ai_assistant_button.dart @@ -0,0 +1,53 @@ +import 'package:core/presentation/resources/image_paths.dart'; +import 'package:core/presentation/views/button/tmail_button_widget.dart'; +import 'package:flutter/material.dart'; +import 'package:scribe/scribe.dart'; + +typedef OnOpenAiAssistantModal = void Function( + Offset? position, + Size? size, +); + +class AiAssistantButton extends StatelessWidget { + final ImagePaths imagePaths; + final Color? iconColor; + final EdgeInsetsGeometry? margin; + final OnOpenAiAssistantModal onOpenAiAssistantModal; + + const AiAssistantButton({ + super.key, + required this.imagePaths, + required this.onOpenAiAssistantModal, + this.iconColor, + this.margin, + }); + + @override + Widget build(BuildContext context) { + return TMailButtonWidget.fromIcon( + icon: imagePaths.icGradientSparkle, + padding: AIScribeSizes.aiAssistantIconPadding, + backgroundColor: Colors.transparent, + iconSize: AIScribeSizes.aiAssistantIcon, + iconColor: iconColor, + margin: margin, + borderRadius: AIScribeSizes.aiAssistantIconRadius, + tooltipMessage: ScribeLocalizations.of(context).aiAssistant, + onTapActionCallback: () => _onTapActionCallback(context), + ); + } + + void _onTapActionCallback(BuildContext context) { + final renderBox = context.findRenderObject(); + + Offset? position; + Size? size; + + if (renderBox != null && renderBox is RenderBox) { + position = renderBox.localToGlobal(Offset.zero); + size = renderBox.size; + } + + onOpenAiAssistantModal(position, size); + } +} diff --git a/scribe/lib/scribe/ai/presentation/widgets/button/inline_ai_assist_button.dart b/scribe/lib/scribe/ai/presentation/widgets/button/inline_ai_assist_button.dart new file mode 100644 index 0000000000..1b36ed0956 --- /dev/null +++ b/scribe/lib/scribe/ai/presentation/widgets/button/inline_ai_assist_button.dart @@ -0,0 +1,52 @@ +import 'package:core/presentation/resources/image_paths.dart'; +import 'package:core/presentation/views/button/tmail_button_widget.dart'; +import 'package:flutter/material.dart'; +import 'package:scribe/scribe.dart'; + +class InlineAiAssistButton extends StatelessWidget { + final ImagePaths imagePaths; + final String? selectedText; + final OnSelectAiScribeSuggestionAction onSelectAiScribeSuggestionAction; + + const InlineAiAssistButton({ + super.key, + required this.imagePaths, + required this.onSelectAiScribeSuggestionAction, + this.selectedText, + }); + + @override + Widget build(BuildContext context) { + return TMailButtonWidget.fromIcon( + icon: imagePaths.icSparkle, + padding: AIScribeSizes.scribeButtonPadding, + backgroundColor: AIScribeColors.background, + iconSize: AIScribeSizes.scribeIcon, + iconColor: AIScribeColors.scribeIcon, + borderRadius: AIScribeSizes.scribeButtonRadius, + boxShadow: AIScribeShadows.sparkleIcon, + onTapActionCallback: () => _onTapActionCallback(context), + ); + } + + Future _onTapActionCallback(BuildContext context) async { + final renderBox = context.findRenderObject(); + + Offset? position; + Size? size; + + if (renderBox != null && renderBox is RenderBox) { + position = renderBox.localToGlobal(Offset.zero); + size = renderBox.size; + } + + await AiScribeModalManager.showAIScribeMenuModal( + imagePaths: imagePaths, + availableCategories: AIScribeMenuCategory.values, + buttonPosition: position, + content: selectedText, + buttonSize: size, + onSelectAiScribeSuggestionAction: onSelectAiScribeSuggestionAction, + ); + } +} diff --git a/scribe/lib/scribe/ai/presentation/widgets/context_menu/ai_scribe_context_menu.dart b/scribe/lib/scribe/ai/presentation/widgets/context_menu/ai_scribe_context_menu.dart new file mode 100644 index 0000000000..73054de963 --- /dev/null +++ b/scribe/lib/scribe/ai/presentation/widgets/context_menu/ai_scribe_context_menu.dart @@ -0,0 +1,110 @@ +import 'package:core/presentation/resources/image_paths.dart'; +import 'package:flutter/material.dart'; +import 'package:scribe/scribe.dart'; + +class AiScribeContextMenu extends StatefulWidget { + final ImagePaths imagePaths; + final List menuActions; + final ValueChanged onActionSelected; + final PopupSubmenuController? submenuController; + + const AiScribeContextMenu({ + super.key, + required this.imagePaths, + required this.menuActions, + required this.onActionSelected, + this.submenuController, + }); + + @override + State createState() => + _AiScribeContextMenuContentState(); +} + +class _AiScribeContextMenuContentState extends State { + final GlobalKey _contextMenuKey = GlobalKey(); + + @override + Widget build(BuildContext context) { + return Container( + key: _contextMenuKey, + width: AIScribeSizes.contextMenuWidth, + constraints: const BoxConstraints( + maxHeight: AIScribeSizes.submenuMaxHeight, + ), + decoration: BoxDecoration( + borderRadius: const BorderRadius.all( + Radius.circular(AIScribeSizes.menuRadius), + ), + color: AIScribeColors.background, + boxShadow: AIScribeShadows.modal, + ), + clipBehavior: Clip.antiAlias, + child: ListView.builder( + shrinkWrap: true, + padding: AIScribeSizes.contextMenuPadding, + itemCount: widget.menuActions.length, + itemBuilder: (context, index) { + final menuAction = widget.menuActions[index]; + return AiScribeContextMenuItem( + menuAction: menuAction, + imagePaths: widget.imagePaths, + onSelectAction: (menuAction) { + widget.submenuController?.hide(); + widget.onActionSelected(menuAction); + }, + onHoverShowSubmenu: (itemKey) => + menuAction.submenuActions?.isNotEmpty == true + ? _showSubmenu( + context: context, + itemKey: itemKey, + contextMenuKey: _contextMenuKey, + actions: menuAction.submenuActions!, + ) + : null, + onHoverOtherItem: widget.submenuController?.hide, + ); + }, + ), + ); + } + + Rect? _generateRectByKey(GlobalKey key) { + final renderObject = key.currentContext?.findRenderObject(); + if (renderObject is! RenderBox) return null; + final renderBox = renderObject; + + final offset = renderBox.localToGlobal(Offset.zero); + return offset & renderBox.size; + } + + void _showSubmenu({ + required BuildContext context, + required GlobalKey itemKey, + required GlobalKey contextMenuKey, + required List actions, + }) { + final itemRect = _generateRectByKey(itemKey); + final contextMenuRect = _generateRectByKey(contextMenuKey); + + if (itemRect == null || contextMenuRect == null) { + return; + } + + widget.submenuController?.show( + context: context, + anchor: itemRect, + anchorMenu: contextMenuRect, + submenuMaxHeight: AIScribeSizes.submenuMaxHeight, + submenuWidth: AIScribeSizes.submenuWidth, + menuFieldSpacing: AIScribeSizes.fieldSpacing, + submenu: AiScribeSubmenu( + menuActions: actions, + onSelectAction: (action) { + widget.submenuController?.hide(); + widget.onActionSelected(action); + }, + ), + ); + } +} diff --git a/scribe/lib/scribe/ai/presentation/widgets/context_menu/ai_scribe_context_menu_item.dart b/scribe/lib/scribe/ai/presentation/widgets/context_menu/ai_scribe_context_menu_item.dart new file mode 100644 index 0000000000..7dd1a9a4f0 --- /dev/null +++ b/scribe/lib/scribe/ai/presentation/widgets/context_menu/ai_scribe_context_menu_item.dart @@ -0,0 +1,132 @@ +import 'package:core/presentation/extensions/color_extension.dart'; +import 'package:core/presentation/resources/image_paths.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:scribe/scribe.dart'; + +class AiScribeContextMenuItem extends StatefulWidget { + final AiScribeContextMenuAction menuAction; + final ImagePaths imagePaths; + final ValueChanged onSelectAction; + final OnHoverShowSubmenu? onHoverShowSubmenu; + final VoidCallback? onHoverOtherItem; + + const AiScribeContextMenuItem({ + super.key, + required this.menuAction, + required this.imagePaths, + required this.onSelectAction, + this.onHoverShowSubmenu, + this.onHoverOtherItem, + }); + + @override + State createState() => + _AiScribeContextMenuItemState(); +} + +class _AiScribeContextMenuItemState extends State { + GlobalKey? _itemKey; + HoverSubmenuController? _hoverController; + + @override + void initState() { + super.initState(); + if (widget.menuAction.hasSubmenu) { + _itemKey = GlobalKey(); + _hoverController = HoverSubmenuController(); + } + } + + @override + Widget build(BuildContext context) { + final childWidget = Container( + key: _itemKey, + height: AIScribeSizes.menuItemHeight, + padding: AIScribeSizes.menuCategoryItemPadding, + alignment: AlignmentDirectional.centerStart, + child: Row( + children: [ + if (widget.menuAction.actionIcon != null) + Padding( + padding: const EdgeInsetsDirectional.only(end: 12), + child: SvgPicture.asset( + widget.menuAction.actionIcon!, + width: 20, + height: 20, + fit: BoxFit.fill, + colorFilter: + AppColor.gray424244.withValues(alpha: 0.72).asFilter(), + ), + ), + Flexible( + child: Text( + widget.menuAction.actionName, + style: AIScribeTextStyles.menuItem, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (widget.menuAction.hasSubmenu) + Padding( + padding: const EdgeInsetsDirectional.only(start: 3), + child: SvgPicture.asset( + widget.imagePaths.icArrowRight, + width: 16, + height: 16, + fit: BoxFit.fill, + colorFilter: AppColor.gray777778.asFilter(), + ), + ), + ], + ), + ); + + if (widget.menuAction.hasSubmenu) { + return MouseRegion( + onEnter: (_) { + _hoverController?.enter(); + + if (_itemKey != null) { + widget.onHoverShowSubmenu?.call(_itemKey!); + } else { + widget.onHoverOtherItem?.call(); + } + }, + onExit: (_) { + _hoverController?.exit(); + }, + child: Material( + type: MaterialType.transparency, + child: InkWell( + onTap: () => widget.onSelectAction(widget.menuAction), + hoverColor: AppColor.grayBackgroundColor, + child: childWidget, + ), + ), + ); + } + + return Material( + type: MaterialType.transparency, + child: InkWell( + onTap: () => widget.onSelectAction(widget.menuAction), + hoverColor: AppColor.grayBackgroundColor, + onHover: (_) { + _hoverController?.exit(); + widget.onHoverOtherItem?.call(); + }, + child: childWidget, + ), + ); + } + + @override + void dispose() { + if (_hoverController != null) { + _hoverController?.dispose(); + _hoverController = null; + } + super.dispose(); + } +} diff --git a/scribe/lib/scribe/ai/presentation/widgets/context_menu/ai_scribe_submenu.dart b/scribe/lib/scribe/ai/presentation/widgets/context_menu/ai_scribe_submenu.dart new file mode 100644 index 0000000000..e9fd8dbace --- /dev/null +++ b/scribe/lib/scribe/ai/presentation/widgets/context_menu/ai_scribe_submenu.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'package:scribe/scribe.dart'; + +class AiScribeSubmenu extends StatelessWidget { + final List menuActions; + final ValueChanged onSelectAction; + + const AiScribeSubmenu({ + super.key, + required this.menuActions, + required this.onSelectAction, + }); + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: AIScribeColors.background, + borderRadius: const BorderRadius.all( + Radius.circular(AIScribeSizes.menuRadius), + ), + boxShadow: AIScribeShadows.modal, + ), + clipBehavior: Clip.antiAlias, + child: ListView.builder( + shrinkWrap: true, + itemCount: menuActions.length, + itemBuilder: (_, index) { + final action = menuActions[index]; + return AiScribeSubmenuItem( + menuAction: action, + onSelectAction: onSelectAction, + ); + }, + ), + ); + } +} diff --git a/scribe/lib/scribe/ai/presentation/widgets/context_menu/ai_scribe_submenu_item.dart b/scribe/lib/scribe/ai/presentation/widgets/context_menu/ai_scribe_submenu_item.dart new file mode 100644 index 0000000000..b740da58b4 --- /dev/null +++ b/scribe/lib/scribe/ai/presentation/widgets/context_menu/ai_scribe_submenu_item.dart @@ -0,0 +1,55 @@ +import 'package:core/presentation/extensions/color_extension.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:scribe/scribe.dart'; + +class AiScribeSubmenuItem extends StatelessWidget { + final AiScribeContextMenuAction menuAction; + final ValueChanged onSelectAction; + + const AiScribeSubmenuItem({ + super.key, + required this.menuAction, + required this.onSelectAction, + }); + + @override + Widget build(BuildContext context) { + return Material( + type: MaterialType.transparency, + child: InkWell( + onTap: () => onSelectAction(menuAction), + hoverColor: AppColor.grayBackgroundColor, + child: Container( + height: AIScribeSizes.menuItemHeight, + padding: AIScribeSizes.menuItemPadding, + alignment: AlignmentDirectional.centerStart, + child: Row( + children: [ + if (menuAction.actionIcon != null) + Padding( + padding: const EdgeInsetsDirectional.only(end: 12), + child: SvgPicture.asset( + menuAction.actionIcon!, + width: 20, + height: 20, + fit: BoxFit.fill, + colorFilter: + AppColor.gray424244.withValues(alpha: 0.72).asFilter(), + ), + ), + Flexible( + child: Text( + menuAction.actionName, + style: AIScribeTextStyles.menuItem, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/scribe/lib/scribe/ai/presentation/widgets/modal/ai_scribe_modal_widget.dart b/scribe/lib/scribe/ai/presentation/widgets/modal/ai_scribe_modal_widget.dart new file mode 100644 index 0000000000..9250bccdaf --- /dev/null +++ b/scribe/lib/scribe/ai/presentation/widgets/modal/ai_scribe_modal_widget.dart @@ -0,0 +1,146 @@ +import 'dart:math'; + +import 'package:core/presentation/resources/image_paths.dart'; +import 'package:flutter/material.dart'; +import 'package:pointer_interceptor/pointer_interceptor.dart'; +import 'package:scribe/scribe.dart'; + +class AiScribeModalWidget extends StatelessWidget { + final ImagePaths imagePaths; + final String? content; + final List availableCategories; + final Offset? buttonPosition; + final Size? buttonSize; + final ModalPlacement? preferredPlacement; + final ModalCrossAxisAlignment crossAxisAlignment; + final PopupSubmenuController? submenuController; + + const AiScribeModalWidget({ + super.key, + required this.imagePaths, + required this.availableCategories, + this.content, + this.buttonPosition, + this.buttonSize, + this.preferredPlacement, + this.crossAxisAlignment = ModalCrossAxisAlignment.center, + this.submenuController, + }); + + @override + Widget build(BuildContext context) { + final hasContent = content?.isNotEmpty ?? false; + final scribeLocalizations = ScribeLocalizations.of(context); + final menuActions = availableCategories + .map((category) => AiScribeCategoryContextMenuAction( + category, + scribeLocalizations, + imagePaths, + )) + .toList(); + + final dialogContent = PointerInterceptor( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + spacing: AIScribeSizes.fieldSpacing, + children: [ + if (hasContent) + Flexible( + child: AiScribeContextMenu( + imagePaths: imagePaths, + menuActions: menuActions, + submenuController: submenuController, + onActionSelected: (menuAction) => _onActionSelected( + context, + menuAction, + ), + ), + ), + MouseRegion( + onEnter: (_) => submenuController?.hide(), + child: AIScribeBar( + imagePaths: imagePaths, + onCustomPrompt: (customPrompt) { + Navigator.of(context).pop(CustomPromptAction(customPrompt)); + submenuController?.hide(); + }, + ), + ), + ], + ), + ); + + if (buttonPosition != null && buttonSize != null) { + final maxHeightModal = hasContent + ? AIScribeSizes.searchBarMinHeight + + AIScribeSizes.fieldSpacing + + min(menuActions.length * AIScribeSizes.menuItemHeight, + AIScribeSizes.submenuMaxHeight) + : AIScribeSizes.searchBarMinHeight; + + final layoutResult = AnchoredModalLayoutCalculator.calculate( + input: AnchoredModalLayoutInput( + screenSize: MediaQuery.of(context).size, + anchorPosition: buttonPosition!, + anchorSize: buttonSize!, + menuSize: Size( + AIScribeSizes.modalMaxWidth, + maxHeightModal, + ), + preferredPlacement: preferredPlacement, + crossAxisAlignment: crossAxisAlignment, + ), + padding: AIScribeSizes.screenEdgePadding, + ); + + final position = layoutResult.position; + + final top = preferredPlacement == ModalPlacement.top + ? position.dy - + (hasContent + ? AIScribeSizes.modalSpacing + : AIScribeSizes.modalWithoutContentSpacing) + : position.dy; + + return PointerInterceptor( + child: Stack( + children: [ + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: Navigator.of(context).pop, + ), + ), + PositionedDirectional( + start: position.dx, + top: top, + child: dialogContent, + ), + ], + ), + ); + } + + return Center(child: dialogContent); + } + + void _onActionSelected( + BuildContext context, + AiScribeContextMenuAction menuAction, + ) { + final navigator = Navigator.of(context); + if (menuAction is AiScribeCategoryContextMenuAction) { + final firstAiScribeAction = menuAction.action.actions.firstOrNull; + if (firstAiScribeAction != null) { + navigator.pop(PredefinedAction(firstAiScribeAction)); + } else { + navigator.pop(); + } + } else if (menuAction is AiScribeActionContextMenuAction) { + navigator.pop(PredefinedAction(menuAction.action)); + } else { + navigator.pop(); + } + } +} diff --git a/scribe/lib/scribe/ai/presentation/widgets/modal/ai_scribe_suggestion_widget.dart b/scribe/lib/scribe/ai/presentation/widgets/modal/ai_scribe_suggestion_widget.dart new file mode 100644 index 0000000000..bb359365c6 --- /dev/null +++ b/scribe/lib/scribe/ai/presentation/widgets/modal/ai_scribe_suggestion_widget.dart @@ -0,0 +1,211 @@ +import 'dart:math'; + +import 'package:core/presentation/resources/image_paths.dart'; +import 'package:core/presentation/state/failure.dart'; +import 'package:core/presentation/state/success.dart'; +import 'package:dartz/dartz.dart' as dartz; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:pointer_interceptor/pointer_interceptor.dart'; +import 'package:scribe/scribe.dart'; +import 'package:scribe/scribe/ai/data/network/ai_api_exception.dart'; + +class AiScribeSuggestionWidget extends StatefulWidget { + final AIAction aiAction; + final String? content; + final ImagePaths imagePaths; + final Offset? buttonPosition; + final Size? buttonSize; + final ModalPlacement? preferredPlacement; + final ModalCrossAxisAlignment crossAxisAlignment; + final OnSelectAiScribeSuggestionAction onSelectAiScribeSuggestionAction; + + const AiScribeSuggestionWidget({ + super.key, + required this.aiAction, + required this.imagePaths, + required this.onSelectAiScribeSuggestionAction, + this.content, + this.buttonPosition, + this.buttonSize, + this.preferredPlacement, + this.crossAxisAlignment = ModalCrossAxisAlignment.center, + }); + + @override + State createState() => + _AiScribeSuggestionWidgetState(); +} + +class _AiScribeSuggestionWidgetState extends State { + GenerateAITextInteractor? _interactor; + + final ValueNotifier> _state = + ValueNotifier(dartz.Right(GenerateAITextLoading())); + + @override + void initState() { + super.initState(); + + if (!Get.isRegistered()) { + _state.value = dartz.Left( + GenerateAITextFailure( + GenerateAITextInteractorIsNotRegisteredException(), + ), + ); + return; + } + + _interactor = Get.find(); + _loadSuggestion(); + } + + Future _loadSuggestion() async { + final result = await _interactor!.execute( + widget.aiAction, + widget.content, + ); + + result.fold( + (failure) => _state.value = dartz.Left(failure), + (success) => _state.value = dartz.Right(success), + ); + } + + @override + Widget build(BuildContext context) { + final screenSize = MediaQuery.sizeOf(context); + + final modalWidth = min( + screenSize.width * AIScribeSizes.mobileFactor, + AIScribeSizes.suggestionModalMaxWidth, + ); + + final modalMaxHeight = min( + screenSize.height * AIScribeSizes.mobileFactor, + AIScribeSizes.suggestionModalMaxHeight, + ); + + final hasContent = widget.content?.trim().isNotEmpty == true; + + final dialogContent = _buildDialogContent(context, hasContent); + + if (!_hasAnchor) { + return Center( + child: _buildModalContainer( + width: modalWidth, + maxHeight: modalMaxHeight, + child: dialogContent, + ), + ); + } + + final layout = AnchoredModalLayoutCalculator.calculateAnchoredSuggestLayout( + screenSize: screenSize, + anchorPosition: widget.buttonPosition!, + anchorSize: widget.buttonSize!, + menuSize: Size(modalWidth, modalMaxHeight), + preferredPlacement: widget.preferredPlacement, + padding: AIScribeSizes.screenEdgePadding, + ); + + return PointerInterceptor( + child: Stack( + children: [ + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: _handleClickOutside, + ), + ), + PositionedDirectional( + start: layout.left, + bottom: layout.bottom, + child: _buildModalContainer( + width: modalWidth, + maxHeight: layout.availableHeight, + child: dialogContent, + ), + ), + ], + ), + ); + } + + Widget _buildDialogContent(BuildContext context, bool hasContent) { + final localizations = ScribeLocalizations.of(context); + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8, + children: [ + AiScribeSuggestionHeader( + title: widget.aiAction.getLabel(localizations), + imagePaths: widget.imagePaths, + ), + Flexible( + child: ValueListenableBuilder>( + valueListenable: _state, + builder: (_, state, __) { + return state.fold( + (_) => AiScribeSuggestionError( + imagePaths: widget.imagePaths, + ), + (value) { + if (value is GenerateAITextSuccess) { + return AiScribeSuggestionSuccess( + imagePaths: widget.imagePaths, + suggestionText: value.response.result, + hasContent: hasContent, + onSelectAction: widget.onSelectAiScribeSuggestionAction, + ); + } + + return AiScribeSuggestionLoading( + imagePaths: widget.imagePaths, + ); + }, + ); + }, + ), + ), + ], + ); + } + + Widget _buildModalContainer({ + required double width, + required double maxHeight, + required Widget child, + }) { + return PointerInterceptor( + child: Container( + width: width, + constraints: BoxConstraints( + minHeight: AIScribeSizes.suggestionModalMinHeight, + maxHeight: maxHeight, + ), + padding: AIScribeSizes.suggestionContentPadding, + decoration: BoxDecoration( + color: AIScribeColors.background, + borderRadius: BorderRadius.circular( + AIScribeSizes.menuRadius, + ), + boxShadow: AIScribeShadows.modal, + ), + child: child, + ), + ); + } + + bool get _hasAnchor => + widget.buttonPosition != null && widget.buttonSize != null; + + void _handleClickOutside() { + final result = _state.value.getOrElse(() => UIState.idle); + if (result is GenerateAITextSuccess || result is GenerateAITextFailure) { + Navigator.of(context).pop(); + } + } +} diff --git a/scribe/lib/scribe/ai/presentation/widgets/modal/suggestion/ai_scribe_suggestion_error.dart b/scribe/lib/scribe/ai/presentation/widgets/modal/suggestion/ai_scribe_suggestion_error.dart new file mode 100644 index 0000000000..e5cc19c366 --- /dev/null +++ b/scribe/lib/scribe/ai/presentation/widgets/modal/suggestion/ai_scribe_suggestion_error.dart @@ -0,0 +1,36 @@ +import 'package:core/presentation/resources/image_paths.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:scribe/scribe.dart'; + +class AiScribeSuggestionError extends StatelessWidget { + final ImagePaths imagePaths; + + const AiScribeSuggestionError({ + super.key, + required this.imagePaths, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsetsDirectional.only(bottom: 8, start: 8), + child: Row( + spacing: 8, + children: [ + SvgPicture.asset( + imagePaths.icWarning, + width: 22, + height: 22, + ), + Expanded( + child: Text( + ScribeLocalizations.of(context).failedToGenerate, + style: AIScribeTextStyles.suggestionLoading, + ), + ), + ], + ), + ); + } +} diff --git a/scribe/lib/scribe/ai/presentation/widgets/modal/suggestion/ai_scribe_suggestion_header.dart b/scribe/lib/scribe/ai/presentation/widgets/modal/suggestion/ai_scribe_suggestion_header.dart new file mode 100644 index 0000000000..5e18a305dc --- /dev/null +++ b/scribe/lib/scribe/ai/presentation/widgets/modal/suggestion/ai_scribe_suggestion_header.dart @@ -0,0 +1,42 @@ +import 'package:core/presentation/extensions/color_extension.dart'; +import 'package:core/presentation/resources/image_paths.dart'; +import 'package:core/presentation/views/button/tmail_button_widget.dart'; +import 'package:flutter/material.dart'; +import 'package:scribe/scribe/ai/presentation/styles/ai_scribe_styles.dart'; + +class AiScribeSuggestionHeader extends StatelessWidget { + final String title; + final ImagePaths imagePaths; + + const AiScribeSuggestionHeader({ + super.key, + required this.title, + required this.imagePaths, + }); + + @override + Widget build(BuildContext context) { + return Row( + spacing: 8, + children: [ + Expanded( + child: Text( + title, + style: AIScribeTextStyles.suggestionTitle, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + TMailButtonWidget.fromIcon( + icon: imagePaths.icCloseDialog, + iconSize: 20, + iconColor: AppColor.gray424244.withValues(alpha: 0.72), + padding: const EdgeInsets.all(3), + borderRadius: 20, + backgroundColor: Colors.transparent, + onTapActionCallback: Navigator.of(context).pop, + ) + ], + ); + } +} diff --git a/scribe/lib/scribe/ai/presentation/widgets/modal/suggestion/ai_scribe_suggestion_loading.dart b/scribe/lib/scribe/ai/presentation/widgets/modal/suggestion/ai_scribe_suggestion_loading.dart new file mode 100644 index 0000000000..5e1999a916 --- /dev/null +++ b/scribe/lib/scribe/ai/presentation/widgets/modal/suggestion/ai_scribe_suggestion_loading.dart @@ -0,0 +1,135 @@ +import 'package:core/presentation/extensions/color_extension.dart'; +import 'package:core/presentation/resources/image_paths.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:scribe/scribe.dart'; + +class AiScribeSuggestionLoading extends StatelessWidget { + final ImagePaths imagePaths; + + const AiScribeSuggestionLoading({ + super.key, + required this.imagePaths, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsetsDirectional.only(bottom: 8, start: 8), + child: Row( + spacing: 8, + children: [ + _SparklePulseIcon( + asset: imagePaths.icSparkle, + color: AppColor.blue00B7FF, + ), + Expanded( + child: _AnimatedEllipsisText( + text: ScribeLocalizations.of(context).generatingResponse, + style: AIScribeTextStyles.suggestionLoading, + ), + ), + ], + ), + ); + } +} + +class _SparklePulseIcon extends StatefulWidget { + final String asset; + final Color color; + + const _SparklePulseIcon({ + required this.asset, + required this.color, + }); + + @override + State<_SparklePulseIcon> createState() => _SparklePulseIconState(); +} + +class _SparklePulseIconState extends State<_SparklePulseIcon> + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + late final Animation _scale; + late final Animation _opacity; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1200), + )..repeat(reverse: true); + + _scale = Tween(begin: 0.95, end: 1.05).animate( + CurvedAnimation(parent: _controller, curve: Curves.easeInOut), + ); + + _opacity = Tween(begin: 0.7, end: 1.0).animate( + CurvedAnimation(parent: _controller, curve: Curves.easeInOut), + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return FadeTransition( + opacity: _opacity, + child: ScaleTransition( + scale: _scale, + child: SvgPicture.asset( + widget.asset, + width: 22, + height: 22, + colorFilter: widget.color.asFilter(), + ), + ), + ); + } +} + +class _AnimatedEllipsisText extends StatefulWidget { + final String text; + final TextStyle style; + + const _AnimatedEllipsisText({ + required this.text, + required this.style, + }); + + @override + State<_AnimatedEllipsisText> createState() => _AnimatedEllipsisTextState(); +} + +class _AnimatedEllipsisTextState extends State<_AnimatedEllipsisText> { + static const _dotStates = ['', '.', '..', '...']; + int _index = 0; + + @override + void initState() { + super.initState(); + _tick(); + } + + void _tick() { + Future.delayed(const Duration(milliseconds: 500), () { + if (!mounted) return; + setState(() => _index = (_index + 1) % _dotStates.length); + _tick(); + }); + } + + @override + Widget build(BuildContext context) { + return Text( + '${widget.text}${_dotStates[_index]}', + style: widget.style, + ); + } +} diff --git a/scribe/lib/scribe/ai/presentation/widgets/modal/suggestion/ai_scribe_suggestion_success.dart b/scribe/lib/scribe/ai/presentation/widgets/modal/suggestion/ai_scribe_suggestion_success.dart new file mode 100644 index 0000000000..4f15b0754f --- /dev/null +++ b/scribe/lib/scribe/ai/presentation/widgets/modal/suggestion/ai_scribe_suggestion_success.dart @@ -0,0 +1,50 @@ +import 'package:core/presentation/resources/image_paths.dart'; +import 'package:flutter/material.dart'; +import 'package:scribe/scribe.dart'; + +class AiScribeSuggestionSuccess extends StatelessWidget { + final ImagePaths imagePaths; + final String suggestionText; + final bool hasContent; + final OnSelectAiScribeSuggestionAction onSelectAction; + + const AiScribeSuggestionSuccess({ + super.key, + required this.imagePaths, + required this.suggestionText, + required this.onSelectAction, + this.hasContent = false, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsetsDirectional.only(bottom: 8), + child: Column( + spacing: 8, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Flexible( + child: SingleChildScrollView( + physics: const ClampingScrollPhysics(), + child: Padding( + padding: const EdgeInsetsDirectional.only(end: 8), + child: SelectableText( + suggestionText, + style: AIScribeTextStyles.suggestionContent, + ), + ), + ), + ), + AiScribeSuggestionSuccessListActions( + imagePaths: imagePaths, + suggestionText: suggestionText, + hasContent: hasContent, + onSelectAction: onSelectAction, + ), + ], + ), + ); + } +} diff --git a/scribe/lib/scribe/ai/presentation/widgets/modal/suggestion/ai_scribe_suggestion_success_list_actions.dart b/scribe/lib/scribe/ai/presentation/widgets/modal/suggestion/ai_scribe_suggestion_success_list_actions.dart new file mode 100644 index 0000000000..7482eb3e3b --- /dev/null +++ b/scribe/lib/scribe/ai/presentation/widgets/modal/suggestion/ai_scribe_suggestion_success_list_actions.dart @@ -0,0 +1,73 @@ +import 'package:core/presentation/extensions/color_extension.dart'; +import 'package:core/presentation/resources/image_paths.dart'; +import 'package:core/presentation/views/dialog/confirm_dialog_button.dart'; +import 'package:flutter/material.dart'; +import 'package:scribe/scribe.dart'; + +typedef OnSelectAiScribeSuggestionAction = void Function( + AiScribeSuggestionActions action, + String suggestionText, +); + +class AiScribeSuggestionSuccessListActions extends StatelessWidget { + final ImagePaths imagePaths; + final String suggestionText; + final bool hasContent; + final OnSelectAiScribeSuggestionAction onSelectAction; + + const AiScribeSuggestionSuccessListActions({ + super.key, + required this.imagePaths, + required this.suggestionText, + required this.onSelectAction, + this.hasContent = false, + }); + + @override + Widget build(BuildContext context) { + final localizations = ScribeLocalizations.of(context); + + return Row( + mainAxisAlignment: MainAxisAlignment.end, + spacing: 8, + children: [ + if (hasContent) + Flexible( + child: Container( + constraints: const BoxConstraints(minWidth: 67), + height: 36, + child: ConfirmDialogButton( + label: AiScribeSuggestionActions.replace.getLabel(localizations), + textColor: AppColor.primaryMain, + onTapAction: () { + Navigator.of(context).pop(); + onSelectAction( + AiScribeSuggestionActions.replace, + suggestionText, + ); + }, + ), + ), + ), + Flexible( + child: Container( + constraints: const BoxConstraints(minWidth: 72), + height: 36, + child: ConfirmDialogButton( + label: AiScribeSuggestionActions.insert.getLabel(localizations), + backgroundColor: AppColor.blueD2E9FF, + textColor: AppColor.primaryMain, + onTapAction: () { + Navigator.of(context).pop(); + onSelectAction( + AiScribeSuggestionActions.insert, + suggestionText, + ); + }, + ), + ), + ), + ], + ); + } +} diff --git a/scribe/lib/scribe/ai/presentation/widgets/overlay/ai_selection_overlay.dart b/scribe/lib/scribe/ai/presentation/widgets/overlay/ai_selection_overlay.dart new file mode 100644 index 0000000000..33581851ac --- /dev/null +++ b/scribe/lib/scribe/ai/presentation/widgets/overlay/ai_selection_overlay.dart @@ -0,0 +1,41 @@ +import 'package:core/presentation/resources/image_paths.dart'; +import 'package:flutter/material.dart'; +import 'package:pointer_interceptor/pointer_interceptor.dart'; +import 'package:scribe/scribe.dart'; + +class AiSelectionOverlay extends StatelessWidget { + const AiSelectionOverlay({ + super.key, + required this.selection, + required this.imagePaths, + required this.onSelectAiScribeSuggestionAction, + }); + + final TextSelectionModel? selection; + final ImagePaths imagePaths; + final OnSelectAiScribeSuggestionAction onSelectAiScribeSuggestionAction; + + @override + Widget build(BuildContext context) { + if (selection == null || + !selection!.hasSelection || + selection!.coordinates == null) { + return const SizedBox.shrink(); + } + + final coordinates = selection!.coordinates!; + final selectedText = selection!.selectedText; + + return PositionedDirectional( + start: coordinates.dx, + top: coordinates.dy, + child: PointerInterceptor( + child: InlineAiAssistButton( + imagePaths: imagePaths, + selectedText: selectedText, + onSelectAiScribeSuggestionAction: onSelectAiScribeSuggestionAction, + ), + ), + ); + } +} diff --git a/scribe/lib/scribe/ai/presentation/widgets/search/ai_scribe_bar.dart b/scribe/lib/scribe/ai/presentation/widgets/search/ai_scribe_bar.dart new file mode 100644 index 0000000000..eec55b5c4b --- /dev/null +++ b/scribe/lib/scribe/ai/presentation/widgets/search/ai_scribe_bar.dart @@ -0,0 +1,125 @@ +import 'package:core/presentation/resources/image_paths.dart'; +import 'package:core/presentation/views/button/tmail_button_widget.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:scribe/scribe.dart'; + +typedef OnCustomPromptCallback = void Function(String customPrompt); + +class AIScribeBar extends StatefulWidget { + final OnCustomPromptCallback onCustomPrompt; + final ImagePaths imagePaths; + + const AIScribeBar({ + super.key, + required this.onCustomPrompt, + required this.imagePaths, + }); + + @override + State createState() => _AIScribeBarState(); +} + +class _AIScribeBarState extends State { + final TextEditingController _controller = TextEditingController(); + final ValueNotifier _isButtonEnabled = ValueNotifier(false); + final FocusNode _focusNode = FocusNode(); + + @override + void initState() { + super.initState(); + _controller.addListener(_onTextChanged); + } + + @override + void dispose() { + _controller.removeListener(_onTextChanged); + _controller.dispose(); + _isButtonEnabled.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + void _onTextChanged() { + _isButtonEnabled.value = _controller.text.trim().isNotEmpty; + } + + void _onSendPressed() { + final prompt = _controller.text.trim(); + if (prompt.isNotEmpty) { + widget.onCustomPrompt(prompt); + _controller.clear(); + } + } + + @override + Widget build(BuildContext context) { + return Container( + width: AIScribeSizes.searchBarWidth, + padding: AIScribeSizes.searchBarPadding, + decoration: BoxDecoration( + borderRadius: const BorderRadius.all( + Radius.circular(AIScribeSizes.searchBarRadius), + ), + color: AIScribeColors.background, + boxShadow: AIScribeShadows.modal, + ), + constraints: const BoxConstraints( + maxHeight: AIScribeSizes.searchBarMaxHeight, + minHeight: AIScribeSizes.searchBarMinHeight, + ), + child: Row( + children: [ + Expanded( + child: KeyboardListener( + focusNode: _focusNode, + onKeyEvent: _handleKeyboardEvent, + child: TextField( + controller: _controller, + decoration: InputDecoration( + hintText: ScribeLocalizations.of(context).inputPlaceholder, + hintStyle: AIScribeTextStyles.searchBarHint, + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric(vertical: 8), + isDense: true, + ), + style: AIScribeTextStyles.searchBar, + maxLines: null, + keyboardType: TextInputType.multiline, + cursorHeight: 16, + ), + ), + ), + const SizedBox(width: AIScribeSizes.fieldSpacing), + ValueListenableBuilder( + valueListenable: _isButtonEnabled, + builder: (_, isEnabled, __) { + return TMailButtonWidget.fromIcon( + icon: widget.imagePaths.icSend, + iconSize: AIScribeSizes.sendIcon, + padding: AIScribeSizes.sendIconPadding, + iconColor: AIScribeColors.background, + backgroundColor: isEnabled + ? AIScribeColors.sendPromptBackground + : AIScribeColors.sendPromptBackgroundDisabled, + onTapActionCallback: isEnabled ? _onSendPressed : null, + ); + }, + ), + ], + ), + ); + } + + void _handleKeyboardEvent(KeyEvent event) { + if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.enter) { + final keys = HardwareKeyboard.instance.logicalKeysPressed; + final isShiftPressed = keys.contains(LogicalKeyboardKey.shiftLeft) || + keys.contains(LogicalKeyboardKey.shiftRight); + + if (!isShiftPressed) { + _onSendPressed(); + } + } + } +} diff --git a/scribe/pubspec.lock b/scribe/pubspec.lock index c7b31e06f4..76cf7a1e8f 100644 --- a/scribe/pubspec.lock +++ b/scribe/pubspec.lock @@ -109,10 +109,10 @@ packages: dependency: transitive description: name: build_runner_core - sha256: f8126682b87a7282a339b871298cc12009cb67109cfa1614d6436fb0289193e0 + sha256: "0671ad4162ed510b70d0eb4ad6354c249f8429cab4ae7a4cec86bbc2886eb76e" url: "https://pub.dev" source: hosted - version: "7.3.2" + version: "7.2.7+1" built_collection: dependency: transitive description: @@ -381,14 +381,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.0" - flutter_dotenv: - dependency: "direct main" - description: - name: flutter_dotenv - sha256: d9283d92059a22e9834bc0a31336658ffba77089fb6f3cc36751f1fc7c6661a3 - url: "https://pub.dev" - source: hosted - version: "5.0.2" flutter_image_compress: dependency: transitive description: @@ -547,7 +539,7 @@ packages: source: sdk version: "0.0.0" flutter_svg: - dependency: transitive + dependency: "direct main" description: name: flutter_svg sha256: d44bf546b13025ec7353091516f6881f1d4c633993cb109c3916c3a0159dadf1 @@ -677,6 +669,15 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" + jmap_dart_client: + dependency: "direct main" + description: + path: "." + ref: main + resolved-ref: "446efbbccdde981371b4d2a636762184adf6e926" + url: "https://github.com/linagora/jmap-dart-client.git" + source: git + version: "0.3.6" js: dependency: transitive description: @@ -686,13 +687,13 @@ packages: source: hosted version: "0.7.2" json_annotation: - dependency: transitive + dependency: "direct main" description: name: json_annotation - sha256: b10a7b2ff83d83c777edba3c6a0f97045ddadd56c944e1a23a3fdf43a1bf4467 + sha256: c33da08e136c3df0190bd5bbe51ae1df4a7d96e7954d1d7249fea2968a72d317 url: "https://pub.dev" source: hosted - version: "4.8.1" + version: "4.8.0" json_serializable: dependency: "direct dev" description: @@ -989,6 +990,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.2" + quiver: + dependency: transitive + description: + name: quiver + sha256: b1c1ac5ce6688d77f65f3375a9abb9319b3cb32486bdc7a1e0fdf004d7ba4e47 + url: "https://pub.dev" + source: hosted + version: "3.2.1" sanitize_html: dependency: transitive description: diff --git a/scribe/pubspec.yaml b/scribe/pubspec.yaml index 96d128a8bd..7c21abd24d 100644 --- a/scribe/pubspec.yaml +++ b/scribe/pubspec.yaml @@ -17,19 +17,27 @@ dependencies: core: path: ../core + ### Dependencies from git ### + jmap_dart_client: + git: + url: https://github.com/linagora/jmap-dart-client.git + ref: main + ### Dependencies from pub.dev ### dartz: 0.10.1 dio: 5.0.0 - flutter_dotenv: 5.0.2 - get: 4.6.6 pointer_interceptor: 0.10.1+2 intl: 0.20.2 + json_annotation: 4.8.0 + + flutter_svg: 2.1.0 + dev_dependencies: flutter_test: sdk: flutter diff --git a/scribe/test/scribe/ai/presentation/utils/context_menu/anchored_modal_layout_calculator_test.dart b/scribe/test/scribe/ai/presentation/utils/context_menu/anchored_modal_layout_calculator_test.dart new file mode 100644 index 0000000000..affebd9c21 --- /dev/null +++ b/scribe/test/scribe/ai/presentation/utils/context_menu/anchored_modal_layout_calculator_test.dart @@ -0,0 +1,153 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:scribe/scribe/ai/presentation/model/modal/anchored_modal_layout_input.dart'; +import 'package:scribe/scribe/ai/presentation/model/modal/modal_placement.dart'; +import 'package:scribe/scribe/ai/presentation/utils/modal/anchored_modal_layout_calculator.dart'; + +void main() { + const screen = Size(800, 600); + const menuSize = Size(300, 200); + const anchorSize = Size(24, 24); + + group('AnchoredModalLayoutCalculator – placement strategy', () { + test('places menu to the RIGHT when there is enough horizontal space', () { + final result = AnchoredModalLayoutCalculator.calculate( + input: const AnchoredModalLayoutInput( + screenSize: screen, + anchorPosition: Offset(100, 200), + anchorSize: anchorSize, + menuSize: menuSize, + ), + ); + + expect(result.placement, ModalPlacement.right); + expect(result.position.dx, greaterThan(100)); + }); + + test( + 'places menu at the BOTTOM when right space is insufficient but bottom has enough space', + () { + final result = AnchoredModalLayoutCalculator.calculate( + input: const AnchoredModalLayoutInput( + screenSize: screen, + anchorPosition: Offset(750, 200), + anchorSize: anchorSize, + menuSize: menuSize, + ), + ); + + expect(result.placement, ModalPlacement.bottom); + expect(result.position.dy, greaterThan(200)); + }); + + test( + 'places menu at the TOP when right and bottom space are insufficient but top has space', + () { + final result = AnchoredModalLayoutCalculator.calculate( + input: const AnchoredModalLayoutInput( + screenSize: screen, + anchorPosition: Offset(750, 500), + anchorSize: anchorSize, + menuSize: menuSize, + ), + ); + + expect(result.placement, ModalPlacement.top); + expect(result.position.dy, lessThan(500)); + }); + + test( + 'places menu to the LEFT when neither top nor bottom has enough vertical space', + () { + final result = AnchoredModalLayoutCalculator.calculate( + input: const AnchoredModalLayoutInput( + screenSize: Size(800, 260), + anchorPosition: Offset(750, 120), + anchorSize: anchorSize, + menuSize: menuSize, + ), + ); + + expect(result.placement, ModalPlacement.left); + expect(result.position.dx, lessThan(750)); + }); + + test('falls back to LEFT placement when no direction has sufficient space', + () { + final result = AnchoredModalLayoutCalculator.calculate( + input: const AnchoredModalLayoutInput( + screenSize: Size(320, 480), + anchorPosition: Offset(150, 200), + anchorSize: anchorSize, + menuSize: Size(300, 400), + ), + ); + + expect(result.placement, ModalPlacement.left); + }); + }); + + group('AnchoredModalLayoutCalculator – screen clamping', () { + test('clamps LEFT position to screen padding', () { + final result = AnchoredModalLayoutCalculator.calculate( + input: const AnchoredModalLayoutInput( + screenSize: screen, + anchorPosition: Offset(5, 200), + anchorSize: anchorSize, + menuSize: menuSize, + ), + padding: 16, + ); + + expect(result.position.dx, greaterThanOrEqualTo(16)); + }); + + test('clamps TOP position to screen padding', () { + final result = AnchoredModalLayoutCalculator.calculate( + input: const AnchoredModalLayoutInput( + screenSize: screen, + anchorPosition: Offset(200, 5), + anchorSize: anchorSize, + menuSize: menuSize, + ), + padding: 16, + ); + + expect(result.position.dy, greaterThanOrEqualTo(16)); + }); + + test('does not overflow RIGHT screen edge', () { + final result = AnchoredModalLayoutCalculator.calculate( + input: const AnchoredModalLayoutInput( + screenSize: screen, + anchorPosition: Offset(780, 200), + anchorSize: anchorSize, + menuSize: menuSize, + ), + padding: 16, + ); + + expect( + result.position.dx + menuSize.width, + lessThanOrEqualTo(screen.width - 16), + ); + }); + + test('does not overflow BOTTOM screen edge', () { + final result = AnchoredModalLayoutCalculator.calculate( + input: const AnchoredModalLayoutInput( + screenSize: screen, + anchorPosition: Offset(200, 580), + anchorSize: anchorSize, + menuSize: menuSize, + ), + padding: 16, + ); + + expect( + result.position.dy + menuSize.height, + lessThanOrEqualTo(screen.height - 16), + ); + }); + }); +} diff --git a/scribe/test/scribe/ai/presentation/widgets/ai_scribe_test.dart b/scribe/test/scribe/ai/presentation/widgets/ai_scribe_test.dart deleted file mode 100644 index 4089af2b5b..0000000000 --- a/scribe/test/scribe/ai/presentation/widgets/ai_scribe_test.dart +++ /dev/null @@ -1,202 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:scribe/scribe/ai/presentation/styles/ai_scribe_styles.dart'; -import 'package:scribe/scribe/ai/presentation/widgets/ai_scribe.dart'; - -void main() { - group('calculateMenuDialogHeight::', () { - test('should calculate correct height with content and default 4 categories', () { - // Arrange - const hasContent = true; - const categoryCount = 4; - - // Act - final height = calculateMenuDialogHeight( - hasContent: hasContent, - categoryCount: categoryCount, - ); - - // Assert - // 4 categories × 40px + 8px spacing + 48px bar = 216px - const expectedHeight = (4 * AIScribeSizes.menuItemHeight) + - AIScribeSizes.fieldSpacing + - AIScribeSizes.barHeight; - expect(height, expectedHeight); - expect(height, 216.0); - }); - - test('should calculate correct height with content and custom categories', () { - // Arrange - const hasContent = true; - const categoryCount = 2; - - // Act - final height = calculateMenuDialogHeight( - hasContent: hasContent, - categoryCount: categoryCount, - ); - - // Assert - // 2 categories × 40px + 8px spacing + 48px bar = 136px - const expectedHeight = (2 * AIScribeSizes.menuItemHeight) + - AIScribeSizes.fieldSpacing + - AIScribeSizes.barHeight; - expect(height, expectedHeight); - expect(height, 136.0); - }); - - test('should calculate correct height without content (bar only)', () { - // Arrange - const hasContent = false; - const categoryCount = 4; // Should be ignored when hasContent is false - - // Act - final height = calculateMenuDialogHeight( - hasContent: hasContent, - categoryCount: categoryCount, - ); - - // Assert - // Only the bar height - expect(height, AIScribeSizes.barHeight); - expect(height, 48.0); - }); - }); - - group('calculateModalPosition::', () { - testWidgets('should adjust left position when modal would go off-screen to the right', (tester) async { - // Arrange - const screenSize = Size(800, 600); - const buttonPosition = Offset(700, 300); // Close to right edge - const modalWidth = 440.0; - BuildContext? capturedContext; - - await tester.pumpWidget( - MaterialApp( - home: MediaQuery( - data: const MediaQueryData(size: screenSize), - child: Builder( - builder: (context) { - capturedContext = context; - return Container(); - }, - ), - ), - ), - ); - - // Act - final position = calculateModalPosition( - context: capturedContext!, - buttonPosition: buttonPosition, - modalWidth: modalWidth, - ); - - // Assert - final expectedLeft = screenSize.width - modalWidth - AIScribeSizes.screenEdgePadding; - expect(position.left, expectedLeft); - }); - - testWidgets('should adjust left position to screenEdgePadding when modal would go off-screen to the left', (tester) async { - // Arrange - const screenSize = Size(800, 600); - const buttonPosition = Offset(5, 300); // Very close to left edge - const modalWidth = 440.0; - BuildContext? capturedContext; - - await tester.pumpWidget( - MaterialApp( - home: MediaQuery( - data: const MediaQueryData(size: screenSize), - child: Builder( - builder: (context) { - capturedContext = context; - return Container(); - }, - ), - ), - ), - ); - - // Act - final position = calculateModalPosition( - context: capturedContext!, - buttonPosition: buttonPosition, - modalWidth: modalWidth, - ); - - // Assert - expect(position.left, AIScribeSizes.screenEdgePadding); - }); - - testWidgets('should adjust bottom position when modal height is provided and would go off-screen to the top', (tester) async { - // Arrange - const screenSize = Size(800, 600); - const buttonPosition = Offset(100, 50); // Close to top - const modalWidth = 440.0; - const modalHeight = 400.0; - BuildContext? capturedContext; - - await tester.pumpWidget( - MaterialApp( - home: MediaQuery( - data: const MediaQueryData(size: screenSize), - child: Builder( - builder: (context) { - capturedContext = context; - return Container(); - }, - ), - ), - ), - ); - - // Act - final position = calculateModalPosition( - context: capturedContext!, - buttonPosition: buttonPosition, - modalWidth: modalWidth, - modalHeight: modalHeight, - ); - - // Assert - final expectedBottom = screenSize.height - modalHeight - AIScribeSizes.screenEdgePadding; - expect(position.bottom, expectedBottom); - }); - - testWidgets('should adjust bottom position to screenEdgePadding when calculated bottom is too small', (tester) async { - // Arrange - const screenSize = Size(800, 600); - const buttonPosition = Offset(100, 595); // Very close to bottom edge - const modalWidth = 440.0; - const modalHeight = 400.0; - BuildContext? capturedContext; - - await tester.pumpWidget( - MaterialApp( - home: MediaQuery( - data: const MediaQueryData(size: screenSize), - child: Builder( - builder: (context) { - capturedContext = context; - return Container(); - }, - ), - ), - ), - ); - - // Act - final position = calculateModalPosition( - context: capturedContext!, - buttonPosition: buttonPosition, - modalWidth: modalWidth, - modalHeight: modalHeight, - ); - - // Assert - // bottom = 600 - 595 + 8 = 13, which is < 16, so it should be adjusted to 16 - expect(position.bottom, AIScribeSizes.screenEdgePadding); - }); - }); -} diff --git a/test/features/composer/presentation/composer_controller_test.dart b/test/features/composer/presentation/composer_controller_test.dart index d5ad4189bc..ea8b2c1898 100644 --- a/test/features/composer/presentation/composer_controller_test.dart +++ b/test/features/composer/presentation/composer_controller_test.dart @@ -8,7 +8,6 @@ import 'package:core/utils/application_manager.dart'; import 'package:core/utils/platform_info.dart'; import 'package:dartz/dartz.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:get/get.dart'; import 'package:html_editor_enhanced/html_editor.dart'; @@ -241,11 +240,6 @@ void main() { setUp(() { Get.testMode = true; - // Initialize DotEnv for testing - dotenv.testLoad(mergeWith: { - 'AI_ENABLED': 'false', - }); - // Mock base controller mockCachingManager = MockCachingManager(); mockLanguageCacheManager = MockLanguageCacheManager();