[Scribe] Add Improve button (#4335)

This commit is contained in:
Théo Poizat
2026-02-27 08:12:58 +01:00
committed by Dat H. Pham
parent c8253551b1
commit 2ab6e0df02
23 changed files with 361 additions and 92 deletions
@@ -245,7 +245,7 @@ extension HandleAiScribeInComposerExtension on ComposerController {
final fullText = await _getTextOnlyContentInEditor();
await AiScribeModalManager.showAIScribeMenuModal(
await AiScribeModalManager.showAIScribeModal(
imagePaths: imagePaths,
availableCategories: AIScribeMenuCategory.values,
buttonPosition: position,
+2
View File
@@ -40,12 +40,14 @@ export 'scribe/ai/presentation/widgets/items/ai_scribe_menu_submenu_icon.dart';
export 'scribe/ai/presentation/widgets/items/ai_scribe_menu_text.dart';
export 'scribe/ai/presentation/widgets/modal/ai_scribe_modal_widget.dart';
export 'scribe/ai/presentation/widgets/modal/ai_scribe_suggestion_widget.dart';
export 'scribe/ai/presentation/widgets/modal/suggestion/ai_scribe_improve_button.dart';
export 'scribe/ai/presentation/widgets/modal/suggestion/ai_scribe_suggestion_error.dart';
export 'scribe/ai/presentation/widgets/modal/suggestion/ai_scribe_suggestion_header.dart';
export 'scribe/ai/presentation/widgets/modal/suggestion/ai_scribe_suggestion_loading.dart';
export 'scribe/ai/presentation/widgets/modal/suggestion/ai_scribe_suggestion_success.dart';
export 'scribe/ai/presentation/widgets/modal/suggestion/ai_scribe_suggestion_success_list_actions.dart';
export 'scribe/ai/presentation/widgets/modal/suggestion/ai_scribe_suggestion_success_toolbar.dart';
export 'scribe/ai/presentation/widgets/modal/suggestion/ai_scribe_suggestion_success_actions.dart';
export 'scribe/ai/presentation/widgets/overlay/ai_selection_overlay.dart';
export 'scribe/ai/presentation/widgets/search/ai_scribe_bar.dart';
export 'scribe/ai/presentation/widgets/mobile/ai_scribe_mobile_actions_bottom_sheet.dart';
+6
View File
@@ -102,6 +102,12 @@
"placeholders_order": [],
"placeholders": {}
},
"improve": "تحسين",
"@improve": {
"type": "text",
"placeholders_order": [],
"placeholders": {}
},
"generatingResponse": "إنشاء الاستجابة",
"@generatingResponse": {
"type": "text",
+6
View File
@@ -102,6 +102,12 @@
"placeholders_order": [],
"placeholders": {}
},
"improve": "Verbessern",
"@improve": {
"type": "text",
"placeholders_order": [],
"placeholders": {}
},
"generatingResponse": "Antwort wird generiert",
"@generatingResponse": {
"type": "text",
+6
View File
@@ -102,6 +102,12 @@
"placeholders_order": [],
"placeholders": {}
},
"improve": "Improve",
"@improve": {
"type": "text",
"placeholders_order": [],
"placeholders": {}
},
"generatingResponse": "Generating response",
"@generatingResponse": {
"type": "text",
+6
View File
@@ -102,6 +102,12 @@
"placeholders_order": [],
"placeholders": {}
},
"improve": "Améliorer",
"@improve": {
"type": "text",
"placeholders_order": [],
"placeholders": {}
},
"generatingResponse": "Génération de la réponse",
"@generatingResponse": {
"type": "text",
+6
View File
@@ -102,6 +102,12 @@
"placeholders_order": [],
"placeholders": {}
},
"improve": "Migliora",
"@improve": {
"type": "text",
"placeholders_order": [],
"placeholders": {}
},
"generatingResponse": "Generazione della risposta",
"@generatingResponse": {
"type": "text",
+7 -1
View File
@@ -1,5 +1,5 @@
{
"@@last_modified": "2026-02-04T15:15:31.191557",
"@@last_modified": "2026-02-09T16:58:15.727654",
"categoryCorrectGrammar": "Correct",
"@categoryCorrectGrammar": {
"type": "text",
@@ -102,6 +102,12 @@
"placeholders_order": [],
"placeholders": {}
},
"improve": "Improve",
"@improve": {
"type": "text",
"placeholders_order": [],
"placeholders": {}
},
"aiAssistant": "AI assistant",
"@aiAssistant": {
"type": "text",
+6
View File
@@ -102,6 +102,12 @@
"placeholders_order": [],
"placeholders": {}
},
"improve": "Улучшить",
"@improve": {
"type": "text",
"placeholders_order": [],
"placeholders": {}
},
"generatingResponse": "Генерация ответа",
"@generatingResponse": {
"type": "text",
+6
View File
@@ -102,6 +102,12 @@
"placeholders_order": [],
"placeholders": {}
},
"improve": "Cải thiện",
"@improve": {
"type": "text",
"placeholders_order": [],
"placeholders": {}
},
"generatingResponse": "Tạo phản hồi",
"@generatingResponse": {
"type": "text",
@@ -150,6 +150,13 @@ class ScribeLocalizations {
);
}
String get improve {
return Intl.message(
'Improve',
name: 'improve',
);
}
String get aiAssistant {
return Intl.message(
'AI assistant',
@@ -130,6 +130,7 @@ abstract final class AIScribeSizes {
static const double scribeButtonRadius = 100;
static const double aiAssistantIconRadius = 8;
static const double contentCardRadius = 16;
static const double improveButtonBorderRadius = 4;
// Width / height
static const double menuItemHeight = 40;
@@ -162,6 +163,7 @@ abstract final class AIScribeSizes {
static const double modalSpacing = 26;
static const double modalWithoutContentSpacing = 12;
static const double keyboardSpacing = 12;
static const double successSpacing = 12;
// Elevation
static const double dialogElevation = 8;
@@ -173,6 +175,10 @@ abstract final class AIScribeSizes {
static const double aiAssistantIcon = 24;
static const double bottomsheetIcon = 20;
// Button sizes
static const double minButtonWidth = 72;
static const double buttonHeight = 36;
// Padding
static const EdgeInsetsGeometry menuItemPadding =
EdgeInsetsDirectional.only(start: 16, end: 10);
@@ -218,4 +224,7 @@ abstract final class AIScribeSizes {
static const EdgeInsetsGeometry contentCardPadding =
EdgeInsetsDirectional.all(16);
static const EdgeInsetsGeometry improveButtonPadding =
EdgeInsetsDirectional.only(start: 14, top: 6, end: 10, bottom: 6);
}
@@ -56,7 +56,7 @@ class ContextSubmenuController {
final clampedLeft = finalLeft
.clamp(0.0, math.max(0.0, screenWidth - submenuWidth))
.toDouble();
final availableHeight = math.max(0.0, screenHeight - anchor.top);
final availableHeight = math.max(0.0, screenHeight - bottom);
final finalHeight = math.min(submenuMaxHeight, availableHeight);
_submenuEntry = OverlayEntry(
@@ -6,7 +6,7 @@ import 'package:scribe/scribe.dart';
class AiScribeModalManager {
AiScribeModalManager._();
static Future<void> showAIScribeMenuModal({
static Future<void> showAIScribeModal({
required bool isScribeMobile,
required ImagePaths imagePaths,
required List<AIScribeMenuCategory> availableCategories,
@@ -17,31 +17,16 @@ class AiScribeModalManager {
ModalPlacement? preferredPlacement,
ModalCrossAxisAlignment crossAxisAlignment = ModalCrossAxisAlignment.center,
}) async {
final AIAction? aiAction;
if (isScribeMobile) {
aiAction = await showMobileAIScribeMenuModal(
final AIAction? aiAction = await showAIScribeMenuModal(
isScribeMobile: isScribeMobile,
imagePaths: imagePaths,
content: content,
availableCategories: availableCategories,
buttonPosition: buttonPosition,
buttonSize: buttonSize,
preferredPlacement: preferredPlacement,
crossAxisAlignment: crossAxisAlignment,
);
} else {
final ContextSubmenuController submenuController = ContextSubmenuController();
aiAction = await Get.dialog<AIAction>(
AiScribeModalWidget(
imagePaths: imagePaths,
content: content,
availableCategories: availableCategories,
buttonPosition: buttonPosition,
buttonSize: buttonSize,
preferredPlacement: preferredPlacement,
crossAxisAlignment: crossAxisAlignment,
submenuController: submenuController,
),
barrierColor: AIScribeColors.dialogBarrier,
).whenComplete(submenuController.dispose);
}
if (aiAction != null) {
await showAIScribeSuggestionModal(
@@ -58,6 +43,38 @@ class AiScribeModalManager {
}
}
static Future<AIAction?> showAIScribeMenuModal({
required bool isScribeMobile,
required ImagePaths imagePaths,
required List<AIScribeMenuCategory> availableCategories,
String? content,
Offset? buttonPosition,
Size? buttonSize,
ModalPlacement? preferredPlacement,
ModalCrossAxisAlignment crossAxisAlignment = ModalCrossAxisAlignment.center,
bool showCustomPromptBar = true,
}) async {
if (isScribeMobile) {
return await showMobileAIScribeMenuModal(
imagePaths: imagePaths,
content: content,
availableCategories: availableCategories,
showCustomPromptBar: showCustomPromptBar,
);
} else {
return await showDesktopAIScribeMenuModal(
imagePaths: imagePaths,
content: content,
availableCategories: availableCategories,
buttonPosition: buttonPosition,
buttonSize: buttonSize,
preferredPlacement: preferredPlacement,
crossAxisAlignment: crossAxisAlignment,
showCustomPromptBar: showCustomPromptBar,
);
}
}
static Future<void> showAIScribeSuggestionModal({
required AIAction aiAction,
required bool isScribeMobile,
@@ -77,26 +94,52 @@ class AiScribeModalManager {
onSelectAiScribeSuggestionAction: onSelectAiScribeSuggestionAction,
);
} else {
await Get.dialog<String?>(
AiScribeSuggestionWidget(
aiAction: aiAction,
await showDesktopAIScribeSuggestionModal(
aiAction: aiAction,
imagePaths: imagePaths,
content: content,
buttonPosition: buttonPosition,
buttonSize: buttonSize,
preferredPlacement: preferredPlacement,
crossAxisAlignment: crossAxisAlignment,
onSelectAiScribeSuggestionAction: onSelectAiScribeSuggestionAction,
);
}
}
static Future<AIAction?> showDesktopAIScribeMenuModal({
required ImagePaths imagePaths,
required List<AIScribeMenuCategory> availableCategories,
String? content,
Offset? buttonPosition,
Size? buttonSize,
ModalPlacement? preferredPlacement,
ModalCrossAxisAlignment crossAxisAlignment = ModalCrossAxisAlignment.center,
bool showCustomPromptBar = true,
}) async {
final ContextSubmenuController submenuController = ContextSubmenuController();
return await Get.dialog<AIAction>(
AiScribeModalWidget(
imagePaths: imagePaths,
content: content,
availableCategories: availableCategories,
buttonPosition: buttonPosition,
buttonSize: buttonSize,
preferredPlacement: preferredPlacement,
crossAxisAlignment: crossAxisAlignment,
onSelectAiScribeSuggestionAction: onSelectAiScribeSuggestionAction,
submenuController: submenuController,
showCustomPromptBar: showCustomPromptBar,
),
barrierColor: AIScribeColors.dialogBarrier,
);
}
).whenComplete(submenuController.dispose);
}
static Future<AIAction?> showMobileAIScribeMenuModal({
required ImagePaths imagePaths,
required List<AIScribeMenuCategory> availableCategories,
String? content,
bool showCustomPromptBar = true,
}) async {
final context = Get.context;
@@ -110,10 +153,36 @@ class AiScribeModalManager {
imagePaths: imagePaths,
availableCategories: availableCategories,
content: content,
showCustomPromptBar: showCustomPromptBar,
),
);
}
static Future<void> showDesktopAIScribeSuggestionModal({
required AIAction aiAction,
required ImagePaths imagePaths,
required OnSelectAiScribeSuggestionAction onSelectAiScribeSuggestionAction,
String? content,
Offset? buttonPosition,
Size? buttonSize,
ModalPlacement? preferredPlacement,
ModalCrossAxisAlignment crossAxisAlignment = ModalCrossAxisAlignment.center,
}) async {
await Get.dialog<void>(
AiScribeSuggestionWidget(
aiAction: aiAction,
imagePaths: imagePaths,
content: content,
buttonPosition: buttonPosition,
buttonSize: buttonSize,
preferredPlacement: preferredPlacement,
crossAxisAlignment: crossAxisAlignment,
onSelectAiScribeSuggestionAction: onSelectAiScribeSuggestionAction,
),
barrierColor: AIScribeColors.dialogBarrier,
);
}
static Future<void> showMobileAIScribeSuggestionModal({
required AIAction aiAction,
required ImagePaths imagePaths,
@@ -7,6 +7,8 @@ import 'package:get/get.dart';
import 'package:scribe/scribe.dart';
import 'package:scribe/scribe/ai/data/network/ai_api_exception.dart';
typedef OnLoadSuggestion = Future<void> Function([AIAction? aiAction, String? content]);
mixin AiScribeSuggestionStateMixin<T extends StatefulWidget> on State<T> {
GenerateAITextInteractor? get interactor => _interactor;
GenerateAITextInteractor? _interactor;
@@ -37,7 +39,10 @@ mixin AiScribeSuggestionStateMixin<T extends StatefulWidget> on State<T> {
loadSuggestion();
}
Future<void> loadSuggestion() async {
Future<void> loadSuggestion([AIAction? newAiAction, String? newContent]) async {
final aiActionToSend = newAiAction ?? aiAction;
final contentToSend = newContent ?? content;
_suggestionState.value = dartz.Right(GenerateAITextLoading());
if (_interactor == null) {
@@ -50,8 +55,8 @@ mixin AiScribeSuggestionStateMixin<T extends StatefulWidget> on State<T> {
}
final result = await _interactor!.execute(
aiAction,
content,
aiActionToSend,
contentToSend,
);
if (!mounted) return;
@@ -49,7 +49,7 @@ class InlineAiAssistButton extends StatelessWidget {
final isScribeMobile = AiScribeMobileUtils.isScribeInMobileMode(context);
await AiScribeModalManager.showAIScribeMenuModal(
await AiScribeModalManager.showAIScribeModal(
isScribeMobile: isScribeMobile,
imagePaths: imagePaths,
availableCategories: AIScribeMenuCategory.values,
@@ -9,12 +9,14 @@ class AiScribeMobileActionsBottomSheet extends StatefulWidget {
final ImagePaths imagePaths;
final List<AIScribeMenuCategory> availableCategories;
final String? content;
final bool showCustomPromptBar;
const AiScribeMobileActionsBottomSheet({
super.key,
required this.imagePaths,
required this.availableCategories,
this.content,
this.showCustomPromptBar = true,
});
@override
@@ -153,6 +155,10 @@ class _AiScribeMobileActionsBottomSheetState
}
Widget _buildBottomBar(BuildContext context) {
if (!widget.showCustomPromptBar) {
return const SizedBox.shrink();
}
return Column(
mainAxisSize: MainAxisSize.min,
children: [
@@ -14,6 +14,7 @@ class AiScribeModalWidget extends StatelessWidget {
final ModalPlacement? preferredPlacement;
final ModalCrossAxisAlignment crossAxisAlignment;
final ContextSubmenuController? submenuController;
final bool showCustomPromptBar;
const AiScribeModalWidget({
super.key,
@@ -25,6 +26,7 @@ class AiScribeModalWidget extends StatelessWidget {
this.preferredPlacement,
this.crossAxisAlignment = ModalCrossAxisAlignment.center,
this.submenuController,
this.showCustomPromptBar = true,
});
@override
@@ -57,16 +59,17 @@ class AiScribeModalWidget extends StatelessWidget {
),
),
),
MouseRegion(
onEnter: (_) => submenuController?.hide(),
child: AIScribeBar(
imagePaths: imagePaths,
onCustomPrompt: (customPrompt) {
Navigator.of(context).pop(CustomPromptAction(customPrompt));
submenuController?.hide();
},
if (showCustomPromptBar)
MouseRegion(
onEnter: (_) => submenuController?.hide(),
child: AIScribeBar(
imagePaths: imagePaths,
onCustomPrompt: (customPrompt) {
Navigator.of(context).pop(CustomPromptAction(customPrompt));
submenuController?.hide();
},
),
),
),
],
),
);
@@ -0,0 +1,69 @@
import 'package:core/presentation/extensions/color_extension.dart';
import 'package:core/presentation/resources/image_paths.dart';
import 'package:core/presentation/views/button/tmail_button_widget.dart';
import 'package:flutter/material.dart';
import 'package:scribe/scribe.dart';
class AiScribeImproveButton extends StatefulWidget {
final ImagePaths imagePaths;
final String suggestionText;
final OnLoadSuggestion onLoadSuggestion;
const AiScribeImproveButton({
super.key,
required this.imagePaths,
required this.suggestionText,
required this.onLoadSuggestion,
});
@override
State<AiScribeImproveButton> createState() => _AiScribeImproveButtonState();
}
class _AiScribeImproveButtonState extends State<AiScribeImproveButton> {
final GlobalKey _improveButtonKey = GlobalKey();
@override
Widget build(BuildContext context) {
return TMailButtonWidget(
key: _improveButtonKey,
text: ScribeLocalizations.of(context).improve,
padding: AIScribeSizes.improveButtonPadding,
borderRadius: AIScribeSizes.improveButtonBorderRadius,
backgroundColor: AppColor.colorBackgroundTagFilter.withValues(alpha: 0.08),
icon: widget.imagePaths.icChevronDown,
iconColor: AppColor.colorTextButtonHeaderThread,
iconAlignment: TextDirection.rtl,
onTapActionCallback: _handleImproveButtonTap,
);
}
Future<void> _handleImproveButtonTap() async {
final renderBox = _improveButtonKey.currentContext?.findRenderObject();
Offset? position;
Size? size;
if (renderBox != null && renderBox is RenderBox) {
position = renderBox.localToGlobal(Offset.zero);
size = renderBox.size;
}
final AIAction? aiAction = await AiScribeModalManager.showAIScribeMenuModal(
isScribeMobile: AiScribeMobileUtils.isScribeInMobileMode(context),
imagePaths: widget.imagePaths,
availableCategories: AIScribeMenuCategory.values,
content: widget.suggestionText,
buttonPosition: position,
buttonSize: size,
showCustomPromptBar: false,
);
if (!mounted) {
return;
}
if (aiAction != null) {
widget.onLoadSuggestion(aiAction, widget.suggestionText);
}
}
}
@@ -7,7 +7,7 @@ class AiScribeSuggestionSuccess extends StatelessWidget {
final String suggestionText;
final bool hasContent;
final OnSelectAiScribeSuggestionAction onSelectAction;
final VoidCallback onLoadSuggestion;
final OnLoadSuggestion onLoadSuggestion;
const AiScribeSuggestionSuccess({
super.key,
@@ -0,0 +1,89 @@
import 'package:core/presentation/extensions/color_extension.dart';
import 'package:core/presentation/resources/image_paths.dart';
import 'package:core/presentation/views/dialog/confirm_dialog_button.dart';
import 'package:flutter/material.dart';
import 'package:scribe/scribe.dart';
class AiScribeSuggestionSuccessActions extends StatelessWidget {
final ImagePaths imagePaths;
final String suggestionText;
final bool hasContent;
final OnSelectAiScribeSuggestionAction onSelectAction;
final OnLoadSuggestion onLoadSuggestion;
const AiScribeSuggestionSuccessActions({
super.key,
required this.imagePaths,
required this.suggestionText,
required this.onSelectAction,
required this.onLoadSuggestion,
this.hasContent = false,
});
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
AiScribeImproveButton(
imagePaths: imagePaths,
suggestionText: suggestionText,
onLoadSuggestion: onLoadSuggestion,
),
Flexible(
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (hasContent) _buildReplaceButton(context),
_buildInsertButton(context),
],
),
),
],
);
}
Widget _buildReplaceButton(BuildContext context) {
final localizations = ScribeLocalizations.of(context);
return Flexible(
child: Container(
constraints: const BoxConstraints(minWidth: AIScribeSizes.minButtonWidth),
height: AIScribeSizes.buttonHeight,
child: ConfirmDialogButton(
label: AiScribeSuggestionActions.replace.getLabel(localizations),
textColor: AppColor.primaryMain,
onTapAction: () {
Navigator.of(context).pop();
onSelectAction(
AiScribeSuggestionActions.replace,
suggestionText,
);
},
),
),
);
}
Widget _buildInsertButton(BuildContext context) {
final localizations = ScribeLocalizations.of(context);
return Flexible(
child: Container(
constraints: const BoxConstraints(minWidth: AIScribeSizes.minButtonWidth),
height: AIScribeSizes.buttonHeight,
child: ConfirmDialogButton(
label: AiScribeSuggestionActions.insert.getLabel(localizations),
backgroundColor: AppColor.blueD2E9FF,
textColor: AppColor.primaryMain,
onTapAction: () {
Navigator.of(context).pop();
onSelectAction(
AiScribeSuggestionActions.insert,
suggestionText,
);
},
),
),
);
}
}
@@ -1,6 +1,4 @@
import 'package:core/presentation/extensions/color_extension.dart';
import 'package:core/presentation/resources/image_paths.dart';
import 'package:core/presentation/views/dialog/confirm_dialog_button.dart';
import 'package:flutter/material.dart';
import 'package:scribe/scribe.dart';
@@ -14,7 +12,7 @@ class AiScribeSuggestionSuccessListActions extends StatelessWidget {
final String suggestionText;
final bool hasContent;
final OnSelectAiScribeSuggestionAction onSelectAction;
final VoidCallback onLoadSuggestion;
final OnLoadSuggestion onLoadSuggestion;
const AiScribeSuggestionSuccessListActions({
super.key,
@@ -27,52 +25,16 @@ class AiScribeSuggestionSuccessListActions extends StatelessWidget {
@override
Widget build(BuildContext context) {
final localizations = ScribeLocalizations.of(context);
return Column(
spacing: AIScribeSizes.successSpacing,
children: [
AiScribeSuggestionSuccessToolbar(suggestionText: suggestionText, onLoadSuggestion: onLoadSuggestion, imagePaths: imagePaths),
Row(
mainAxisAlignment: MainAxisAlignment.end,
spacing: 8,
children: [
if (hasContent)
Flexible(
child: Container(
constraints: const BoxConstraints(minWidth: 67),
height: 36,
child: ConfirmDialogButton(
label: AiScribeSuggestionActions.replace.getLabel(localizations),
textColor: AppColor.primaryMain,
onTapAction: () {
Navigator.of(context).pop();
onSelectAction(
AiScribeSuggestionActions.replace,
suggestionText,
);
},
),
),
),
Flexible(
child: Container(
constraints: const BoxConstraints(minWidth: 72),
height: 36,
child: ConfirmDialogButton(
label: AiScribeSuggestionActions.insert.getLabel(localizations),
backgroundColor: AppColor.blueD2E9FF,
textColor: AppColor.primaryMain,
onTapAction: () {
Navigator.of(context).pop();
onSelectAction(
AiScribeSuggestionActions.insert,
suggestionText,
);
},
),
),
),
],
AiScribeSuggestionSuccessActions(
suggestionText: suggestionText,
onLoadSuggestion: onLoadSuggestion,
imagePaths: imagePaths,
hasContent: hasContent,
onSelectAction: onSelectAction,
),
],
);
@@ -6,7 +6,7 @@ import 'package:core/presentation/resources/image_paths.dart';
class AiScribeSuggestionSuccessToolbar extends StatelessWidget {
final String suggestionText;
final VoidCallback onLoadSuggestion;
final OnLoadSuggestion onLoadSuggestion;
final ImagePaths imagePaths;
const AiScribeSuggestionSuccessToolbar({