From 28d50366670483cd986644c33bdb5e7a6280928b Mon Sep 17 00:00:00 2001 From: dab246 Date: Mon, 16 Mar 2026 11:45:14 +0700 Subject: [PATCH] TF-4378 Migrate `scribe-mobile` branch to `master` branch --- core/lib/utils/string_convert.dart | 4 +- .../rich_text_mobile_tablet_controller.dart | 5 +- ...andle_ai_scribe_in_composer_extension.dart | 39 ++++++------ .../state/get_linagora_ecosystem_state.dart | 2 +- .../mailbox_dashboard_controller.dart | 1 + .../setup_scribe_prompt_url_extension.dart | 7 ++- model/lib/extensions/session_extension.dart | 2 +- .../ai/data/datasource/ai_datasource.dart | 2 +- .../datasource_impl/ai_datasource_impl.dart | 2 +- .../scribe/ai/data/model/ai_api_request.dart | 2 +- scribe/lib/scribe/ai/data/network/ai_api.dart | 2 +- .../data/repository/ai_repository_impl.dart | 2 +- .../ai/data/service/prompt_service.dart | 13 ++-- .../ai/domain/constants/ai_prompts.dart | 26 ++++++-- .../ai/{data => domain}/model/ai_message.dart | 0 .../scribe/ai/domain/model/prompt_data.dart | 34 +++++------ .../repository/ai_scribe_repository.dart | 2 +- .../presentation/styles/ai_scribe_styles.dart | 10 +--- .../utils/ai_scribe_mobile_utils.dart | 7 ++- .../ai_scribe_suggestion_state_mixin.dart | 23 ++++--- .../button/inline_ai_assist_button.dart | 15 +++-- ...ai_scribe_mobile_actions_bottom_sheet.dart | 32 ++++------ .../mobile/ai_scribe_mobile_actions_item.dart | 12 ++-- .../widgets/modal/ai_scribe_modal_widget.dart | 10 +++- .../modal/ai_scribe_suggestion_widget.dart | 23 ++++--- .../ai_scribe_suggestion_success_actions.dart | 60 +++++++++++-------- .../ai_scribe_suggestion_success_toolbar.dart | 4 +- .../ai/data/service/prompt_service_test.dart | 55 ++++------------- .../ai/domain/model/prompt_data_test.dart | 16 ++++- 29 files changed, 223 insertions(+), 189 deletions(-) rename scribe/lib/scribe/ai/{data => domain}/model/ai_message.dart (100%) diff --git a/core/lib/utils/string_convert.dart b/core/lib/utils/string_convert.dart index 51d9700b5..158c7d0fb 100644 --- a/core/lib/utils/string_convert.dart +++ b/core/lib/utils/string_convert.dart @@ -258,8 +258,8 @@ class StringConvert { } static String convertTextContentToHtmlContent(String textContent) { - final htmlContent = textContent.replaceAll('\n', '
'); - + final escapedText = escapeTextContent(textContent); + final htmlContent = escapedText.replaceAll('\n', '
'); return '
$htmlContent
'; } } \ No newline at end of file diff --git a/lib/features/composer/presentation/controller/rich_text_mobile_tablet_controller.dart b/lib/features/composer/presentation/controller/rich_text_mobile_tablet_controller.dart index 37d764d2c..b70c718e0 100644 --- a/lib/features/composer/presentation/controller/rich_text_mobile_tablet_controller.dart +++ b/lib/features/composer/presentation/controller/rich_text_mobile_tablet_controller.dart @@ -23,7 +23,10 @@ class RichTextMobileTabletController extends GetxController { try { await htmlEditorApi?.webViewController.evaluateJavascript(source: ''' (() => { - document.getElementById('editor').focus(); + const editor = document.getElementById('editor'); + if (editor && typeof editor.focus === 'function') { + editor.focus(); + } })();'''); } catch (e) { logWarning('RichTextMobileTabletController::focus:Exception: $e'); 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 2c817cf01..c4c2377cc 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 @@ -100,17 +100,17 @@ extension HandleAiScribeInComposerExtension on ComposerController { HtmlUtils.saveSelection.name, hasReturnValue: true, ); - return result; + return result?.toString() ?? ''; } else { final result = await richTextMobileTabletController?.htmlEditorApi?.webViewController .evaluateJavascript( source: HtmlUtils.saveSelection.script, ); - return result; + return result?.toString() ?? ''; } } catch (e) { logWarning('$runtimeType::saveSelection:Exception = $e'); - return ""; + return ''; } } @@ -121,17 +121,17 @@ extension HandleAiScribeInComposerExtension on ComposerController { HtmlUtils.restoreSelection.name, hasReturnValue: true, ); - return result; + return result?.toString() ?? ''; } else { final result = await richTextMobileTabletController?.htmlEditorApi?.webViewController .evaluateJavascript( source: HtmlUtils.restoreSelection.script, ); - return result; + return result?.toString() ?? ''; } } catch (e) { logWarning('$runtimeType::restoreSelection:Exception = $e'); - return ""; + return ''; } } @@ -142,17 +142,17 @@ extension HandleAiScribeInComposerExtension on ComposerController { HtmlUtils.getSavedSelection.name, hasReturnValue: true, ); - return result; + return result?.toString() ?? ''; } else { final result = await richTextMobileTabletController?.htmlEditorApi?.webViewController .evaluateJavascript( source: HtmlUtils.getSavedSelection.script, ); - return result; + return result?.toString() ?? ''; } } catch (e) { logWarning('$runtimeType::getSavedSelection:Exception = $e'); - return ""; + return ''; } } @@ -176,12 +176,16 @@ extension HandleAiScribeInComposerExtension on ComposerController { } Future unfocusEditor() async { - final editorApi = richTextMobileTabletController?.htmlEditorApi; - if (PlatformInfo.isIOS) { - await editorApi?.unfocus(); - } else if (PlatformInfo.isAndroid) { - await editorApi?.hideKeyboard(); - await editorApi?.unfocus(); + try { + final editorApi = richTextMobileTabletController?.htmlEditorApi; + if (PlatformInfo.isIOS) { + await editorApi?.unfocus(); + } else if (PlatformInfo.isAndroid) { + await editorApi?.hideKeyboard(); + await editorApi?.unfocus(); + } + } catch (e) { + logWarning('$runtimeType::unfocusEditor:Exception = $e'); } } @@ -239,7 +243,8 @@ extension HandleAiScribeInComposerExtension on ComposerController { clearFocusRecipients(); clearFocusSubject(); - if (isScribeMobile) { + final scribeMobile = isScribeMobile; + if (scribeMobile) { await saveAndUnfocusForModal(); } @@ -254,7 +259,7 @@ extension HandleAiScribeInComposerExtension on ComposerController { preferredPlacement: ModalPlacement.top, crossAxisAlignment: ModalCrossAxisAlignment.start, onSelectAiScribeSuggestionAction: handleAiScribeSuggestionAction, - isScribeMobile: isScribeMobile, + isScribeMobile: scribeMobile, ); } diff --git a/lib/features/mailbox_dashboard/domain/state/get_linagora_ecosystem_state.dart b/lib/features/mailbox_dashboard/domain/state/get_linagora_ecosystem_state.dart index eb5a039f5..27736c1a0 100644 --- a/lib/features/mailbox_dashboard/domain/state/get_linagora_ecosystem_state.dart +++ b/lib/features/mailbox_dashboard/domain/state/get_linagora_ecosystem_state.dart @@ -12,7 +12,7 @@ class GetLinagoraEcosystemSuccess extends Success { } class GetLinagoraEcosystemFailure extends Failure { - final dynamic exception; + final Object exception; GetLinagoraEcosystemFailure(this.exception); 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 e0d83c39e..5608f3ab0 100644 --- a/lib/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller.dart +++ b/lib/features/mailbox_dashboard/presentation/controller/mailbox_dashboard_controller.dart @@ -3470,6 +3470,7 @@ class MailboxDashBoardController extends ReloadableController twakeAppManager.setHasComposer(false); paywallController?.onClose(); paywallController = null; + cachedLinagoraEcosystem = null; _disposeWorkerObxVariables(); super.onClose(); } diff --git a/lib/features/mailbox_dashboard/presentation/extensions/ai_scribe/setup_scribe_prompt_url_extension.dart b/lib/features/mailbox_dashboard/presentation/extensions/ai_scribe/setup_scribe_prompt_url_extension.dart index 4221c9f63..16e055966 100644 --- a/lib/features/mailbox_dashboard/presentation/extensions/ai_scribe/setup_scribe_prompt_url_extension.dart +++ b/lib/features/mailbox_dashboard/presentation/extensions/ai_scribe/setup_scribe_prompt_url_extension.dart @@ -20,10 +20,10 @@ extension SetupScribePromptUrlExtension on MailboxDashBoardController { if (baseUrl != null && baseUrl.isNotEmpty) { consumeState(interactor.execute(baseUrl)); } else { - logError('SetupScribePromptUrlExtension::loadLinagoraEcosystem: jmapUrl is null or empty'); + logWarning('SetupScribePromptUrlExtension::loadLinagoraEcosystem: jmapUrl is null or empty'); } } else { - logError('SetupScribePromptUrlExtension::loadLinagoraEcosystem: GetLinagoraEcosystemInteractor not found'); + logWarning('SetupScribePromptUrlExtension::loadLinagoraEcosystem: GetLinagoraEcosystemInteractor not found'); } } @@ -33,7 +33,8 @@ extension SetupScribePromptUrlExtension on MailboxDashBoardController { } void handleGetLinagoraEcosystemFailure(GetLinagoraEcosystemFailure failure) { - logError('SetupScribePromptUrlExtension::handleGetLinagoraEcosystemFailure: GetScribePromptUrl failed - ${failure.exception}'); + logWarning('SetupScribePromptUrlExtension::handleGetLinagoraEcosystemFailure: GetScribePromptUrl failed - ${failure.exception}'); + cachedLinagoraEcosystem = null; _applyScribePromptUrl(null); } diff --git a/model/lib/extensions/session_extension.dart b/model/lib/extensions/session_extension.dart index 1617f8287..7cf3a831b 100644 --- a/model/lib/extensions/session_extension.dart +++ b/model/lib/extensions/session_extension.dart @@ -177,7 +177,7 @@ extension SessionExtension on Session { try { return personalAccount.accountId; } catch (e) { - logError('SessionExtension::safeAccountId:Exception: $e'); + logWarning('SessionExtension::safeAccountId:Exception: $e'); return null; } } diff --git a/scribe/lib/scribe/ai/data/datasource/ai_datasource.dart b/scribe/lib/scribe/ai/data/datasource/ai_datasource.dart index ef60dc6ab..4284b1279 100644 --- a/scribe/lib/scribe/ai/data/datasource/ai_datasource.dart +++ b/scribe/lib/scribe/ai/data/datasource/ai_datasource.dart @@ -1,4 +1,4 @@ -import 'package:scribe/scribe/ai/data/model/ai_message.dart'; +import 'package:scribe/scribe/ai/domain/model/ai_message.dart'; import 'package:scribe/scribe/ai/domain/model/ai_response.dart'; abstract class AIDataSource { 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 d8fbfa9c6..d679acbcb 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,6 +1,6 @@ import 'package:dio/dio.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/domain/model/ai_message.dart'; import 'package:scribe/scribe/ai/data/network/ai_api.dart'; import 'package:scribe/scribe/ai/domain/model/ai_response.dart'; 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 822f2b2ae..e6e0469d4 100644 --- a/scribe/lib/scribe/ai/data/model/ai_api_request.dart +++ b/scribe/lib/scribe/ai/data/model/ai_api_request.dart @@ -1,4 +1,4 @@ -import 'package:scribe/scribe/ai/data/model/ai_message.dart'; +import 'package:scribe/scribe/ai/domain/model/ai_message.dart'; class AIAPIRequest { final List messages; diff --git a/scribe/lib/scribe/ai/data/network/ai_api.dart b/scribe/lib/scribe/ai/data/network/ai_api.dart index d972e5dfe..a722ad480 100644 --- a/scribe/lib/scribe/ai/data/network/ai_api.dart +++ b/scribe/lib/scribe/ai/data/network/ai_api.dart @@ -1,7 +1,7 @@ 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'; +import 'package:scribe/scribe/ai/domain/model/ai_message.dart'; class AIApi { final DioClient _dioClient; 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 68200d42f..a8d15a7b7 100644 --- a/scribe/lib/scribe/ai/data/repository/ai_repository_impl.dart +++ b/scribe/lib/scribe/ai/data/repository/ai_repository_impl.dart @@ -1,5 +1,5 @@ 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/domain/model/ai_message.dart'; import 'package:scribe/scribe/ai/domain/model/ai_response.dart'; import 'package:scribe/scribe/ai/domain/repository/ai_scribe_repository.dart'; diff --git a/scribe/lib/scribe/ai/data/service/prompt_service.dart b/scribe/lib/scribe/ai/data/service/prompt_service.dart index 91337bf63..4f213618f 100644 --- a/scribe/lib/scribe/ai/data/service/prompt_service.dart +++ b/scribe/lib/scribe/ai/data/service/prompt_service.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'package:core/utils/app_logger.dart'; import 'package:dio/dio.dart'; -import 'package:scribe/scribe/ai/data/model/ai_message.dart'; +import 'package:scribe/scribe/ai/domain/model/ai_message.dart'; import 'package:scribe/scribe/ai/domain/model/prompt_data.dart'; import 'package:flutter/services.dart' show rootBundle; @@ -59,8 +59,9 @@ class PromptService { } Future _fetchPromptsFromUrl(String url) async { - log('PromptService::_fetchPromptsFromUrl: Fetching from $url'); - + final sanitizedUrl = + Uri.tryParse(url)?.replace(queryParameters: {}).toString() ?? url; + log('PromptService::_fetchPromptsFromUrl: Fetching from $sanitizedUrl'); try { final response = await _dio.get(url); final data = response.data; @@ -87,11 +88,11 @@ class PromptService { Future getPromptByName(String name) async { final promptData = await loadPrompts(); - try { - return promptData.prompts.firstWhere((prompt) => prompt.name == name); - } catch (_) { + final prompt = promptData.prompts.where((p) => p.name == name).firstOrNull; + if (prompt == null) { throw Exception('Prompt not found: $name'); } + return prompt; } Future> buildPromptByName(String name, String inputText, {String? task}) async { diff --git a/scribe/lib/scribe/ai/domain/constants/ai_prompts.dart b/scribe/lib/scribe/ai/domain/constants/ai_prompts.dart index 721e69e67..f9524bcf3 100644 --- a/scribe/lib/scribe/ai/domain/constants/ai_prompts.dart +++ b/scribe/lib/scribe/ai/domain/constants/ai_prompts.dart @@ -1,11 +1,19 @@ -import 'package:scribe/scribe/ai/data/model/ai_message.dart'; +import 'package:scribe/scribe/ai/domain/model/ai_message.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/data/service/prompt_service.dart'; import 'package:get/get.dart'; class AIPrompts { - static final PromptService _promptService = Get.find(); + static PromptService? _promptServiceInstance; + + static PromptService get _promptService { + if (!Get.isRegistered()) { + throw StateError( + 'PromptService not registered. Ensure NetworkBindings.dependencies() has been called.'); + } + return _promptServiceInstance ??= Get.find(); + } static Future> buildPrompt(AIAction action, String? text) async { return switch (action) { @@ -23,7 +31,17 @@ class AIPrompts { return await _promptService.buildPromptByName(menuAction.promptId, text); } - static Future> buildCustomPrompt(String customPrompt, String? text) async { - return await _promptService.buildPromptByName(CustomPromptAction.promptId, text ?? '', task: customPrompt); + static Future> buildCustomPrompt( + String customPrompt, + String? text, + ) async { + if (customPrompt.trim().isEmpty) { + throw ArgumentError('Custom prompt cannot be empty'); + } + return await _promptService.buildPromptByName( + CustomPromptAction.promptId, + text ?? '', + task: customPrompt, + ); } } diff --git a/scribe/lib/scribe/ai/data/model/ai_message.dart b/scribe/lib/scribe/ai/domain/model/ai_message.dart similarity index 100% rename from scribe/lib/scribe/ai/data/model/ai_message.dart rename to scribe/lib/scribe/ai/domain/model/ai_message.dart diff --git a/scribe/lib/scribe/ai/domain/model/prompt_data.dart b/scribe/lib/scribe/ai/domain/model/prompt_data.dart index 8b54a70d8..9b0205485 100644 --- a/scribe/lib/scribe/ai/domain/model/prompt_data.dart +++ b/scribe/lib/scribe/ai/domain/model/prompt_data.dart @@ -1,4 +1,4 @@ -import 'package:scribe/scribe/ai/data/model/ai_message.dart'; +import 'package:scribe/scribe/ai/domain/model/ai_message.dart'; class PromptData { final List prompts; @@ -9,13 +9,13 @@ class PromptData { factory PromptData.fromJson(Map json) { final promptsJson = json['prompts'] as List?; - + return PromptData( prompts: promptsJson ?.whereType>() - .map(Prompt.fromJson) + .map(Prompt.fromJson) .toList() ?? - const [], + const [], ); } } @@ -34,15 +34,15 @@ class Prompt { if (name is! String) { throw const FormatException('Prompt name must be a non-null String'); } - + final messagesJson = json['messages'] as List?; - + return Prompt( name: name, messages: messagesJson ?.whereType>() .map(AIMessage.fromJson) - .toList() ?? + .toList() ?? const [], ); } @@ -50,20 +50,18 @@ class Prompt { List buildPrompt(String inputText, {String? task}) { return [ for (final message in messages) - if (message.role == AIRole.system) - AIMessage.ofSystem(message.content) - else if (message.role == AIRole.user) - AIMessage.ofUser(_replacePlaceholders(message.content, inputText, task)) + switch (message.role) { + AIRole.system => AIMessage.ofSystem(message.content), + AIRole.user => AIMessage.ofUser( + _replacePlaceholders(message.content, inputText, task), + ), + } ]; } String _replacePlaceholders(String content, String inputText, String? task) { - var result = content.replaceAll('{{input}}', inputText); - - if (task != null) { - result = result.replaceAll('{{task}}', task); - } - + var result = content.replaceAll(RegExp(r'\{\{\s*input\s*\}\}'), inputText); + result = result.replaceAll(RegExp(r'\{\{\s*task\s*\}\}'), task ?? ''); return result; } -} \ No newline at end of file +} 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 3f603a932..6b96d0f4d 100644 --- a/scribe/lib/scribe/ai/domain/repository/ai_scribe_repository.dart +++ b/scribe/lib/scribe/ai/domain/repository/ai_scribe_repository.dart @@ -1,4 +1,4 @@ -import 'package:scribe/scribe/ai/data/model/ai_message.dart'; +import 'package:scribe/scribe/ai/domain/model/ai_message.dart'; import 'package:scribe/scribe/ai/domain/model/ai_response.dart'; abstract class AIScribeRepository { 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 962134bb3..42aaeb79a 100644 --- a/scribe/lib/scribe/ai/presentation/styles/ai_scribe_styles.dart +++ b/scribe/lib/scribe/ai/presentation/styles/ai_scribe_styles.dart @@ -120,13 +120,7 @@ abstract final class AIScribeTextStyles { color: Colors.black.withValues(alpha: 0.85), ); - static final TextStyle contentCard = - ThemeUtils.textStyleInter400.copyWith( - fontSize: 14, - height: 22 / 14, - letterSpacing: 0.4, - color: Colors.black.withValues(alpha: 0.85), - ); + static final TextStyle contentCard = suggestionContent; static final TextStyle mainActionButton = ThemeUtils.textStyleInter500().copyWith( @@ -187,7 +181,7 @@ abstract final class AIScribeSizes { static const double scribeIcon = 12; static const double scribeMobileIcon = 16; static const double aiAssistantIcon = 24; - static const double bottomsheetIcon = 20; + static const double bottomSheetIcon = 20; // Button sizes static const double minButtonWidth = 72; diff --git a/scribe/lib/scribe/ai/presentation/utils/ai_scribe_mobile_utils.dart b/scribe/lib/scribe/ai/presentation/utils/ai_scribe_mobile_utils.dart index 4dea2489a..4e8bade6e 100644 --- a/scribe/lib/scribe/ai/presentation/utils/ai_scribe_mobile_utils.dart +++ b/scribe/lib/scribe/ai/presentation/utils/ai_scribe_mobile_utils.dart @@ -4,6 +4,9 @@ import 'package:get/get.dart'; class AiScribeMobileUtils { static bool isScribeInMobileMode(BuildContext? context) { - return context != null && (Get.find().isMobile(context) || Get.find().isLandscapeMobile(context)); + if (context == null) return false; + final responsiveUtils = Get.find(); + return responsiveUtils.isMobile(context) || + responsiveUtils.isLandscapeMobile(context); } -} \ No newline at end of file +} diff --git a/scribe/lib/scribe/ai/presentation/utils/modal/ai_scribe_suggestion_state_mixin.dart b/scribe/lib/scribe/ai/presentation/utils/modal/ai_scribe_suggestion_state_mixin.dart index 882daf35a..f128f5fe8 100644 --- a/scribe/lib/scribe/ai/presentation/utils/modal/ai_scribe_suggestion_state_mixin.dart +++ b/scribe/lib/scribe/ai/presentation/utils/modal/ai_scribe_suggestion_state_mixin.dart @@ -22,14 +22,20 @@ mixin AiScribeSuggestionStateMixin on State { ImagePaths get imagePaths; OnSelectAiScribeSuggestionAction get onSelectAction; + late AIAction _currentAiAction; + String? _currentContent; + int _requestId = 0; + @override void initState() { super.initState(); + _currentAiAction = aiAction; + _currentContent = content; if (!Get.isRegistered()) { _suggestionState.value = dartz.Left( GenerateAITextFailure( - GenerateAITextInteractorIsNotRegisteredException(), + const GenerateAITextInteractorIsNotRegisteredException(), ), ); return; @@ -40,26 +46,27 @@ mixin AiScribeSuggestionStateMixin on State { } Future loadSuggestion([AIAction? newAiAction, String? newContent]) async { - final aiActionToSend = newAiAction ?? aiAction; - final contentToSend = newContent ?? content; + _currentAiAction = newAiAction ?? _currentAiAction; + _currentContent = newContent ?? _currentContent; + final requestId = ++_requestId; _suggestionState.value = dartz.Right(GenerateAITextLoading()); if (_interactor == null) { _suggestionState.value = dartz.Left( GenerateAITextFailure( - GenerateAITextInteractorIsNotRegisteredException(), + const GenerateAITextInteractorIsNotRegisteredException(), ), ); return; } final result = await _interactor!.execute( - aiActionToSend, - contentToSend, + _currentAiAction, + _currentContent, ); - if (!mounted) return; + if (!mounted || requestId != _requestId) return; result.fold( (failure) => _suggestionState.value = dartz.Left(failure), @@ -77,7 +84,7 @@ mixin AiScribeSuggestionStateMixin on State { (failure) => buildErrorState(), (value) { if (value is GenerateAITextSuccess) { - final hasContent = content?.trim().isNotEmpty == true; + final hasContent = _currentContent?.trim().isNotEmpty == true; return buildSuccessState( value.response.result, 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 index 7497a4ae3..4ee8d9bfa 100644 --- 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 @@ -1,6 +1,5 @@ import 'package:core/presentation/resources/image_paths.dart'; import 'package:core/presentation/views/button/tmail_button_widget.dart'; -import 'package:core/utils/platform_info.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:scribe/scribe.dart'; @@ -21,7 +20,10 @@ class InlineAiAssistButton extends StatelessWidget { @override Widget build(BuildContext context) { - final iconSize = PlatformInfo.isWeb ? AIScribeSizes.scribeIcon : AIScribeSizes.scribeMobileIcon; + final isScribeMobile = AiScribeMobileUtils.isScribeInMobileMode(context); + final iconSize = isScribeMobile + ? AIScribeSizes.scribeMobileIcon + : AIScribeSizes.scribeIcon; return TMailButtonWidget.fromIcon( icon: imagePaths.icSparkle, @@ -31,11 +33,14 @@ class InlineAiAssistButton extends StatelessWidget { iconColor: AIScribeColors.scribeIcon, borderRadius: AIScribeSizes.scribeButtonRadius, boxShadow: AIScribeShadows.sparkleIcon, - onTapActionCallback: () => _onTapActionCallback(context), + onTapActionCallback: () => _onTapActionCallback(context, isScribeMobile), ); } - Future _onTapActionCallback(BuildContext context) async { + Future _onTapActionCallback( + BuildContext context, + bool isScribeMobile, + ) async { final renderBox = context.findRenderObject(); Offset? position; @@ -46,8 +51,6 @@ class InlineAiAssistButton extends StatelessWidget { size = renderBox.size; } - final isScribeMobile = AiScribeMobileUtils.isScribeInMobileMode(context); - await onTapFallback?.call(); await AiScribeModalManager.showAIScribeModal( diff --git a/scribe/lib/scribe/ai/presentation/widgets/mobile/ai_scribe_mobile_actions_bottom_sheet.dart b/scribe/lib/scribe/ai/presentation/widgets/mobile/ai_scribe_mobile_actions_bottom_sheet.dart index 4eb7598bc..18663f64f 100644 --- a/scribe/lib/scribe/ai/presentation/widgets/mobile/ai_scribe_mobile_actions_bottom_sheet.dart +++ b/scribe/lib/scribe/ai/presentation/widgets/mobile/ai_scribe_mobile_actions_bottom_sheet.dart @@ -58,7 +58,7 @@ class _AiScribeMobileActionsBottomSheetState ? TMailButtonWidget.fromIcon( icon: widget.imagePaths.icArrowBackIos, backgroundColor: Colors.transparent, - iconSize: AIScribeSizes.bottomsheetIcon, + iconSize: AIScribeSizes.bottomSheetIcon, iconColor: AIScribeColors.secondaryIcon, padding: AIScribeSizes.backIconPadding, onTapActionCallback: _goBackToCategories @@ -80,7 +80,7 @@ class _AiScribeMobileActionsBottomSheetState TMailButtonWidget.fromIcon( icon: widget.imagePaths.icCloseDialog, backgroundColor: Colors.transparent, - iconSize: AIScribeSizes.bottomsheetIcon, + iconSize: AIScribeSizes.bottomSheetIcon, iconColor: AIScribeColors.secondaryIcon, onTapActionCallback: () => Navigator.of(context).pop() ) @@ -126,13 +126,7 @@ class _AiScribeMobileActionsBottomSheetState ); } - Widget _buildTextCard(BuildContext context) { - final displayText = widget.content; - - if (displayText == null || displayText.isEmpty) { - return const SizedBox.shrink(); - } - + Widget _buildTextCard(String displayText) { return Container( margin: AIScribeSizes.contentCardMargin, constraints: const BoxConstraints( @@ -205,14 +199,14 @@ class _AiScribeMobileActionsBottomSheetState crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _buildHeader(context, localizations), - Expanded( - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _buildTextCard(context), - if(hasContent) + if (hasContent) + Expanded( + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildTextCard(widget.content ?? ''), ValueListenableBuilder( valueListenable: _selectedCategory, builder: (context, selectedCategory, _) { @@ -221,10 +215,10 @@ class _AiScribeMobileActionsBottomSheetState : _buildSubmenuListView(); }, ), - ], + ], + ), ), ), - ), _buildBottomBar(context) ], ), diff --git a/scribe/lib/scribe/ai/presentation/widgets/mobile/ai_scribe_mobile_actions_item.dart b/scribe/lib/scribe/ai/presentation/widgets/mobile/ai_scribe_mobile_actions_item.dart index 2579f32c4..8ac9db2f8 100644 --- a/scribe/lib/scribe/ai/presentation/widgets/mobile/ai_scribe_mobile_actions_item.dart +++ b/scribe/lib/scribe/ai/presentation/widgets/mobile/ai_scribe_mobile_actions_item.dart @@ -5,14 +5,14 @@ import 'package:scribe/scribe.dart'; class AiScribeMobileActionsItem extends StatelessWidget { final AiScribeContextMenuAction menuAction; final ImagePaths imagePaths; - final ValueChanged? onCategorySelected; + final ValueChanged onCategorySelected; final ValueChanged onActionSelected; const AiScribeMobileActionsItem({ super.key, required this.menuAction, required this.imagePaths, - this.onCategorySelected, + required this.onCategorySelected, required this.onActionSelected, }); @@ -23,11 +23,11 @@ class AiScribeMobileActionsItem extends StatelessWidget { return AiScribeMenuItem( menuAction: menuAction, imagePaths: imagePaths, - onSelectAction: (menuAction) { - if (menuAction is AiScribeCategoryContextMenuAction) { - onCategorySelected?.call(menuAction); + onSelectAction: (selectedAction) { + if (selectedAction is AiScribeCategoryContextMenuAction) { + onCategorySelected.call(selectedAction); } else { - onActionSelected.call(menuAction); + onActionSelected.call(selectedAction); } } ); 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 index c51d98ca1..be8d787ca 100644 --- 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 @@ -83,11 +83,17 @@ class AiScribeModalWidget extends StatelessWidget { // in tablet mode where we can encounter keyboard and modal we have issues // with calculating the modal height and the search bar is frequently behind the keyboard // that's why we take more space here - final searchBarHeight = keyboardHeight > 0 ? AIScribeSizes.searchBarMaxHeight : AIScribeSizes.searchBarMinHeight; + final searchBarHeight = showCustomPromptBar + ? (keyboardHeight > 0 + ? AIScribeSizes.searchBarMaxHeight + : AIScribeSizes.searchBarMinHeight) + : 0.0; + final contentSpacing = + hasContent && showCustomPromptBar ? AIScribeSizes.fieldSpacing : 0.0; final maxHeightModal = hasContent ? searchBarHeight + - AIScribeSizes.fieldSpacing + + contentSpacing + min(menuActions.length * AIScribeSizes.menuItemHeight, AIScribeSizes.submenuMaxHeight) : searchBarHeight; 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 56f5f7284..d6ac73e25 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 @@ -74,9 +74,12 @@ class _AiScribeSuggestionWidgetState extends State AIScribeSizes.suggestionModalMaxWidth, ); - final modalMaxHeight = min( - availableHeight * AIScribeSizes.mobileFactor, - AIScribeSizes.suggestionModalMaxHeight, + final modalMaxHeight = max( + AIScribeSizes.suggestionModalMinHeight, + min( + availableHeight * AIScribeSizes.mobileFactor, + AIScribeSizes.suggestionModalMaxHeight, + ), ); final dialogContent = _buildDialogContent(context); @@ -148,9 +151,12 @@ class _AiScribeSuggestionWidgetState extends State top = anchorPos.dy; bottom = null; - height = min( - layout.availableHeight, - screenSize.height - anchorPos.dy - anchorSize.height - _defaultPadding, + height = max( + AIScribeSizes.suggestionModalMinHeight, + min( + layout.availableHeight, + screenSize.height - anchorPos.dy - anchorSize.height - _defaultPadding, + ), ); width = min( @@ -163,7 +169,10 @@ class _AiScribeSuggestionWidgetState extends State // Layout bottom doesn't account for keyboard in calculation usually, so we add it back bottom = layout.bottom + keyboardHeightWithSpacing; - height = layout.availableHeight; + height = max( + AIScribeSizes.suggestionModalMinHeight, + layout.availableHeight, + ); width = modalWidth; } diff --git a/scribe/lib/scribe/ai/presentation/widgets/modal/suggestion/ai_scribe_suggestion_success_actions.dart b/scribe/lib/scribe/ai/presentation/widgets/modal/suggestion/ai_scribe_suggestion_success_actions.dart index 727cee7fd..4d1b4cd13 100644 --- a/scribe/lib/scribe/ai/presentation/widgets/modal/suggestion/ai_scribe_suggestion_success_actions.dart +++ b/scribe/lib/scribe/ai/presentation/widgets/modal/suggestion/ai_scribe_suggestion_success_actions.dart @@ -46,19 +46,39 @@ class AiScribeSuggestionSuccessActions extends StatelessWidget { Widget _buildReplaceButton(BuildContext context) { final localizations = ScribeLocalizations.of(context); + return _buildActionButton( + context: context, + label: AiScribeSuggestionActions.replace.getLabel(localizations), + textColor: AppColor.primaryMain, + action: AiScribeSuggestionActions.replace, + ); + } + + Widget _buildActionButton({ + required BuildContext context, + required String label, + Color? backgroundColor, + required Color textColor, + required AiScribeSuggestionActions action, + }) { + final isMobileScribe = AiScribeMobileUtils.isScribeInMobileMode(context); return Flexible( child: Container( - constraints: const BoxConstraints(minWidth: AIScribeSizes.minButtonWidth), - height: AIScribeSizes.buttonHeight, + constraints: BoxConstraints( + minWidth: isMobileScribe + ? AIScribeSizes.minButtonMobileWidth + : AIScribeSizes.minButtonWidth, + ), + height: isMobileScribe + ? AIScribeSizes.buttonMobileHeight + : AIScribeSizes.buttonHeight, child: ConfirmDialogButton( - label: AiScribeSuggestionActions.replace.getLabel(localizations), - textColor: AppColor.primaryMain, + label: label, + backgroundColor: backgroundColor, + textColor: textColor, onTapAction: () { Navigator.of(context).pop(); - onSelectAction( - AiScribeSuggestionActions.replace, - suggestionText, - ); + onSelectAction(action, suggestionText); }, ), ), @@ -67,24 +87,12 @@ class AiScribeSuggestionSuccessActions extends StatelessWidget { Widget _buildInsertButton(BuildContext context) { final localizations = ScribeLocalizations.of(context); - final isMobileScribe = AiScribeMobileUtils.isScribeInMobileMode(context); - return Flexible( - child: Container( - constraints: BoxConstraints(minWidth: isMobileScribe ? AIScribeSizes.minButtonMobileWidth : AIScribeSizes.minButtonWidth), - height: isMobileScribe ? AIScribeSizes.buttonMobileHeight : AIScribeSizes.buttonHeight, - child: ConfirmDialogButton( - label: AiScribeSuggestionActions.insert.getLabel(localizations), - backgroundColor: AppColor.primaryMain, - textColor: Colors.white, - onTapAction: () { - Navigator.of(context).pop(); - onSelectAction( - AiScribeSuggestionActions.insert, - suggestionText, - ); - }, - ), - ), + return _buildActionButton( + context: context, + label: AiScribeSuggestionActions.insert.getLabel(localizations), + backgroundColor: AppColor.primaryMain, + textColor: Colors.white, + action: AiScribeSuggestionActions.insert, ); } } \ No newline at end of file diff --git a/scribe/lib/scribe/ai/presentation/widgets/modal/suggestion/ai_scribe_suggestion_success_toolbar.dart b/scribe/lib/scribe/ai/presentation/widgets/modal/suggestion/ai_scribe_suggestion_success_toolbar.dart index c30c8f8af..0380261e2 100644 --- a/scribe/lib/scribe/ai/presentation/widgets/modal/suggestion/ai_scribe_suggestion_success_toolbar.dart +++ b/scribe/lib/scribe/ai/presentation/widgets/modal/suggestion/ai_scribe_suggestion_success_toolbar.dart @@ -20,7 +20,7 @@ class AiScribeSuggestionSuccessToolbar extends StatelessWidget { @override Widget build(BuildContext context) { - final appToast = Get.find(); + final appToast = Get.isRegistered() ? Get.find() : null; return Row( mainAxisAlignment: MainAxisAlignment.start, @@ -33,7 +33,7 @@ class AiScribeSuggestionSuccessToolbar extends StatelessWidget { tooltipMessage: ScribeLocalizations.of(context).copy, onTapActionCallback: () { Clipboard.setData(ClipboardData(text: suggestionText)); - appToast.showToastSuccessMessage( + appToast?.showToastSuccessMessage( context, ScribeLocalizations.of(context).copiedToClipboard, ); diff --git a/scribe/test/scribe/ai/data/service/prompt_service_test.dart b/scribe/test/scribe/ai/data/service/prompt_service_test.dart index 9e236500b..b70cde113 100644 --- a/scribe/test/scribe/ai/data/service/prompt_service_test.dart +++ b/scribe/test/scribe/ai/data/service/prompt_service_test.dart @@ -1,10 +1,9 @@ import 'dart:typed_data'; -import 'package:flutter_test/flutter_test.dart'; import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; import 'package:scribe/scribe/ai/data/service/prompt_service.dart'; -import 'package:scribe/scribe/ai/domain/model/prompt_data.dart'; -import 'package:scribe/scribe/ai/data/model/ai_message.dart'; +import 'package:scribe/scribe/ai/domain/model/ai_message.dart'; class _ThrowingAdapter implements HttpClientAdapter { @override @@ -59,51 +58,21 @@ void main() { }); group('PromptService getPromptByName', () { - test('getPromptByName should return correct prompt from data', () async { - // Arrange - final promptData = PromptData( - prompts: [ - Prompt( - name: 'test-prompt', - messages: [ - const AIMessage(role: AIRole.system, content: 'System message'), - const AIMessage(role: AIRole.user, content: 'User message with {{input}}') - ] - ) - ] - ); + test('getPromptByName should return correct prompt from assets', () async { + final service = PromptService(_throwingDio()); - // Act - final prompt = promptData.prompts.firstWhere( - (prompt) => prompt.name == 'test-prompt', - orElse: () => throw Exception('Prompt not found: test-prompt'), - ); + final prompt = await service.getPromptByName('change-tone-casual'); - // Assert - expect(prompt.name, 'test-prompt'); - expect(prompt.messages.length, 2); + expect(prompt.name, 'change-tone-casual'); + expect(prompt.messages.length, greaterThan(0)); }); - test('getPromptByName should throw exception for non-existent prompt', () async { - // Arrange - final promptData = PromptData( - prompts: [ - Prompt( - name: 'test-prompt', - messages: [ - const AIMessage(role: AIRole.system, content: 'System message'), - const AIMessage(role: AIRole.user, content: 'User message') - ] - ) - ] - ); + test('getPromptByName should throw exception for non-existent prompt', + () async { + final service = PromptService(_throwingDio()); - // Act & Assert - expect( - () => promptData.prompts.firstWhere( - (prompt) => prompt.name == 'non-existent-prompt', - orElse: () => throw Exception('Prompt not found: non-existent-prompt'), - ), + await expectLater( + service.getPromptByName('non-existent-prompt'), throwsException, ); }); diff --git a/scribe/test/scribe/ai/domain/model/prompt_data_test.dart b/scribe/test/scribe/ai/domain/model/prompt_data_test.dart index 25e9846e3..081ec7584 100644 --- a/scribe/test/scribe/ai/domain/model/prompt_data_test.dart +++ b/scribe/test/scribe/ai/domain/model/prompt_data_test.dart @@ -1,6 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:scribe/scribe/ai/domain/model/prompt_data.dart'; -import 'package:scribe/scribe/ai/data/model/ai_message.dart'; +import 'package:scribe/scribe/ai/domain/model/ai_message.dart'; void main() { group('PromptData', () { @@ -163,5 +163,19 @@ void main() { expect(result.length, 2); expect(result.last.content, 'User message without placeholders'); }); + + test('fromJson should throw FormatException when name is missing', () { + final jsonData = {"messages": []}; + + expect(() => Prompt.fromJson(jsonData), throwsA(isA())); + }); + + test('fromJson should handle missing prompts key', () { + final jsonData = {}; + + final promptData = PromptData.fromJson(jsonData); + + expect(promptData.prompts, isEmpty); + }); }); } \ No newline at end of file