From 6e09d853fd020781cfad54ff4ec248c56e0bdd52 Mon Sep 17 00:00:00 2001 From: dab246 Date: Fri, 19 Dec 2025 15:09:56 +0700 Subject: [PATCH] feat(ai-scribe): Fix the English locale file ``(intl_en.arb)`` is missing all three AI Scribe keys feat(ai-scribe): Fix async function declared without proper return type annotation. feat(ai-scribe): Fix Async operations not properly awaited. feat(ai-scribe): Fix inconsistent capitalization between button labels. feat(ai-scribe): Fix GenerateAITextFailure check will never match. feat(ai-scribe): Fix consider validating text input for predefined actions. feat(ai-scribe): Using a subshell for directory isolation. feat(ai-scribe): Fix verify the mock usage or document intent. feat(ai-scribe): Using a more conventional import for Offset. feat(ai-scribe): Adding error handling for consistency feat(ai-scribe): Remove `fromJson` and `toJson` of AIResponse feat(ai-scribe): Using constants for role values. feat(ai-scribe): Using a more specific import. feat(ai-scribe): Using Timer for cleaner lifecycle management feat(ai-scribe): Adding error handling to fromMap() for consistency. feat(ai-scribe): Disposing the ValueNotifier feat(ai-scribe): Redundant null check after assignment feat(ai-scribe): Fix calling `registerSelectionChangeListener` multiple times may be inefficient. feat(ai-scribe): Safer type handling for JavaScript callback args feat(ai-scribe): Fix async callbacks not awaited in switch statement. feat(ai-scribe): Fix binding lifecycle mismatch for GetAIScribeConfigInteractor feat(ai-scribe): Fix ai prompts feat(ai-scribe): Fix unawaited async call to `_setupSelectionListener` feat(ai-scribe): Fix switch cases use top-origin coordinates while `PositionedDirectional(bottom:)`` expects bottom-origin coordinates. feat(ai-scribe): Adding error handling for selection listener setup. --- core/lib/utils/html/html_utils.dart | 2 +- core/lib/utils/string_convert.dart | 25 +++++++++------- ...andle_ai_scribe_in_composer_extension.dart | 17 +++++------ .../mixin/text_selection_mixin.dart | 21 +++++++++---- .../widgets/mobile/mobile_editor_widget.dart | 30 +++++++++---------- .../widgets/web/web_editor_widget.dart | 18 ++++++----- .../mailbox_dashboard_controller.dart | 2 +- .../preferences_interactors_bindings.dart | 2 -- lib/l10n/intl_en.arb | 18 +++++++++++ .../lib/scribe/ai/data/model/ai_message.dart | 4 ++- .../ai/domain/constants/ai_prompts.dart | 30 ++++++++++--------- .../scribe/ai/domain/model/ai_response.dart | 12 -------- scribe/lib/scribe/ai/l10n/intl_ru.arb | 2 +- .../model/text_selection_model.dart | 2 +- .../hover_submenu_controller.dart | 2 +- .../anchored_modal_layout_calculator.dart | 11 +++---- .../modal/ai_scribe_suggestion_widget.dart | 15 ++++++++-- .../ai_scribe_suggestion_loading.dart | 17 +++++++---- scripts/prebuild.sh | 9 +++--- .../composer_controller_test.dart | 4 +-- 20 files changed, 142 insertions(+), 101 deletions(-) diff --git a/core/lib/utils/html/html_utils.dart b/core/lib/utils/html/html_utils.dart index ab4d28b65..a50a87243 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 ({String name, String script})registerSelectionChangeListener(String viewId) => ( + static registerSelectionChangeListener(String viewId) => ( script: ''' let lastSelectedText = ''; diff --git a/core/lib/utils/string_convert.dart b/core/lib/utils/string_convert.dart index 8e1b7b75a..f8021f027 100644 --- a/core/lib/utils/string_convert.dart +++ b/core/lib/utils/string_convert.dart @@ -199,20 +199,25 @@ class StringConvert { } static String convertHtmlContentToTextContent(String htmlContent) { - final document = parse(htmlContent); + try { + final document = parse(htmlContent); - // Each paragraph is surrounded by block tags so we add a /n for each block tag - // Even
are surrounded by block tags so we can ignore
and treat them - // as paragraph - const blockTags = 'p, div, li, section, blockquote, article, header, footer, h1, h2, h3, h4, h5, h6'; + // Each paragraph is surrounded by block tags so we add a /n for each block tag + // Even
are surrounded by block tags so we can ignore
and treat them + // as paragraph + const blockTags = 'p, div, li, section, blockquote, article, header, footer, h1, h2, h3, h4, h5, h6'; - document.querySelectorAll(blockTags).forEach((element) { - element.append(Text('\n')); - }); + document.querySelectorAll(blockTags).forEach((element) { + element.append(Text('\n')); + }); - final String textContent = document.body?.text ?? ''; + final String textContent = document.body?.text ?? ''; - return textContent.trim(); + return textContent.trim(); + } catch (e) { + logError('StringConvert::convertHtmlContentToTextContent:Exception = $e'); + return htmlContent.trim(); + } } static String convertTextContentToHtmlContent(String textContent) { 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 index a9a872215..549465e31 100644 --- 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 @@ -35,7 +35,7 @@ extension HandleAiScribeInComposerExtension on ComposerController { } } - void insertTextInEditor(String text) async { + Future insertTextInEditor(String text) async { try { final htmlContent = StringConvert.convertTextContentToHtmlContent(text); @@ -87,18 +87,17 @@ extension HandleAiScribeInComposerExtension on ComposerController { // Ensure we only insert at cursor position by collapsing selection before inserting Future onInsertTextCallback(String text) async { await collapseSelection(); - - insertTextInEditor(text); + await insertTextInEditor(text); } // If there is a selection, it will replace the selection, else it will replace everything - void onReplaceTextCallback(String text) { + Future onReplaceTextCallback(String text) async { final selection = editorTextSelection.value?.selectedText; if (selection == null || selection.isEmpty) { clearTextInEditor(); } - insertTextInEditor(text); + await insertTextInEditor(text); } Future openAIAssistantModal(Offset? position, Size? size) async { @@ -116,16 +115,16 @@ extension HandleAiScribeInComposerExtension on ComposerController { ); } - void handleAiScribeSuggestionAction( + Future handleAiScribeSuggestionAction( AiScribeSuggestionActions action, String suggestionText, - ) { + ) async { switch (action) { case AiScribeSuggestionActions.replace: - onReplaceTextCallback(suggestionText); + await onReplaceTextCallback(suggestionText); break; case AiScribeSuggestionActions.insert: - onInsertTextCallback(suggestionText); + await onInsertTextCallback(suggestionText); break; } } diff --git a/lib/features/composer/presentation/mixin/text_selection_mixin.dart b/lib/features/composer/presentation/mixin/text_selection_mixin.dart index 24fa2bb92..12c4f2d0b 100644 --- a/lib/features/composer/presentation/mixin/text_selection_mixin.dart +++ b/lib/features/composer/presentation/mixin/text_selection_mixin.dart @@ -58,12 +58,21 @@ class TextSelectionCoordinates { }); factory TextSelectionCoordinates.fromMap(Map data) { - return TextSelectionCoordinates( - x: (data['x'] as num?)?.toDouble() ?? 0.0, - y: (data['y'] as num?)?.toDouble() ?? 0.0, - width: (data['width'] as num?)?.toDouble() ?? 0.0, - height: (data['height'] as num?)?.toDouble() ?? 0.0, - ); + try { + return TextSelectionCoordinates( + x: (data['x'] as num?)?.toDouble() ?? 0.0, + y: (data['y'] as num?)?.toDouble() ?? 0.0, + width: (data['width'] as num?)?.toDouble() ?? 0.0, + height: (data['height'] as num?)?.toDouble() ?? 0.0, + ); + } catch (e) { + return const TextSelectionCoordinates( + x: 0.0, + y: 0.0, + width: 0.0, + height: 0.0, + ); + } } Offset get position => Offset(x, y); 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 79daaa71a..2ece1ec3c 100644 --- a/lib/features/composer/presentation/widgets/mobile/mobile_editor_widget.dart +++ b/lib/features/composer/presentation/widgets/mobile/mobile_editor_widget.dart @@ -1,5 +1,6 @@ import 'package:core/presentation/constants/constants_ui.dart'; +import 'package:core/utils/app_logger.dart'; import 'package:core/utils/html/html_template.dart'; import 'package:core/utils/platform_info.dart'; import 'package:core/utils/html/html_utils.dart'; @@ -36,7 +37,6 @@ class MobileEditorWidget extends StatefulWidget { class _MobileEditorState extends State with TextSelectionMixin { - HtmlEditorApi? _editorApi; late String _createdViewId; @override @@ -48,9 +48,8 @@ class _MobileEditorState extends State with TextSelectionMix @override void Function(TextSelectionData?)? get onSelectionChanged => widget.onTextSelectionChanged; - void _setupSelectionListener() async { - final webViewController = _editorApi?.webViewController; - if (webViewController == null) return; + Future _setupSelectionListener(HtmlEditorApi editorApi) async { + final webViewController = editorApi.webViewController; final registerSelectionChange = HtmlUtils.registerSelectionChangeListener(_createdViewId); @@ -61,8 +60,10 @@ class _MobileEditorState extends State with TextSelectionMix if (!mounted) return; if (args.isNotEmpty) { - final data = args[0] as Map; - handleSelectionChange(data); + final rawData = args[0]; + if (rawData is Map) { + handleSelectionChange(rawData); + } } }, ); @@ -72,10 +73,13 @@ class _MobileEditorState extends State with TextSelectionMix ); } - @override - void dispose() { - _editorApi = null; - super.dispose(); + Future _onWebViewCreated(HtmlEditorApi editorApi) async { + widget.onCreatedEditorAction.call(context, editorApi, widget.content); + try { + await _setupSelectionListener(editorApi); + } catch (e) { + logError('Error onWebViewCreated: $e'); + } } @override @@ -90,11 +94,7 @@ class _MobileEditorState extends State with TextSelectionMix direction: widget.direction, useDefaultFontStyle: true, ), - onCreated: (editorApi) { - _editorApi = editorApi; - widget.onCreatedEditorAction.call(context, editorApi, widget.content); - _setupSelectionListener(); - }, + onCreated: _onWebViewCreated, onCompleted: widget.onLoadCompletedEditorAction, onContentHeightChanged: PlatformInfo.isIOS ? widget.onEditorContentHeightChanged : null, ); 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 bd97f20f6..e78c2f806 100644 --- a/lib/features/composer/presentation/widgets/web/web_editor_widget.dart +++ b/lib/features/composer/presentation/widgets/web/web_editor_widget.dart @@ -92,6 +92,7 @@ class _WebEditorState extends State with TextSelectionMixin { bool _signatureTooltipReady = false; late String _createdViewId; + late WebScript _selectionChangeScript; @override void Function(TextSelectionData?)? get onSelectionChanged => widget.onTextSelectionChanged; @@ -103,7 +104,11 @@ class _WebEditorState extends State with TextSelectionMixin { _editorController = widget.editorController; final registerSelectionChange = - HtmlUtils.registerSelectionChangeListener(_createdViewId); + HtmlUtils.registerSelectionChangeListener(_createdViewId); + _selectionChangeScript = WebScript( + name: registerSelectionChange.name, + script: registerSelectionChange.script, + ); _editorListener = (event) { try { @@ -112,7 +117,7 @@ class _WebEditorState extends State with TextSelectionMixin { if (data['name'] == HtmlUtils.registerDropListener.name) { _editorController.evaluateJavascriptWeb(HtmlUtils.removeLineHeight1px.name); - } else if (data['name'] == registerSelectionChange.name + } else if (data['name'] == _selectionChangeScript.name && data['viewId'] == _createdViewId) { handleSelectionChange(data); } @@ -123,9 +128,8 @@ class _WebEditorState extends State with TextSelectionMixin { ); } }; - if (_editorListener != null) { - window.addEventListener("message", _editorListener!); - } + + window.addEventListener("message", _editorListener!); } @override @@ -189,8 +193,8 @@ class _WebEditorState extends State with TextSelectionMixin { script: HtmlUtils.unregisterDropListener.script, ), WebScript( - name: HtmlUtils.registerSelectionChangeListener(_createdViewId).name, - script: HtmlUtils.registerSelectionChangeListener(_createdViewId).script, + name: _selectionChangeScript.name, + script: _selectionChangeScript.script, ), WebScript( name: HtmlUtils.collapseSelectionToEnd.name, 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 d9b18172f..7e03ade3c 100644 --- a/lib/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller.dart +++ b/lib/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller.dart @@ -309,7 +309,7 @@ class MailboxDashBoardController extends ReloadableController final isPopupMenuOpened = RxBool(false); final octetsQuota = Rxn(); final isTextFormattingMenuOpened = RxBool(false); - final cachedAIScribeConfig = Rx(AIScribeConfig.initial()); + final cachedAIScribeConfig = AIScribeConfig.initial().obs; Map mapDefaultMailboxIdByRole = {}; Map mapMailboxById = {}; diff --git a/lib/features/manage_account/presentation/preferences/bindings/preferences_interactors_bindings.dart b/lib/features/manage_account/presentation/preferences/bindings/preferences_interactors_bindings.dart index 8994a9cfd..09dd63546 100644 --- a/lib/features/manage_account/presentation/preferences/bindings/preferences_interactors_bindings.dart +++ b/lib/features/manage_account/presentation/preferences/bindings/preferences_interactors_bindings.dart @@ -6,7 +6,6 @@ import 'package:tmail_ui_user/features/manage_account/data/local/language_cache_ import 'package:tmail_ui_user/features/manage_account/data/local/preferences_setting_manager.dart'; import 'package:tmail_ui_user/features/manage_account/data/repository/manage_account_repository_impl.dart'; import 'package:tmail_ui_user/features/manage_account/domain/repository/manage_account_repository.dart'; -import 'package:tmail_ui_user/features/manage_account/domain/usecases/get_ai_scribe_config_interactor.dart'; import 'package:tmail_ui_user/features/manage_account/domain/usecases/save_language_interactor.dart'; import 'package:tmail_ui_user/features/server_settings/data/datasource/server_settings_data_source.dart'; import 'package:tmail_ui_user/features/server_settings/data/datasource_impl/remote_server_settings_data_source_impl.dart'; @@ -102,6 +101,5 @@ class PreferencesInteractorsBindings extends InteractorsBindings { Get.delete(tag: composerId); Get.delete(tag: composerId); Get.delete(tag: composerId); - Get.delete(tag: composerId); } } \ No newline at end of file diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 1a9197881..c1fcb50b6 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -3274,5 +3274,23 @@ "type": "text", "placeholders_order": [], "placeholders": {} + }, + "aiScribe": "AI Scribe", + "@aiScribe": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "aiScribeSettingExplanation": "Use AI to help write and improve your emails", + "@aiScribeSettingExplanation": { + "type": "text", + "placeholders_order": [], + "placeholders": {} + }, + "aiScribeToggleDescription": "Enable AI Scribe", + "@aiScribeToggleDescription": { + "type": "text", + "placeholders_order": [], + "placeholders": {} } } diff --git a/scribe/lib/scribe/ai/data/model/ai_message.dart b/scribe/lib/scribe/ai/data/model/ai_message.dart index 67d2482a8..eb8e6d571 100644 --- a/scribe/lib/scribe/ai/data/model/ai_message.dart +++ b/scribe/lib/scribe/ai/data/model/ai_message.dart @@ -4,6 +4,8 @@ part 'ai_message.g.dart'; @JsonSerializable() class AIMessage { + static const String aiUserRole = 'user'; + final String role; final String content; @@ -18,7 +20,7 @@ class AIMessage { Map toJson() => _$AIMessageToJson(this); factory AIMessage.ofUser(String content) => AIMessage( - role: 'user', + role: aiUserRole, content: content, ); } diff --git a/scribe/lib/scribe/ai/domain/constants/ai_prompts.dart b/scribe/lib/scribe/ai/domain/constants/ai_prompts.dart index 9e6887c94..a0eafc2ed 100644 --- a/scribe/lib/scribe/ai/domain/constants/ai_prompts.dart +++ b/scribe/lib/scribe/ai/domain/constants/ai_prompts.dart @@ -1,17 +1,19 @@ import 'package:scribe/scribe/ai/presentation/model/ai_action.dart'; import 'package:scribe/scribe/ai/presentation/model/ai_scribe_menu_action.dart'; -const PERFORM_TASK = "Perform only the following task:"; -const PRESERVE_LANGUAGE_PROMPT = "Do not translate. Strictly keep the original language of the input text. For example, if it's French, keep French. If it's English, keep English."; -const DO_NOT_ADD_INFO_PROMPT = "Do not add any extra information or interpret anything beyond the explicit task."; - -// TODO -// In a near future, prompts will be loaded from a remote source class AIPrompts { + static const _performTask = "Perform only the following task:"; + static const _preserveLanguagePrompt = + "Do not translate. Strictly keep the original language of the input text. For example, if it's French, keep French. If it's English, keep English."; + static const _doNotAddInfoPrompt = + "Do not add any extra information or interpret anything beyond the explicit task."; + static String buildPrompt(AIAction action, String? text) { return switch (action) { PredefinedAction(action: final menuAction) => - buildPredefinedPrompt(menuAction, text ?? ''), + text?.trim().isNotEmpty == true + ? buildPredefinedPrompt(menuAction, text!) + : throw ArgumentError('Text cannot be empty for predefined actions'), CustomPromptAction(prompt: final customPrompt) => buildCustomPrompt(customPrompt, text), }; @@ -47,31 +49,31 @@ class AIPrompts { } static String improveMakeShorter(String text) { - return '$PERFORM_TASK make the text shorter but preserve the meaning. $PRESERVE_LANGUAGE_PROMPT $DO_NOT_ADD_INFO_PROMPT Text:\n\n$text'; + return '$_performTask make the text shorter but preserve the meaning. $_preserveLanguagePrompt $_doNotAddInfoPrompt Text:\n\n$text'; } static String improveExpandContext(String text) { - return '$PERFORM_TASK expand the context of the text to make it more detailed and comprehensive. $PRESERVE_LANGUAGE_PROMPT $DO_NOT_ADD_INFO_PROMPT Text:\n\n$text'; + return '$_performTask expand the context of the text to make it more detailed and comprehensive. $_preserveLanguagePrompt $_doNotAddInfoPrompt Text:\n\n$text'; } static String improveEmojify(String text) { - return '$PERFORM_TASK add emojis to the important parts of the text. Do not try to rephrase or replace text. $PRESERVE_LANGUAGE_PROMPT $DO_NOT_ADD_INFO_PROMPT Text:\n\n$text'; + return '$_performTask add emojis to the important parts of the text. Do not try to rephrase or replace text. $_preserveLanguagePrompt $_doNotAddInfoPrompt Text:\n\n$text'; } static String improveTransformToBullets(String text) { - return '$PERFORM_TASK transform the text in a bullet list. $PRESERVE_LANGUAGE_PROMPT $DO_NOT_ADD_INFO_PROMPT Text:\n\n$text'; + return '$_performTask transform the text into a bullet list. $_preserveLanguagePrompt $_doNotAddInfoPrompt Text:\n\n$text'; } static String correctGrammar(String text) { - return '$PERFORM_TASK correct grammar and spelling. $PRESERVE_LANGUAGE_PROMPT $DO_NOT_ADD_INFO_PROMPT Text:\n\n$text'; + return '$_performTask correct grammar and spelling. $_preserveLanguagePrompt $_doNotAddInfoPrompt Text:\n\n$text'; } static String changeToneTo(String text, String tone) { - return '$PERFORM_TASK change the tone to be $tone. $PRESERVE_LANGUAGE_PROMPT $DO_NOT_ADD_INFO_PROMPT Text:\n\n$text'; + return '$_performTask change the tone to be $tone. $_preserveLanguagePrompt $_doNotAddInfoPrompt Text:\n\n$text'; } static String translateTo(String text, String language) { - return '$PERFORM_TASK translate. Translate the text to the specified language: $language. $DO_NOT_ADD_INFO_PROMPT Text:\n\n$text'; + return '$_performTask translate. Translate the text to the specified language: $language. $_doNotAddInfoPrompt Text:\n\n$text'; } static String buildCustomPrompt(String customPrompt, String? text) { diff --git a/scribe/lib/scribe/ai/domain/model/ai_response.dart b/scribe/lib/scribe/ai/domain/model/ai_response.dart index 2f73960f2..91379ad2b 100644 --- a/scribe/lib/scribe/ai/domain/model/ai_response.dart +++ b/scribe/lib/scribe/ai/domain/model/ai_response.dart @@ -2,16 +2,4 @@ class AIResponse { final String result; const AIResponse({required this.result}); - - factory AIResponse.fromJson(Map json) { - return AIResponse( - result: json['result'] as String? ?? json['text'] as String? ?? '', - ); - } - - Map toJson() { - return { - 'result': result, - }; - } } diff --git a/scribe/lib/scribe/ai/l10n/intl_ru.arb b/scribe/lib/scribe/ai/l10n/intl_ru.arb index 8e1e9fc16..c67135054 100644 --- a/scribe/lib/scribe/ai/l10n/intl_ru.arb +++ b/scribe/lib/scribe/ai/l10n/intl_ru.arb @@ -114,7 +114,7 @@ "placeholders_order": [], "placeholders": {} }, - "replaceButton": "заменить", + "replaceButton": "Заменить", "@replaceButton": { "type": "text", "placeholders_order": [], diff --git a/scribe/lib/scribe/ai/presentation/model/text_selection_model.dart b/scribe/lib/scribe/ai/presentation/model/text_selection_model.dart index 995f91433..10a04378f 100644 --- a/scribe/lib/scribe/ai/presentation/model/text_selection_model.dart +++ b/scribe/lib/scribe/ai/presentation/model/text_selection_model.dart @@ -1,4 +1,4 @@ -import 'package:flutter/cupertino.dart'; +import 'dart:ui' show Offset; class TextSelectionModel { final String? selectedText; 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 index 43b04f7a3..d6eebd591 100644 --- 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 @@ -1,6 +1,6 @@ import 'dart:async'; -import 'package:flutter/cupertino.dart'; +import 'package:flutter/widgets.dart'; typedef OnHoverShowSubmenu = void Function(GlobalKey key); 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 index a968977ec..f42e60c97 100644 --- 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 @@ -1,4 +1,4 @@ -import 'package:flutter/animation.dart'; +import 'dart:ui'; import 'package:scribe/scribe.dart'; class AnchoredModalLayoutCalculator { @@ -201,21 +201,22 @@ class AnchoredModalLayoutCalculator { switch (placement) { case ModalPlacement.right: left = anchorPosition.dx + anchorSize.width + gap; - bottom = anchorPosition.dy + verticalOffset; + bottom = screenSize.height - (anchorPosition.dy + verticalOffset); break; case ModalPlacement.bottom: left = anchorPosition.dx; - bottom = anchorPosition.dy + anchorSize.height + gap + verticalOffset; + bottom = screenSize.height - (anchorPosition.dy + anchorSize.height + gap + verticalOffset); break; case ModalPlacement.top: left = anchorPosition.dx; - bottom = anchorPosition.dy - menuSize.height - gap + verticalOffset; + bottom = screenSize.height - (anchorPosition.dy - menuSize.height - gap + verticalOffset); break; + case ModalPlacement.left: left = anchorPosition.dx - menuSize.width - gap; - bottom = anchorPosition.dy + verticalOffset; + bottom = screenSize.height - (anchorPosition.dy + verticalOffset); break; } 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 index bb359365c..84389d372 100644 --- 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 @@ -66,6 +66,8 @@ class _AiScribeSuggestionWidgetState extends State { widget.content, ); + if (!mounted) return; + result.fold( (failure) => _state.value = dartz.Left(failure), (success) => _state.value = dartz.Right(success), @@ -203,9 +205,18 @@ class _AiScribeSuggestionWidgetState extends State { widget.buttonPosition != null && widget.buttonSize != null; void _handleClickOutside() { - final result = _state.value.getOrElse(() => UIState.idle); - if (result is GenerateAITextSuccess || result is GenerateAITextFailure) { + final shouldDismiss = _state.value.fold( + (failure) => failure is GenerateAITextFailure, + (success) => success is GenerateAITextSuccess, + ); + if (shouldDismiss) { Navigator.of(context).pop(); } } + + @override + void dispose() { + _state.dispose(); + super.dispose(); + } } 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 index 5e1999a91..9a9ed8365 100644 --- 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 @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:core/presentation/extensions/color_extension.dart'; import 'package:core/presentation/resources/image_paths.dart'; import 'package:flutter/material.dart'; @@ -110,21 +112,18 @@ class _AnimatedEllipsisText extends StatefulWidget { class _AnimatedEllipsisTextState extends State<_AnimatedEllipsisText> { static const _dotStates = ['', '.', '..', '...']; int _index = 0; + Timer? _timer; @override void initState() { super.initState(); - _tick(); - } - - void _tick() { - Future.delayed(const Duration(milliseconds: 500), () { + _timer = Timer.periodic(const Duration(milliseconds: 500), (_) { if (!mounted) return; setState(() => _index = (_index + 1) % _dotStates.length); - _tick(); }); } + @override Widget build(BuildContext context) { return Text( @@ -132,4 +131,10 @@ class _AnimatedEllipsisTextState extends State<_AnimatedEllipsisText> { style: widget.style, ); } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } } diff --git a/scripts/prebuild.sh b/scripts/prebuild.sh index 8293f779a..752055ef9 100755 --- a/scripts/prebuild.sh +++ b/scripts/prebuild.sh @@ -27,7 +27,8 @@ dart run build_runner build --delete-conflicting-outputs && dart run intl_generator:generate_from_arb --output-dir=lib/l10n --no-use-deferred-loading lib/main/localizations/app_localizations.dart lib/l10n/intl*.arb # For scribe module localizations -cd scribe -dart run intl_generator:extract_to_arb --output-dir=./lib/scribe/ai/l10n lib/scribe/ai/localizations/scribe_localizations.dart && - dart run intl_generator:generate_from_arb --output-dir=lib/scribe/ai/l10n --no-use-deferred-loading lib/scribe/ai/localizations/scribe_localizations.dart lib/scribe/ai/l10n/intl*.arb -cd ../ +( + cd scribe + dart run intl_generator:extract_to_arb --output-dir=./lib/scribe/ai/l10n lib/scribe/ai/localizations/scribe_localizations.dart && + dart run intl_generator:generate_from_arb --output-dir=lib/scribe/ai/l10n --no-use-deferred-loading lib/scribe/ai/localizations/scribe_localizations.dart lib/scribe/ai/l10n/intl*.arb +) diff --git a/test/features/composer/presentation/composer_controller_test.dart b/test/features/composer/presentation/composer_controller_test.dart index d47551ca9..1bb31c6b8 100644 --- a/test/features/composer/presentation/composer_controller_test.dart +++ b/test/features/composer/presentation/composer_controller_test.dart @@ -25,7 +25,6 @@ import 'package:model/email/email_action_type.dart'; import 'package:model/extensions/session_extension.dart'; import 'package:model/mailbox/presentation_mailbox.dart'; import 'package:rich_text_composer/rich_text_composer.dart'; -import 'package:scribe/scribe/ai/domain/usecases/generate_ai_text_interactor.dart'; import 'package:tmail_ui_user/features/base/before_reconnect_manager.dart'; import 'package:tmail_ui_user/features/caching/caching_manager.dart'; import 'package:tmail_ui_user/features/composer/domain/repository/composer_repository.dart'; @@ -147,7 +146,7 @@ class MockMailboxDashBoardController extends Mock implements MailboxDashBoardCon RxBool get isPopupMenuOpened => false.obs; @override - Rx get cachedAIScribeConfig => Rx(AIScribeConfig.initial()); + Rx get cachedAIScribeConfig => AIScribeConfig.initial().obs; } @GenerateNiceMocks([ @@ -183,7 +182,6 @@ class MockMailboxDashBoardController extends Mock implements MailboxDashBoardCon MockSpec(), MockSpec(), MockSpec(), - MockSpec(), // Additional Getx dependencies mock specs MockSpec(fallbackGenerators: fallbackGenerators),