Add AI scribe widgets

- Button : the button with sparkle icon to display AI scribe
- Menu : the menu where you can select the action (eg: translate)
- Bar : the input where you can write a custom action
- Suggestion : the wrapper of the result of the LLM
This commit is contained in:
Théo Poizat
2025-12-10 10:50:35 +01:00
committed by Dat H. Pham
parent 23065aa76d
commit dced5b5dc7
12 changed files with 649 additions and 72 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
{
"@@last_modified": "2025-12-09T13:09:50.879268",
"@@last_modified": "2025-12-10T16:32:00.611716",
"initializing_data": "Initializing data...",
"@initializing_data": {
"type": "text",
-12
View File
@@ -102,24 +102,12 @@
"placeholders_order": [],
"placeholders": {}
},
"generatingResponse": "Generating AI response...",
"@generatingResponse": {
"type": "text",
"placeholders_order": [],
"placeholders": {}
},
"failedToGenerate": "Failed to generate AI response",
"@failedToGenerate": {
"type": "text",
"placeholders_order": [],
"placeholders": {}
},
"noDataReceived": "No data received",
"@noDataReceived": {
"type": "text",
"placeholders_order": [],
"placeholders": {}
},
"insertButton": "Insert",
"@insertButton": {
"type": "text",
-12
View File
@@ -102,24 +102,12 @@
"placeholders_order": [],
"placeholders": {}
},
"generatingResponse": "Génération de la réponse IA...",
"@generatingResponse": {
"type": "text",
"placeholders_order": [],
"placeholders": {}
},
"failedToGenerate": "Échec de la génération de la réponse IA",
"@failedToGenerate": {
"type": "text",
"placeholders_order": [],
"placeholders": {}
},
"noDataReceived": "Aucune donnée reçue",
"@noDataReceived": {
"type": "text",
"placeholders_order": [],
"placeholders": {}
},
"insertButton": "Insérer",
"@insertButton": {
"type": "text",
+1 -13
View File
@@ -1,5 +1,5 @@
{
"@@last_modified": "2025-12-09T13:09:52.614790",
"@@last_modified": "2025-12-10T16:32:03.004307",
"categoryCorrectGrammar": "Correct grammar",
"@categoryCorrectGrammar": {
"type": "text",
@@ -102,24 +102,12 @@
"placeholders_order": [],
"placeholders": {}
},
"generatingResponse": "Generating AI response...",
"@generatingResponse": {
"type": "text",
"placeholders_order": [],
"placeholders": {}
},
"failedToGenerate": "Failed to generate AI response",
"@failedToGenerate": {
"type": "text",
"placeholders_order": [],
"placeholders": {}
},
"noDataReceived": "No data received",
"@noDataReceived": {
"type": "text",
"placeholders_order": [],
"placeholders": {}
},
"insertButton": "Insert",
"@insertButton": {
"type": "text",
-12
View File
@@ -102,24 +102,12 @@
"placeholders_order": [],
"placeholders": {}
},
"generatingResponse": "Генерация ответа ИИ...",
"@generatingResponse": {
"type": "text",
"placeholders_order": [],
"placeholders": {}
},
"failedToGenerate": "Не удалось сгенерировать ответ ИИ",
"@failedToGenerate": {
"type": "text",
"placeholders_order": [],
"placeholders": {}
},
"noDataReceived": "Данные не получены",
"@noDataReceived": {
"type": "text",
"placeholders_order": [],
"placeholders": {}
},
"insertButton": "Вставить",
"@insertButton": {
"type": "text",
-12
View File
@@ -102,24 +102,12 @@
"placeholders_order": [],
"placeholders": {}
},
"generatingResponse": "Đang tạo phản hồi AI...",
"@generatingResponse": {
"type": "text",
"placeholders_order": [],
"placeholders": {}
},
"failedToGenerate": "Không thể tạo phản hồi AI",
"@failedToGenerate": {
"type": "text",
"placeholders_order": [],
"placeholders": {}
},
"noDataReceived": "Không nhận được dữ liệu",
"@noDataReceived": {
"type": "text",
"placeholders_order": [],
"placeholders": {}
},
"insertButton": "Chèn",
"@insertButton": {
"type": "text",
@@ -112,21 +112,11 @@ class ScribeLocalizations {
}
// Suggestion Dialog
String get generatingResponse {
return Intl.message('Generating AI response...',
name: 'generatingResponse');
}
String get failedToGenerate {
return Intl.message('Failed to generate AI response',
name: 'failedToGenerate');
}
String get noDataReceived {
return Intl.message('No data received',
name: 'noDataReceived');
}
String get insertButton {
return Intl.message('Insert',
name: 'insertButton');
@@ -0,0 +1,82 @@
import 'package:flutter/material.dart';
import 'package:core/presentation/extensions/color_extension.dart';
import 'package:core/presentation/utils/theme_utils.dart';
class AIScribeColors {
static const textPrimary = AppColor.textPrimary;
static const background = Colors.white;
}
class AIScribeShadows {
static List<BoxShadow> get elevation8 => [
BoxShadow(
color: Colors.black.withValues(alpha: 0.16),
blurRadius: 12,
offset: const Offset(0, 2),
),
BoxShadow(
color: Colors.black.withValues(alpha: 0.08),
blurRadius: 4,
offset: const Offset(0, 1),
),
];
}
class AIScribeTextStyles {
static TextStyle menuItem = ThemeUtils.textStyleBodyBody3(color: AIScribeColors.textPrimary);
static TextStyle menuHint = ThemeUtils.textStyleBodyBody3(color: AIScribeColors.textPrimary.withValues(alpha: 0.6));
static TextStyle suggestionTitle = ThemeUtils.textStyleInter700();
static TextStyle suggestionContent = ThemeUtils.textStyleBodyBody3(color: AIScribeColors.textPrimary);
}
class AIScribeButtonStyles {
static TextStyle mainActionButtonText = ThemeUtils.textStyleInter500().copyWith(color: AppColor.blue700);
static const Color mainActionButtonBackgroundColor = Color(0xFFD2E9FF);
static const EdgeInsetsGeometry mainActionButtonPadding = EdgeInsetsDirectional.symmetric(vertical: 8, horizontal: 16);
static const Color sendCustomPromptBackgroundColor = AppColor.blue700;
static const Color sendCustomPromptBackgroundColorDisabled = Color(0xFFD2E9FF);
}
class AIScribeSizes {
// Border radius
static const double menuBorderRadius = 12.0;
static const double menuItemBorderRadius = 6.0;
static const double scribeButtonBorderRadius = 100.0;
// Dialog dimensions
static const double menuWidth = 200.0;
static const double barWidth = 440.0;
static const double modalMaxHeight = 400.0;
static const double modalMaxWidthLargeScreen = 500.0;
static const double mobileWidthPercentage = 0.9;
static const double mobileBreakpoint = 600.0;
static const double infoHeight = 120.0;
// Heights
static const double menuItemHeight = 40.0;
static const double barHeight = 48.0;
static const double submenuMaxHeight = 300.0;
// Spacing
static const double screenEdgePadding = 16.0;
static const double submenuSpacing = 6.0;
static const double fieldSpacing = 8.0;
// Elevation
static const double dialogElevation = 8.0;
// Icon sizes
static const double iconSize = 18.0;
static const double sendIconSize = 16.0;
static const double scribeIconSize = 12.0;
// Padding (using EdgeInsets)
static const EdgeInsetsGeometry menuItemPadding = EdgeInsetsDirectional.symmetric(horizontal: 16, vertical: 10);
static const EdgeInsetsGeometry barPadding = EdgeInsetsDirectional.symmetric(horizontal: 16, vertical: 8);
static const EdgeInsetsGeometry suggestionContentPadding = EdgeInsetsDirectional.symmetric(horizontal: 16, vertical: 8);
static const EdgeInsetsGeometry suggestionInfoPadding = EdgeInsets.only(bottom: 16);
static const EdgeInsetsGeometry suggestionHeaderPadding = EdgeInsets.fromLTRB(16, 8, 8, 8);
static const EdgeInsetsGeometry suggestionFooterPadding = EdgeInsets.symmetric(horizontal: 16, vertical: 16);
static const EdgeInsetsGeometry scribeButtonPadding = EdgeInsets.all(6);
}
@@ -0,0 +1,89 @@
import 'package:flutter/material.dart';
import 'package:core/presentation/resources/image_paths.dart';
import 'package:core/presentation/views/button/tmail_button_widget.dart';
import 'package:scribe/scribe/ai/localizations/scribe_localizations.dart';
import 'package:scribe/scribe/ai/presentation/styles/ai_scribe_styles.dart';
typedef OnCustomPromptCallback = void Function(String customPrompt);
class AIScribeBar extends StatefulWidget {
final OnCustomPromptCallback onCustomPrompt;
final ImagePaths imagePaths;
const AIScribeBar({
super.key,
required this.onCustomPrompt,
required this.imagePaths,
});
@override
State<AIScribeBar> createState() => _AIScribeBarState();
}
class _AIScribeBarState extends State<AIScribeBar> {
final TextEditingController _controller = TextEditingController();
bool _isButtonEnabled = false;
@override
void initState() {
super.initState();
_controller.addListener(_onTextChanged);
}
@override
void dispose() {
_controller.removeListener(_onTextChanged);
_controller.dispose();
super.dispose();
}
void _onTextChanged() {
setState(() {
_isButtonEnabled = _controller.text.trim().isNotEmpty;
});
}
void _onSendPressed() {
final prompt = _controller.text.trim();
if (prompt.isNotEmpty) {
widget.onCustomPrompt(prompt);
_controller.clear();
}
}
@override
Widget build(BuildContext context) {
return Container(
height: AIScribeSizes.barHeight,
padding: AIScribeSizes.barPadding,
child: Row(
children: [
Expanded(
child: TextField(
controller: _controller,
decoration: InputDecoration(
hintText: ScribeLocalizations.of(context)!.inputPlaceholder,
hintStyle: AIScribeTextStyles.menuHint,
border: InputBorder.none,
contentPadding: EdgeInsets.zero,
isDense: true,
),
style: AIScribeTextStyles.menuItem,
onSubmitted: (_) => _onSendPressed(),
),
),
const SizedBox(width: AIScribeSizes.fieldSpacing),
TMailButtonWidget.fromIcon(
icon: widget.imagePaths.icSend,
iconSize: AIScribeSizes.sendIconSize,
iconColor: Colors.white,
backgroundColor: _isButtonEnabled
? AIScribeButtonStyles.sendCustomPromptBackgroundColor
: AIScribeButtonStyles.sendCustomPromptBackgroundColorDisabled,
onTapActionCallback: _isButtonEnabled ? _onSendPressed : null,
)
],
),
);
}
}
@@ -0,0 +1,28 @@
import 'package:flutter/material.dart';
import 'package:core/presentation/resources/image_paths.dart';
import 'package:core/presentation/views/button/tmail_button_widget.dart';
import 'package:scribe/scribe/ai/presentation/styles/ai_scribe_styles.dart';
class AIScribeButton extends StatelessWidget {
final ImagePaths imagePaths;
final VoidCallback onTap;
const AIScribeButton({
super.key,
required this.imagePaths,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return TMailButtonWidget.fromIcon(
icon: imagePaths.icSparkle,
padding: AIScribeSizes.scribeButtonPadding,
backgroundColor: Colors.white,
iconSize: AIScribeSizes.scribeIconSize,
borderRadius: AIScribeSizes.scribeButtonBorderRadius,
onTapActionCallback: onTap,
boxShadow: AIScribeShadows.elevation8,
);
}
}
@@ -0,0 +1,250 @@
import 'package:flutter/material.dart';
import 'package:pointer_interceptor/pointer_interceptor.dart';
import 'package:scribe/scribe/ai/presentation/model/ai_scribe_menu_action.dart';
import 'package:scribe/scribe/ai/presentation/styles/ai_scribe_styles.dart';
class AIScribeMenu extends StatefulWidget {
final Function(AIScribeMenuAction) onActionSelected;
final bool useSubmenuItemStyle;
final List<AIScribeMenuCategory>? availableCategories;
const AIScribeMenu({
super.key,
required this.onActionSelected,
this.useSubmenuItemStyle = true,
this.availableCategories,
});
@override
State<AIScribeMenu> createState() => _AIScribeMenuContentState();
}
class _AIScribeMenuContentState extends State<AIScribeMenu> {
AIScribeMenuCategory? _hoveredCategory;
final Map<AIScribeMenuCategory, GlobalKey> _categoryKeys = {};
final GlobalKey _menuKey = GlobalKey();
OverlayEntry? _submenuOverlay;
@override
void initState() {
super.initState();
final categories = widget.availableCategories ?? AIScribeMenuCategory.values;
for (final category in categories) {
_categoryKeys[category] = GlobalKey();
}
}
@override
void dispose() {
_removeSubmenu();
super.dispose();
}
void _removeSubmenu() {
_submenuOverlay?.remove();
_submenuOverlay = null;
}
void _showSubmenu(AIScribeMenuCategory category) {
if (!category.hasSubmenu) return;
_removeSubmenu();
// Get the entire menu's bounds
final menuRenderBox = _menuKey.currentContext?.findRenderObject() as RenderBox?;
if(menuRenderBox == null) return;
final menuPosition = menuRenderBox.localToGlobal(Offset.zero);
final menuSize = menuRenderBox.size;
_submenuOverlay = OverlayEntry(
builder: (context) => _SubmenuPanel(
category: category,
position: menuPosition,
parentSize: menuSize,
onActionSelected: (action) {
_removeSubmenu();
widget.onActionSelected(action);
},
onDismiss: () {
setState(() {
_hoveredCategory = null;
});
_removeSubmenu();
},
),
);
Overlay.of(context).insert(_submenuOverlay!);
}
void _handleCategoryClick(AIScribeMenuCategory category) {
setState(() {
// Toggle submenu - close if already open, open if closed
if (_hoveredCategory == category) {
_hoveredCategory = null;
_removeSubmenu();
} else {
_hoveredCategory = category;
_showSubmenu(category);
}
});
}
@override
Widget build(BuildContext context) {
final categories = widget.availableCategories ?? AIScribeMenuCategory.values;
return Column(
key: _menuKey,
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: categories.map((category) {
return _buildCategoryItem(category);
}).toList(),
);
}
Widget _buildCategoryItem(AIScribeMenuCategory category) {
if (category.hasSubmenu) {
return Container(
key: _categoryKeys[category],
child: _buildMenuItem(
label: category.getLabel(context),
hasSubmenu: true,
isHovered: _hoveredCategory == category,
onTap: () => _handleCategoryClick(category),
),
);
} else {
// For categories without submenu (like Correct Grammar)
return _buildMenuItem(
label: category.getLabel(context),
onTap: () {
if (category.actions.isNotEmpty) {
widget.onActionSelected(category.actions.first);
}
},
);
}
}
Widget _buildMenuItem({
required String label,
required VoidCallback onTap,
bool hasSubmenu = false,
bool isHovered = false
}) {
return SizedBox(
height: AIScribeSizes.menuItemHeight,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(AIScribeSizes.menuItemBorderRadius),
child: Padding(
padding: AIScribeSizes.menuItemPadding,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
label,
style: AIScribeTextStyles.menuItem,
),
),
if (hasSubmenu)
const Icon(
Icons.chevron_right,
size: AIScribeSizes.iconSize,
color: AIScribeColors.textPrimary,
),
],
),
),
),
);
}
}
/// Submenu panel that appears to the right of the main menu
class _SubmenuPanel extends StatefulWidget {
final AIScribeMenuCategory category;
final Offset position;
final Size parentSize;
final Function(AIScribeMenuAction) onActionSelected;
final VoidCallback onDismiss;
const _SubmenuPanel({
required this.category,
required this.position,
required this.parentSize,
required this.onActionSelected,
required this.onDismiss,
});
@override
State<_SubmenuPanel> createState() => _SubmenuPanelState();
}
class _SubmenuPanelState extends State<_SubmenuPanel> {
@override
Widget build(BuildContext context) {
final submenuHeight = widget.category.actions.length * AIScribeSizes.menuItemHeight;
// Position submenu to the right of the parent item
final left = widget.position.dx + widget.parentSize.width + AIScribeSizes.submenuSpacing;
final top = widget.position.dy + widget.parentSize.height - submenuHeight;
final screenSize = MediaQuery.of(context).size;
// If we exceed screen to the right, we position left instead of right
final adjustedLeft = (left + AIScribeSizes.menuWidth > screenSize.width)
? widget.position.dx - AIScribeSizes.menuWidth - AIScribeSizes.submenuSpacing
: left;
// If we exceed screen to the top, we position at top
final adjustedTop = (top < 0.0)
? 0.0
: top;
return Stack(
children: [
Positioned(
left: adjustedLeft,
top: adjustedTop,
child: PointerInterceptor(
child: Material(
color: Colors.white,
elevation: AIScribeSizes.dialogElevation,
borderRadius: BorderRadius.circular(AIScribeSizes.menuBorderRadius),
child: Container(
width: AIScribeSizes.menuWidth,
constraints: const BoxConstraints(maxHeight: AIScribeSizes.submenuMaxHeight),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: widget.category.actions.map((action) {
return SizedBox(
height: AIScribeSizes.menuItemHeight,
child: InkWell(
onTap: () => widget.onActionSelected(action),
borderRadius: BorderRadius.circular(AIScribeSizes.menuItemBorderRadius),
child: Padding(
padding: AIScribeSizes.menuItemPadding,
child: Align(
alignment: Alignment.centerLeft,
child: Text(
action.getLabel(context),
style: AIScribeTextStyles.menuItem,
),
),
),
),
);
}).toList(),
),
),
),
),
),
],
);
}
}
@@ -0,0 +1,198 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:pointer_interceptor/pointer_interceptor.dart';
import 'package:core/presentation/resources/image_paths.dart';
import 'package:core/presentation/views/button/tmail_button_widget.dart';
import 'package:scribe/scribe/ai/localizations/scribe_localizations.dart';
import 'package:scribe/scribe/ai/presentation/styles/ai_scribe_styles.dart';
typedef OnInsertTextCallback = void Function(String text);
class AIScribeSuggestion extends StatefulWidget {
final String title;
final Future<String> suggestionFuture;
final VoidCallback onClose;
final OnInsertTextCallback onInsert;
final ImagePaths imagePaths;
const AIScribeSuggestion({
super.key,
required this.title,
required this.suggestionFuture,
required this.onClose,
required this.onInsert,
required this.imagePaths,
});
@override
State<AIScribeSuggestion> createState() => _AIScribeSuggestionModalState();
}
class _AIScribeSuggestionModalState extends State<AIScribeSuggestion> {
@override
Widget build(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
final modalWidth = screenWidth < AIScribeSizes.mobileBreakpoint
? screenWidth * AIScribeSizes.mobileWidthPercentage
: AIScribeSizes.modalMaxWidthLargeScreen;
return PointerInterceptor(
child: Material(
borderRadius: BorderRadius.circular(AIScribeSizes.menuBorderRadius),
child: Container(
width: modalWidth,
constraints: const BoxConstraints(
maxHeight: AIScribeSizes.modalMaxHeight,
),
decoration: BoxDecoration(
color: AIScribeColors.background,
borderRadius: BorderRadius.circular(AIScribeSizes.menuBorderRadius),
),
child: FutureBuilder<String>(
future: widget.suggestionFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return _buildLoadingState();
} else if (snapshot.hasError) {
return _buildErrorState();
} else if (snapshot.hasData) {
return _buildSuccessState(snapshot.data!);
} else {
return _buildErrorState();
}
},
),
),
),
);
}
Widget _buildLoadingState() {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildHeader(),
Flexible(
fit: FlexFit.loose,
child: Container(
height: AIScribeSizes.infoHeight,
padding: AIScribeSizes.suggestionInfoPadding,
child: const Center(
child: CircularProgressIndicator(),
),
),
),
],
);
}
Widget _buildSuccessState(String suggestion) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildHeader(),
Flexible(
fit: FlexFit.loose,
child: Container(
padding: AIScribeSizes.suggestionContentPadding,
child: SingleChildScrollView(
child: SelectableText(
suggestion,
style: AIScribeTextStyles.suggestionContent,
),
),
),
),
_buildFooter(suggestion),
],
);
}
Widget _buildErrorState() {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildHeader(),
Flexible(
fit: FlexFit.loose,
child: Container(
height: AIScribeSizes.infoHeight,
padding: AIScribeSizes.suggestionInfoPadding,
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.error_outline,
size: AIScribeSizes.iconSize,
color: Colors.red,
),
const SizedBox(height: AIScribeSizes.fieldSpacing),
Text(
ScribeLocalizations.of(context)!.failedToGenerate,
style: AIScribeTextStyles.suggestionContent,
textAlign: TextAlign.center,
)
],
),
),
),
],
);
}
Widget _buildHeader() {
return Container(
padding: AIScribeSizes.suggestionHeaderPadding,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
widget.title,
style: AIScribeTextStyles.suggestionTitle
),
IconButton(
icon: const Icon(
Icons.close,
size: AIScribeSizes.iconSize,
),
iconSize: AIScribeSizes.iconSize,
color: Colors.grey[600],
onPressed: widget.onClose,
),
],
),
);
}
Widget _buildFooter(String suggestion) {
return Container(
padding: AIScribeSizes.suggestionFooterPadding,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
IconButton(
icon: const Icon(
Icons.content_copy,
size: AIScribeSizes.iconSize,
),
color: Colors.grey[600],
onPressed: () {
Clipboard.setData(ClipboardData(text: suggestion));
},
),
TMailButtonWidget(
text: ScribeLocalizations.of(context)!.insertButton,
textStyle: AIScribeButtonStyles.mainActionButtonText,
padding: AIScribeButtonStyles.mainActionButtonPadding,
backgroundColor: AIScribeButtonStyles.mainActionButtonBackgroundColor,
onTapActionCallback: () => widget.onInsert(suggestion),
)
],
),
);
}
}