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
@@ -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 {
@@ -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';
@@ -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<AIMessage> messages;
@@ -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;
@@ -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';
@@ -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<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 {
final response = await _dio.get(url);
final data = response.data;
@@ -87,11 +88,11 @@ class PromptService {
Future<Prompt> 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<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_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<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 {
return switch (action) {
@@ -23,7 +31,17 @@ class AIPrompts {
return await _promptService.buildPromptByName(menuAction.promptId, text);
}
static Future<List<AIMessage>> buildCustomPrompt(String customPrompt, String? text) async {
return await _promptService.buildPromptByName(CustomPromptAction.promptId, text ?? '', task: customPrompt);
static Future<List<AIMessage>> 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,
);
}
}
@@ -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<Prompt> prompts;
@@ -9,13 +9,13 @@ class PromptData {
factory PromptData.fromJson(Map<String, dynamic> json) {
final promptsJson = json['prompts'] as List?;
return PromptData(
prompts: promptsJson
?.whereType<Map<String, dynamic>>()
.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<String, dynamic>>()
.map(AIMessage.fromJson)
.toList() ??
.toList() ??
const [],
);
}
@@ -50,20 +50,18 @@ class Prompt {
List<AIMessage> 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;
}
}
}
@@ -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 {
@@ -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;
@@ -4,6 +4,9 @@ import 'package:get/get.dart';
class AiScribeMobileUtils {
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;
OnSelectAiScribeSuggestionAction get onSelectAction;
late AIAction _currentAiAction;
String? _currentContent;
int _requestId = 0;
@override
void initState() {
super.initState();
_currentAiAction = aiAction;
_currentContent = content;
if (!Get.isRegistered<GenerateAITextInteractor>()) {
_suggestionState.value = dartz.Left(
GenerateAITextFailure(
GenerateAITextInteractorIsNotRegisteredException(),
const GenerateAITextInteractorIsNotRegisteredException(),
),
);
return;
@@ -40,26 +46,27 @@ mixin AiScribeSuggestionStateMixin<T extends StatefulWidget> on State<T> {
}
Future<void> 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<T extends StatefulWidget> on State<T> {
(failure) => buildErrorState(),
(value) {
if (value is GenerateAITextSuccess) {
final hasContent = content?.trim().isNotEmpty == true;
final hasContent = _currentContent?.trim().isNotEmpty == true;
return buildSuccessState(
value.response.result,
@@ -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<void> _onTapActionCallback(BuildContext context) async {
Future<void> _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(
@@ -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<AiScribeCategoryContextMenuAction?>(
valueListenable: _selectedCategory,
builder: (context, selectedCategory, _) {
@@ -221,10 +215,10 @@ class _AiScribeMobileActionsBottomSheetState
: _buildSubmenuListView();
},
),
],
],
),
),
),
),
_buildBottomBar(context)
],
),
@@ -5,14 +5,14 @@ import 'package:scribe/scribe.dart';
class AiScribeMobileActionsItem extends StatelessWidget {
final AiScribeContextMenuAction menuAction;
final ImagePaths imagePaths;
final ValueChanged<AiScribeCategoryContextMenuAction>? onCategorySelected;
final ValueChanged<AiScribeCategoryContextMenuAction> onCategorySelected;
final ValueChanged<AiScribeContextMenuAction> 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);
}
}
);
@@ -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;
@@ -74,9 +74,12 @@ class _AiScribeSuggestionWidgetState extends State<AiScribeSuggestionWidget>
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<AiScribeSuggestionWidget>
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<AiScribeSuggestionWidget>
// 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;
}
@@ -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,
);
}
}
@@ -20,7 +20,7 @@ class AiScribeSuggestionSuccessToolbar extends StatelessWidget {
@override
Widget build(BuildContext context) {
final appToast = Get.find<AppToast>();
final appToast = Get.isRegistered<AppToast>() ? Get.find<AppToast>() : 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,
);