TF-4378 Migrate scribe-mobile branch to master branch

This commit is contained in:
dab246
2026-03-16 11:45:14 +07:00
committed by Dat H. Pham
parent d0cc14beb4
commit 28d5036667
29 changed files with 223 additions and 189 deletions
+2 -2
View File
@@ -258,8 +258,8 @@ class StringConvert {
} }
static String convertTextContentToHtmlContent(String textContent) { static String convertTextContentToHtmlContent(String textContent) {
final htmlContent = textContent.replaceAll('\n', '<br>'); final escapedText = escapeTextContent(textContent);
final htmlContent = escapedText.replaceAll('\n', '<br>');
return '<div>$htmlContent</div>'; return '<div>$htmlContent</div>';
} }
} }
@@ -23,7 +23,10 @@ class RichTextMobileTabletController extends GetxController {
try { try {
await htmlEditorApi?.webViewController.evaluateJavascript(source: ''' await htmlEditorApi?.webViewController.evaluateJavascript(source: '''
(() => { (() => {
document.getElementById('editor').focus(); const editor = document.getElementById('editor');
if (editor && typeof editor.focus === 'function') {
editor.focus();
}
})();'''); })();''');
} catch (e) { } catch (e) {
logWarning('RichTextMobileTabletController::focus:Exception: $e'); logWarning('RichTextMobileTabletController::focus:Exception: $e');
@@ -100,17 +100,17 @@ extension HandleAiScribeInComposerExtension on ComposerController {
HtmlUtils.saveSelection.name, HtmlUtils.saveSelection.name,
hasReturnValue: true, hasReturnValue: true,
); );
return result; return result?.toString() ?? '';
} else { } else {
final result = await richTextMobileTabletController?.htmlEditorApi?.webViewController final result = await richTextMobileTabletController?.htmlEditorApi?.webViewController
.evaluateJavascript( .evaluateJavascript(
source: HtmlUtils.saveSelection.script, source: HtmlUtils.saveSelection.script,
); );
return result; return result?.toString() ?? '';
} }
} catch (e) { } catch (e) {
logWarning('$runtimeType::saveSelection:Exception = $e'); logWarning('$runtimeType::saveSelection:Exception = $e');
return ""; return '';
} }
} }
@@ -121,17 +121,17 @@ extension HandleAiScribeInComposerExtension on ComposerController {
HtmlUtils.restoreSelection.name, HtmlUtils.restoreSelection.name,
hasReturnValue: true, hasReturnValue: true,
); );
return result; return result?.toString() ?? '';
} else { } else {
final result = await richTextMobileTabletController?.htmlEditorApi?.webViewController final result = await richTextMobileTabletController?.htmlEditorApi?.webViewController
.evaluateJavascript( .evaluateJavascript(
source: HtmlUtils.restoreSelection.script, source: HtmlUtils.restoreSelection.script,
); );
return result; return result?.toString() ?? '';
} }
} catch (e) { } catch (e) {
logWarning('$runtimeType::restoreSelection:Exception = $e'); logWarning('$runtimeType::restoreSelection:Exception = $e');
return ""; return '';
} }
} }
@@ -142,17 +142,17 @@ extension HandleAiScribeInComposerExtension on ComposerController {
HtmlUtils.getSavedSelection.name, HtmlUtils.getSavedSelection.name,
hasReturnValue: true, hasReturnValue: true,
); );
return result; return result?.toString() ?? '';
} else { } else {
final result = await richTextMobileTabletController?.htmlEditorApi?.webViewController final result = await richTextMobileTabletController?.htmlEditorApi?.webViewController
.evaluateJavascript( .evaluateJavascript(
source: HtmlUtils.getSavedSelection.script, source: HtmlUtils.getSavedSelection.script,
); );
return result; return result?.toString() ?? '';
} }
} catch (e) { } catch (e) {
logWarning('$runtimeType::getSavedSelection:Exception = $e'); logWarning('$runtimeType::getSavedSelection:Exception = $e');
return ""; return '';
} }
} }
@@ -176,12 +176,16 @@ extension HandleAiScribeInComposerExtension on ComposerController {
} }
Future<void> unfocusEditor() async { Future<void> unfocusEditor() async {
final editorApi = richTextMobileTabletController?.htmlEditorApi; try {
if (PlatformInfo.isIOS) { final editorApi = richTextMobileTabletController?.htmlEditorApi;
await editorApi?.unfocus(); if (PlatformInfo.isIOS) {
} else if (PlatformInfo.isAndroid) { await editorApi?.unfocus();
await editorApi?.hideKeyboard(); } else if (PlatformInfo.isAndroid) {
await editorApi?.unfocus(); await editorApi?.hideKeyboard();
await editorApi?.unfocus();
}
} catch (e) {
logWarning('$runtimeType::unfocusEditor:Exception = $e');
} }
} }
@@ -239,7 +243,8 @@ extension HandleAiScribeInComposerExtension on ComposerController {
clearFocusRecipients(); clearFocusRecipients();
clearFocusSubject(); clearFocusSubject();
if (isScribeMobile) { final scribeMobile = isScribeMobile;
if (scribeMobile) {
await saveAndUnfocusForModal(); await saveAndUnfocusForModal();
} }
@@ -254,7 +259,7 @@ extension HandleAiScribeInComposerExtension on ComposerController {
preferredPlacement: ModalPlacement.top, preferredPlacement: ModalPlacement.top,
crossAxisAlignment: ModalCrossAxisAlignment.start, crossAxisAlignment: ModalCrossAxisAlignment.start,
onSelectAiScribeSuggestionAction: handleAiScribeSuggestionAction, onSelectAiScribeSuggestionAction: handleAiScribeSuggestionAction,
isScribeMobile: isScribeMobile, isScribeMobile: scribeMobile,
); );
} }
@@ -12,7 +12,7 @@ class GetLinagoraEcosystemSuccess extends Success {
} }
class GetLinagoraEcosystemFailure extends Failure { class GetLinagoraEcosystemFailure extends Failure {
final dynamic exception; final Object exception;
GetLinagoraEcosystemFailure(this.exception); GetLinagoraEcosystemFailure(this.exception);
@@ -3470,6 +3470,7 @@ class MailboxDashBoardController extends ReloadableController
twakeAppManager.setHasComposer(false); twakeAppManager.setHasComposer(false);
paywallController?.onClose(); paywallController?.onClose();
paywallController = null; paywallController = null;
cachedLinagoraEcosystem = null;
_disposeWorkerObxVariables(); _disposeWorkerObxVariables();
super.onClose(); super.onClose();
} }
@@ -20,10 +20,10 @@ extension SetupScribePromptUrlExtension on MailboxDashBoardController {
if (baseUrl != null && baseUrl.isNotEmpty) { if (baseUrl != null && baseUrl.isNotEmpty) {
consumeState(interactor.execute(baseUrl)); consumeState(interactor.execute(baseUrl));
} else { } else {
logError('SetupScribePromptUrlExtension::loadLinagoraEcosystem: jmapUrl is null or empty'); logWarning('SetupScribePromptUrlExtension::loadLinagoraEcosystem: jmapUrl is null or empty');
} }
} else { } 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) { void handleGetLinagoraEcosystemFailure(GetLinagoraEcosystemFailure failure) {
logError('SetupScribePromptUrlExtension::handleGetLinagoraEcosystemFailure: GetScribePromptUrl failed - ${failure.exception}'); logWarning('SetupScribePromptUrlExtension::handleGetLinagoraEcosystemFailure: GetScribePromptUrl failed - ${failure.exception}');
cachedLinagoraEcosystem = null;
_applyScribePromptUrl(null); _applyScribePromptUrl(null);
} }
+1 -1
View File
@@ -177,7 +177,7 @@ extension SessionExtension on Session {
try { try {
return personalAccount.accountId; return personalAccount.accountId;
} catch (e) { } catch (e) {
logError('SessionExtension::safeAccountId:Exception: $e'); logWarning('SessionExtension::safeAccountId:Exception: $e');
return null; return null;
} }
} }
@@ -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'; import 'package:scribe/scribe/ai/domain/model/ai_response.dart';
abstract class AIDataSource { abstract class AIDataSource {
@@ -1,6 +1,6 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:scribe/scribe/ai/data/datasource/ai_datasource.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/data/network/ai_api.dart';
import 'package:scribe/scribe/ai/domain/model/ai_response.dart'; import 'package:scribe/scribe/ai/domain/model/ai_response.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 { class AIAPIRequest {
final List<AIMessage> messages; final List<AIMessage> messages;
@@ -1,7 +1,7 @@
import 'package:core/data/network/dio_client.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_request.dart';
import 'package:scribe/scribe/ai/data/model/ai_api_response.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 { class AIApi {
final DioClient _dioClient; final DioClient _dioClient;
@@ -1,5 +1,5 @@
import 'package:scribe/scribe/ai/data/datasource/ai_datasource.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/domain/model/ai_response.dart'; import 'package:scribe/scribe/ai/domain/model/ai_response.dart';
import 'package:scribe/scribe/ai/domain/repository/ai_scribe_repository.dart'; import 'package:scribe/scribe/ai/domain/repository/ai_scribe_repository.dart';
@@ -2,7 +2,7 @@ import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:core/utils/app_logger.dart'; import 'package:core/utils/app_logger.dart';
import 'package:dio/dio.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:scribe/scribe/ai/domain/model/prompt_data.dart';
import 'package:flutter/services.dart' show rootBundle; import 'package:flutter/services.dart' show rootBundle;
@@ -59,8 +59,9 @@ class PromptService {
} }
Future<PromptData> _fetchPromptsFromUrl(String url) async { Future<PromptData> _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 { try {
final response = await _dio.get(url); final response = await _dio.get(url);
final data = response.data; final data = response.data;
@@ -87,11 +88,11 @@ class PromptService {
Future<Prompt> getPromptByName(String name) async { Future<Prompt> getPromptByName(String name) async {
final promptData = await loadPrompts(); final promptData = await loadPrompts();
try { final prompt = promptData.prompts.where((p) => p.name == name).firstOrNull;
return promptData.prompts.firstWhere((prompt) => prompt.name == name); if (prompt == null) {
} catch (_) {
throw Exception('Prompt not found: $name'); throw Exception('Prompt not found: $name');
} }
return prompt;
} }
Future<List<AIMessage>> buildPromptByName(String name, String inputText, {String? task}) async { Future<List<AIMessage>> buildPromptByName(String name, String inputText, {String? task}) async {
@@ -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_action.dart';
import 'package:scribe/scribe/ai/presentation/model/ai_scribe_menu_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:scribe/scribe/ai/data/service/prompt_service.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
class AIPrompts { class AIPrompts {
static final PromptService _promptService = Get.find<PromptService>(); static PromptService? _promptServiceInstance;
static PromptService get _promptService {
if (!Get.isRegistered<PromptService>()) {
throw StateError(
'PromptService not registered. Ensure NetworkBindings.dependencies() has been called.');
}
return _promptServiceInstance ??= Get.find<PromptService>();
}
static Future<List<AIMessage>> buildPrompt(AIAction action, String? text) async { static Future<List<AIMessage>> buildPrompt(AIAction action, String? text) async {
return switch (action) { return switch (action) {
@@ -23,7 +31,17 @@ class AIPrompts {
return await _promptService.buildPromptByName(menuAction.promptId, text); return await _promptService.buildPromptByName(menuAction.promptId, text);
} }
static Future<List<AIMessage>> buildCustomPrompt(String customPrompt, String? text) async { static Future<List<AIMessage>> buildCustomPrompt(
return await _promptService.buildPromptByName(CustomPromptAction.promptId, text ?? '', task: customPrompt); 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,
);
} }
} }
@@ -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 { class PromptData {
final List<Prompt> prompts; final List<Prompt> prompts;
@@ -9,13 +9,13 @@ class PromptData {
factory PromptData.fromJson(Map<String, dynamic> json) { factory PromptData.fromJson(Map<String, dynamic> json) {
final promptsJson = json['prompts'] as List?; final promptsJson = json['prompts'] as List?;
return PromptData( return PromptData(
prompts: promptsJson prompts: promptsJson
?.whereType<Map<String, dynamic>>() ?.whereType<Map<String, dynamic>>()
.map(Prompt.fromJson) .map(Prompt.fromJson)
.toList() ?? .toList() ??
const [], const [],
); );
} }
} }
@@ -34,15 +34,15 @@ class Prompt {
if (name is! String) { if (name is! String) {
throw const FormatException('Prompt name must be a non-null String'); throw const FormatException('Prompt name must be a non-null String');
} }
final messagesJson = json['messages'] as List?; final messagesJson = json['messages'] as List?;
return Prompt( return Prompt(
name: name, name: name,
messages: messagesJson messages: messagesJson
?.whereType<Map<String, dynamic>>() ?.whereType<Map<String, dynamic>>()
.map(AIMessage.fromJson) .map(AIMessage.fromJson)
.toList() ?? .toList() ??
const [], const [],
); );
} }
@@ -50,20 +50,18 @@ class Prompt {
List<AIMessage> buildPrompt(String inputText, {String? task}) { List<AIMessage> buildPrompt(String inputText, {String? task}) {
return [ return [
for (final message in messages) for (final message in messages)
if (message.role == AIRole.system) switch (message.role) {
AIMessage.ofSystem(message.content) AIRole.system => AIMessage.ofSystem(message.content),
else if (message.role == AIRole.user) AIRole.user => AIMessage.ofUser(
AIMessage.ofUser(_replacePlaceholders(message.content, inputText, task)) _replacePlaceholders(message.content, inputText, task),
),
}
]; ];
} }
String _replacePlaceholders(String content, String inputText, String? task) { String _replacePlaceholders(String content, String inputText, String? task) {
var result = content.replaceAll('{{input}}', inputText); var result = content.replaceAll(RegExp(r'\{\{\s*input\s*\}\}'), inputText);
result = result.replaceAll(RegExp(r'\{\{\s*task\s*\}\}'), task ?? '');
if (task != null) {
result = result.replaceAll('{{task}}', task);
}
return result; return result;
} }
} }
@@ -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'; import 'package:scribe/scribe/ai/domain/model/ai_response.dart';
abstract class AIScribeRepository { abstract class AIScribeRepository {
@@ -120,13 +120,7 @@ abstract final class AIScribeTextStyles {
color: Colors.black.withValues(alpha: 0.85), color: Colors.black.withValues(alpha: 0.85),
); );
static final TextStyle contentCard = static final TextStyle contentCard = suggestionContent;
ThemeUtils.textStyleInter400.copyWith(
fontSize: 14,
height: 22 / 14,
letterSpacing: 0.4,
color: Colors.black.withValues(alpha: 0.85),
);
static final TextStyle mainActionButton = static final TextStyle mainActionButton =
ThemeUtils.textStyleInter500().copyWith( ThemeUtils.textStyleInter500().copyWith(
@@ -187,7 +181,7 @@ abstract final class AIScribeSizes {
static const double scribeIcon = 12; static const double scribeIcon = 12;
static const double scribeMobileIcon = 16; static const double scribeMobileIcon = 16;
static const double aiAssistantIcon = 24; static const double aiAssistantIcon = 24;
static const double bottomsheetIcon = 20; static const double bottomSheetIcon = 20;
// Button sizes // Button sizes
static const double minButtonWidth = 72; static const double minButtonWidth = 72;
@@ -4,6 +4,9 @@ import 'package:get/get.dart';
class AiScribeMobileUtils { class AiScribeMobileUtils {
static bool isScribeInMobileMode(BuildContext? context) { static bool isScribeInMobileMode(BuildContext? context) {
return context != null && (Get.find<ResponsiveUtils>().isMobile(context) || Get.find<ResponsiveUtils>().isLandscapeMobile(context)); if (context == null) return false;
final responsiveUtils = Get.find<ResponsiveUtils>();
return responsiveUtils.isMobile(context) ||
responsiveUtils.isLandscapeMobile(context);
} }
} }
@@ -22,14 +22,20 @@ mixin AiScribeSuggestionStateMixin<T extends StatefulWidget> on State<T> {
ImagePaths get imagePaths; ImagePaths get imagePaths;
OnSelectAiScribeSuggestionAction get onSelectAction; OnSelectAiScribeSuggestionAction get onSelectAction;
late AIAction _currentAiAction;
String? _currentContent;
int _requestId = 0;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_currentAiAction = aiAction;
_currentContent = content;
if (!Get.isRegistered<GenerateAITextInteractor>()) { if (!Get.isRegistered<GenerateAITextInteractor>()) {
_suggestionState.value = dartz.Left( _suggestionState.value = dartz.Left(
GenerateAITextFailure( GenerateAITextFailure(
GenerateAITextInteractorIsNotRegisteredException(), const GenerateAITextInteractorIsNotRegisteredException(),
), ),
); );
return; return;
@@ -40,26 +46,27 @@ mixin AiScribeSuggestionStateMixin<T extends StatefulWidget> on State<T> {
} }
Future<void> loadSuggestion([AIAction? newAiAction, String? newContent]) async { Future<void> loadSuggestion([AIAction? newAiAction, String? newContent]) async {
final aiActionToSend = newAiAction ?? aiAction; _currentAiAction = newAiAction ?? _currentAiAction;
final contentToSend = newContent ?? content; _currentContent = newContent ?? _currentContent;
final requestId = ++_requestId;
_suggestionState.value = dartz.Right(GenerateAITextLoading()); _suggestionState.value = dartz.Right(GenerateAITextLoading());
if (_interactor == null) { if (_interactor == null) {
_suggestionState.value = dartz.Left( _suggestionState.value = dartz.Left(
GenerateAITextFailure( GenerateAITextFailure(
GenerateAITextInteractorIsNotRegisteredException(), const GenerateAITextInteractorIsNotRegisteredException(),
), ),
); );
return; return;
} }
final result = await _interactor!.execute( final result = await _interactor!.execute(
aiActionToSend, _currentAiAction,
contentToSend, _currentContent,
); );
if (!mounted) return; if (!mounted || requestId != _requestId) return;
result.fold( result.fold(
(failure) => _suggestionState.value = dartz.Left(failure), (failure) => _suggestionState.value = dartz.Left(failure),
@@ -77,7 +84,7 @@ mixin AiScribeSuggestionStateMixin<T extends StatefulWidget> on State<T> {
(failure) => buildErrorState(), (failure) => buildErrorState(),
(value) { (value) {
if (value is GenerateAITextSuccess) { if (value is GenerateAITextSuccess) {
final hasContent = content?.trim().isNotEmpty == true; final hasContent = _currentContent?.trim().isNotEmpty == true;
return buildSuccessState( return buildSuccessState(
value.response.result, value.response.result,
@@ -1,6 +1,5 @@
import 'package:core/presentation/resources/image_paths.dart'; import 'package:core/presentation/resources/image_paths.dart';
import 'package:core/presentation/views/button/tmail_button_widget.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/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:scribe/scribe.dart'; import 'package:scribe/scribe.dart';
@@ -21,7 +20,10 @@ class InlineAiAssistButton extends StatelessWidget {
@override @override
Widget build(BuildContext context) { 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( return TMailButtonWidget.fromIcon(
icon: imagePaths.icSparkle, icon: imagePaths.icSparkle,
@@ -31,11 +33,14 @@ class InlineAiAssistButton extends StatelessWidget {
iconColor: AIScribeColors.scribeIcon, iconColor: AIScribeColors.scribeIcon,
borderRadius: AIScribeSizes.scribeButtonRadius, borderRadius: AIScribeSizes.scribeButtonRadius,
boxShadow: AIScribeShadows.sparkleIcon, boxShadow: AIScribeShadows.sparkleIcon,
onTapActionCallback: () => _onTapActionCallback(context), onTapActionCallback: () => _onTapActionCallback(context, isScribeMobile),
); );
} }
Future<void> _onTapActionCallback(BuildContext context) async { Future<void> _onTapActionCallback(
BuildContext context,
bool isScribeMobile,
) async {
final renderBox = context.findRenderObject(); final renderBox = context.findRenderObject();
Offset? position; Offset? position;
@@ -46,8 +51,6 @@ class InlineAiAssistButton extends StatelessWidget {
size = renderBox.size; size = renderBox.size;
} }
final isScribeMobile = AiScribeMobileUtils.isScribeInMobileMode(context);
await onTapFallback?.call(); await onTapFallback?.call();
await AiScribeModalManager.showAIScribeModal( await AiScribeModalManager.showAIScribeModal(
@@ -58,7 +58,7 @@ class _AiScribeMobileActionsBottomSheetState
? TMailButtonWidget.fromIcon( ? TMailButtonWidget.fromIcon(
icon: widget.imagePaths.icArrowBackIos, icon: widget.imagePaths.icArrowBackIos,
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
iconSize: AIScribeSizes.bottomsheetIcon, iconSize: AIScribeSizes.bottomSheetIcon,
iconColor: AIScribeColors.secondaryIcon, iconColor: AIScribeColors.secondaryIcon,
padding: AIScribeSizes.backIconPadding, padding: AIScribeSizes.backIconPadding,
onTapActionCallback: _goBackToCategories onTapActionCallback: _goBackToCategories
@@ -80,7 +80,7 @@ class _AiScribeMobileActionsBottomSheetState
TMailButtonWidget.fromIcon( TMailButtonWidget.fromIcon(
icon: widget.imagePaths.icCloseDialog, icon: widget.imagePaths.icCloseDialog,
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
iconSize: AIScribeSizes.bottomsheetIcon, iconSize: AIScribeSizes.bottomSheetIcon,
iconColor: AIScribeColors.secondaryIcon, iconColor: AIScribeColors.secondaryIcon,
onTapActionCallback: () => Navigator.of(context).pop() onTapActionCallback: () => Navigator.of(context).pop()
) )
@@ -126,13 +126,7 @@ class _AiScribeMobileActionsBottomSheetState
); );
} }
Widget _buildTextCard(BuildContext context) { Widget _buildTextCard(String displayText) {
final displayText = widget.content;
if (displayText == null || displayText.isEmpty) {
return const SizedBox.shrink();
}
return Container( return Container(
margin: AIScribeSizes.contentCardMargin, margin: AIScribeSizes.contentCardMargin,
constraints: const BoxConstraints( constraints: const BoxConstraints(
@@ -205,14 +199,14 @@ class _AiScribeMobileActionsBottomSheetState
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
_buildHeader(context, localizations), _buildHeader(context, localizations),
Expanded( if (hasContent)
child: SingleChildScrollView( Expanded(
child: Column( child: SingleChildScrollView(
mainAxisSize: MainAxisSize.min, child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min,
children: [ crossAxisAlignment: CrossAxisAlignment.stretch,
_buildTextCard(context), children: [
if(hasContent) _buildTextCard(widget.content ?? ''),
ValueListenableBuilder<AiScribeCategoryContextMenuAction?>( ValueListenableBuilder<AiScribeCategoryContextMenuAction?>(
valueListenable: _selectedCategory, valueListenable: _selectedCategory,
builder: (context, selectedCategory, _) { builder: (context, selectedCategory, _) {
@@ -221,10 +215,10 @@ class _AiScribeMobileActionsBottomSheetState
: _buildSubmenuListView(); : _buildSubmenuListView();
}, },
), ),
], ],
),
), ),
), ),
),
_buildBottomBar(context) _buildBottomBar(context)
], ],
), ),
@@ -5,14 +5,14 @@ import 'package:scribe/scribe.dart';
class AiScribeMobileActionsItem extends StatelessWidget { class AiScribeMobileActionsItem extends StatelessWidget {
final AiScribeContextMenuAction menuAction; final AiScribeContextMenuAction menuAction;
final ImagePaths imagePaths; final ImagePaths imagePaths;
final ValueChanged<AiScribeCategoryContextMenuAction>? onCategorySelected; final ValueChanged<AiScribeCategoryContextMenuAction> onCategorySelected;
final ValueChanged<AiScribeContextMenuAction> onActionSelected; final ValueChanged<AiScribeContextMenuAction> onActionSelected;
const AiScribeMobileActionsItem({ const AiScribeMobileActionsItem({
super.key, super.key,
required this.menuAction, required this.menuAction,
required this.imagePaths, required this.imagePaths,
this.onCategorySelected, required this.onCategorySelected,
required this.onActionSelected, required this.onActionSelected,
}); });
@@ -23,11 +23,11 @@ class AiScribeMobileActionsItem extends StatelessWidget {
return AiScribeMenuItem( return AiScribeMenuItem(
menuAction: menuAction, menuAction: menuAction,
imagePaths: imagePaths, imagePaths: imagePaths,
onSelectAction: (menuAction) { onSelectAction: (selectedAction) {
if (menuAction is AiScribeCategoryContextMenuAction) { if (selectedAction is AiScribeCategoryContextMenuAction) {
onCategorySelected?.call(menuAction); onCategorySelected.call(selectedAction);
} else { } else {
onActionSelected.call(menuAction); onActionSelected.call(selectedAction);
} }
} }
); );
@@ -83,11 +83,17 @@ class AiScribeModalWidget extends StatelessWidget {
// in tablet mode where we can encounter keyboard and modal we have issues // 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 // with calculating the modal height and the search bar is frequently behind the keyboard
// that's why we take more space here // 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 final maxHeightModal = hasContent
? searchBarHeight + ? searchBarHeight +
AIScribeSizes.fieldSpacing + contentSpacing +
min(menuActions.length * AIScribeSizes.menuItemHeight, min(menuActions.length * AIScribeSizes.menuItemHeight,
AIScribeSizes.submenuMaxHeight) AIScribeSizes.submenuMaxHeight)
: searchBarHeight; : searchBarHeight;
@@ -74,9 +74,12 @@ class _AiScribeSuggestionWidgetState extends State<AiScribeSuggestionWidget>
AIScribeSizes.suggestionModalMaxWidth, AIScribeSizes.suggestionModalMaxWidth,
); );
final modalMaxHeight = min( final modalMaxHeight = max(
availableHeight * AIScribeSizes.mobileFactor, AIScribeSizes.suggestionModalMinHeight,
AIScribeSizes.suggestionModalMaxHeight, min(
availableHeight * AIScribeSizes.mobileFactor,
AIScribeSizes.suggestionModalMaxHeight,
),
); );
final dialogContent = _buildDialogContent(context); final dialogContent = _buildDialogContent(context);
@@ -148,9 +151,12 @@ class _AiScribeSuggestionWidgetState extends State<AiScribeSuggestionWidget>
top = anchorPos.dy; top = anchorPos.dy;
bottom = null; bottom = null;
height = min( height = max(
layout.availableHeight, AIScribeSizes.suggestionModalMinHeight,
screenSize.height - anchorPos.dy - anchorSize.height - _defaultPadding, min(
layout.availableHeight,
screenSize.height - anchorPos.dy - anchorSize.height - _defaultPadding,
),
); );
width = min( width = min(
@@ -163,7 +169,10 @@ class _AiScribeSuggestionWidgetState extends State<AiScribeSuggestionWidget>
// Layout bottom doesn't account for keyboard in calculation usually, so we add it back // Layout bottom doesn't account for keyboard in calculation usually, so we add it back
bottom = layout.bottom + keyboardHeightWithSpacing; bottom = layout.bottom + keyboardHeightWithSpacing;
height = layout.availableHeight; height = max(
AIScribeSizes.suggestionModalMinHeight,
layout.availableHeight,
);
width = modalWidth; width = modalWidth;
} }
@@ -46,19 +46,39 @@ class AiScribeSuggestionSuccessActions extends StatelessWidget {
Widget _buildReplaceButton(BuildContext context) { Widget _buildReplaceButton(BuildContext context) {
final localizations = ScribeLocalizations.of(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( return Flexible(
child: Container( child: Container(
constraints: const BoxConstraints(minWidth: AIScribeSizes.minButtonWidth), constraints: BoxConstraints(
height: AIScribeSizes.buttonHeight, minWidth: isMobileScribe
? AIScribeSizes.minButtonMobileWidth
: AIScribeSizes.minButtonWidth,
),
height: isMobileScribe
? AIScribeSizes.buttonMobileHeight
: AIScribeSizes.buttonHeight,
child: ConfirmDialogButton( child: ConfirmDialogButton(
label: AiScribeSuggestionActions.replace.getLabel(localizations), label: label,
textColor: AppColor.primaryMain, backgroundColor: backgroundColor,
textColor: textColor,
onTapAction: () { onTapAction: () {
Navigator.of(context).pop(); Navigator.of(context).pop();
onSelectAction( onSelectAction(action, suggestionText);
AiScribeSuggestionActions.replace,
suggestionText,
);
}, },
), ),
), ),
@@ -67,24 +87,12 @@ class AiScribeSuggestionSuccessActions extends StatelessWidget {
Widget _buildInsertButton(BuildContext context) { Widget _buildInsertButton(BuildContext context) {
final localizations = ScribeLocalizations.of(context); final localizations = ScribeLocalizations.of(context);
final isMobileScribe = AiScribeMobileUtils.isScribeInMobileMode(context); return _buildActionButton(
return Flexible( context: context,
child: Container( label: AiScribeSuggestionActions.insert.getLabel(localizations),
constraints: BoxConstraints(minWidth: isMobileScribe ? AIScribeSizes.minButtonMobileWidth : AIScribeSizes.minButtonWidth), backgroundColor: AppColor.primaryMain,
height: isMobileScribe ? AIScribeSizes.buttonMobileHeight : AIScribeSizes.buttonHeight, textColor: Colors.white,
child: ConfirmDialogButton( action: AiScribeSuggestionActions.insert,
label: AiScribeSuggestionActions.insert.getLabel(localizations),
backgroundColor: AppColor.primaryMain,
textColor: Colors.white,
onTapAction: () {
Navigator.of(context).pop();
onSelectAction(
AiScribeSuggestionActions.insert,
suggestionText,
);
},
),
),
); );
} }
} }
@@ -20,7 +20,7 @@ class AiScribeSuggestionSuccessToolbar extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final appToast = Get.find<AppToast>(); final appToast = Get.isRegistered<AppToast>() ? Get.find<AppToast>() : null;
return Row( return Row(
mainAxisAlignment: MainAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.start,
@@ -33,7 +33,7 @@ class AiScribeSuggestionSuccessToolbar extends StatelessWidget {
tooltipMessage: ScribeLocalizations.of(context).copy, tooltipMessage: ScribeLocalizations.of(context).copy,
onTapActionCallback: () { onTapActionCallback: () {
Clipboard.setData(ClipboardData(text: suggestionText)); Clipboard.setData(ClipboardData(text: suggestionText));
appToast.showToastSuccessMessage( appToast?.showToastSuccessMessage(
context, context,
ScribeLocalizations.of(context).copiedToClipboard, ScribeLocalizations.of(context).copiedToClipboard,
); );
@@ -1,10 +1,9 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:dio/dio.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/data/service/prompt_service.dart';
import 'package:scribe/scribe/ai/domain/model/prompt_data.dart'; import 'package:scribe/scribe/ai/domain/model/ai_message.dart';
import 'package:scribe/scribe/ai/data/model/ai_message.dart';
class _ThrowingAdapter implements HttpClientAdapter { class _ThrowingAdapter implements HttpClientAdapter {
@override @override
@@ -59,51 +58,21 @@ void main() {
}); });
group('PromptService getPromptByName', () { group('PromptService getPromptByName', () {
test('getPromptByName should return correct prompt from data', () async { test('getPromptByName should return correct prompt from assets', () async {
// Arrange final service = PromptService(_throwingDio());
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}}')
]
)
]
);
// Act final prompt = await service.getPromptByName('change-tone-casual');
final prompt = promptData.prompts.firstWhere(
(prompt) => prompt.name == 'test-prompt',
orElse: () => throw Exception('Prompt not found: test-prompt'),
);
// Assert expect(prompt.name, 'change-tone-casual');
expect(prompt.name, 'test-prompt'); expect(prompt.messages.length, greaterThan(0));
expect(prompt.messages.length, 2);
}); });
test('getPromptByName should throw exception for non-existent prompt', () async { test('getPromptByName should throw exception for non-existent prompt',
// Arrange () async {
final promptData = PromptData( final service = PromptService(_throwingDio());
prompts: [
Prompt(
name: 'test-prompt',
messages: [
const AIMessage(role: AIRole.system, content: 'System message'),
const AIMessage(role: AIRole.user, content: 'User message')
]
)
]
);
// Act & Assert await expectLater(
expect( service.getPromptByName('non-existent-prompt'),
() => promptData.prompts.firstWhere(
(prompt) => prompt.name == 'non-existent-prompt',
orElse: () => throw Exception('Prompt not found: non-existent-prompt'),
),
throwsException, throwsException,
); );
}); });
@@ -1,6 +1,6 @@
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:scribe/scribe/ai/domain/model/prompt_data.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() { void main() {
group('PromptData', () { group('PromptData', () {
@@ -163,5 +163,19 @@ void main() {
expect(result.length, 2); expect(result.length, 2);
expect(result.last.content, 'User message without placeholders'); 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<FormatException>()));
});
test('fromJson should handle missing prompts key', () {
final jsonData = <String, dynamic>{};
final promptData = PromptData.fromJson(jsonData);
expect(promptData.prompts, isEmpty);
});
}); });
} }