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:
@@ -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": "Initializing data...",
|
||||||
"@initializing_data": {
|
"@initializing_data": {
|
||||||
"type": "text",
|
"type": "text",
|
||||||
|
|||||||
@@ -102,24 +102,12 @@
|
|||||||
"placeholders_order": [],
|
"placeholders_order": [],
|
||||||
"placeholders": {}
|
"placeholders": {}
|
||||||
},
|
},
|
||||||
"generatingResponse": "Generating AI response...",
|
|
||||||
"@generatingResponse": {
|
|
||||||
"type": "text",
|
|
||||||
"placeholders_order": [],
|
|
||||||
"placeholders": {}
|
|
||||||
},
|
|
||||||
"failedToGenerate": "Failed to generate AI response",
|
"failedToGenerate": "Failed to generate AI response",
|
||||||
"@failedToGenerate": {
|
"@failedToGenerate": {
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"placeholders_order": [],
|
"placeholders_order": [],
|
||||||
"placeholders": {}
|
"placeholders": {}
|
||||||
},
|
},
|
||||||
"noDataReceived": "No data received",
|
|
||||||
"@noDataReceived": {
|
|
||||||
"type": "text",
|
|
||||||
"placeholders_order": [],
|
|
||||||
"placeholders": {}
|
|
||||||
},
|
|
||||||
"insertButton": "Insert",
|
"insertButton": "Insert",
|
||||||
"@insertButton": {
|
"@insertButton": {
|
||||||
"type": "text",
|
"type": "text",
|
||||||
|
|||||||
@@ -102,24 +102,12 @@
|
|||||||
"placeholders_order": [],
|
"placeholders_order": [],
|
||||||
"placeholders": {}
|
"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": "Échec de la génération de la réponse IA",
|
||||||
"@failedToGenerate": {
|
"@failedToGenerate": {
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"placeholders_order": [],
|
"placeholders_order": [],
|
||||||
"placeholders": {}
|
"placeholders": {}
|
||||||
},
|
},
|
||||||
"noDataReceived": "Aucune donnée reçue",
|
|
||||||
"@noDataReceived": {
|
|
||||||
"type": "text",
|
|
||||||
"placeholders_order": [],
|
|
||||||
"placeholders": {}
|
|
||||||
},
|
|
||||||
"insertButton": "Insérer",
|
"insertButton": "Insérer",
|
||||||
"@insertButton": {
|
"@insertButton": {
|
||||||
"type": "text",
|
"type": "text",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"@@last_modified": "2025-12-09T13:09:52.614790",
|
"@@last_modified": "2025-12-10T16:32:03.004307",
|
||||||
"categoryCorrectGrammar": "Correct grammar",
|
"categoryCorrectGrammar": "Correct grammar",
|
||||||
"@categoryCorrectGrammar": {
|
"@categoryCorrectGrammar": {
|
||||||
"type": "text",
|
"type": "text",
|
||||||
@@ -102,24 +102,12 @@
|
|||||||
"placeholders_order": [],
|
"placeholders_order": [],
|
||||||
"placeholders": {}
|
"placeholders": {}
|
||||||
},
|
},
|
||||||
"generatingResponse": "Generating AI response...",
|
|
||||||
"@generatingResponse": {
|
|
||||||
"type": "text",
|
|
||||||
"placeholders_order": [],
|
|
||||||
"placeholders": {}
|
|
||||||
},
|
|
||||||
"failedToGenerate": "Failed to generate AI response",
|
"failedToGenerate": "Failed to generate AI response",
|
||||||
"@failedToGenerate": {
|
"@failedToGenerate": {
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"placeholders_order": [],
|
"placeholders_order": [],
|
||||||
"placeholders": {}
|
"placeholders": {}
|
||||||
},
|
},
|
||||||
"noDataReceived": "No data received",
|
|
||||||
"@noDataReceived": {
|
|
||||||
"type": "text",
|
|
||||||
"placeholders_order": [],
|
|
||||||
"placeholders": {}
|
|
||||||
},
|
|
||||||
"insertButton": "Insert",
|
"insertButton": "Insert",
|
||||||
"@insertButton": {
|
"@insertButton": {
|
||||||
"type": "text",
|
"type": "text",
|
||||||
|
|||||||
@@ -102,24 +102,12 @@
|
|||||||
"placeholders_order": [],
|
"placeholders_order": [],
|
||||||
"placeholders": {}
|
"placeholders": {}
|
||||||
},
|
},
|
||||||
"generatingResponse": "Генерация ответа ИИ...",
|
|
||||||
"@generatingResponse": {
|
|
||||||
"type": "text",
|
|
||||||
"placeholders_order": [],
|
|
||||||
"placeholders": {}
|
|
||||||
},
|
|
||||||
"failedToGenerate": "Не удалось сгенерировать ответ ИИ",
|
"failedToGenerate": "Не удалось сгенерировать ответ ИИ",
|
||||||
"@failedToGenerate": {
|
"@failedToGenerate": {
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"placeholders_order": [],
|
"placeholders_order": [],
|
||||||
"placeholders": {}
|
"placeholders": {}
|
||||||
},
|
},
|
||||||
"noDataReceived": "Данные не получены",
|
|
||||||
"@noDataReceived": {
|
|
||||||
"type": "text",
|
|
||||||
"placeholders_order": [],
|
|
||||||
"placeholders": {}
|
|
||||||
},
|
|
||||||
"insertButton": "Вставить",
|
"insertButton": "Вставить",
|
||||||
"@insertButton": {
|
"@insertButton": {
|
||||||
"type": "text",
|
"type": "text",
|
||||||
|
|||||||
@@ -102,24 +102,12 @@
|
|||||||
"placeholders_order": [],
|
"placeholders_order": [],
|
||||||
"placeholders": {}
|
"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": "Không thể tạo phản hồi AI",
|
||||||
"@failedToGenerate": {
|
"@failedToGenerate": {
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"placeholders_order": [],
|
"placeholders_order": [],
|
||||||
"placeholders": {}
|
"placeholders": {}
|
||||||
},
|
},
|
||||||
"noDataReceived": "Không nhận được dữ liệu",
|
|
||||||
"@noDataReceived": {
|
|
||||||
"type": "text",
|
|
||||||
"placeholders_order": [],
|
|
||||||
"placeholders": {}
|
|
||||||
},
|
|
||||||
"insertButton": "Chèn",
|
"insertButton": "Chèn",
|
||||||
"@insertButton": {
|
"@insertButton": {
|
||||||
"type": "text",
|
"type": "text",
|
||||||
|
|||||||
@@ -112,21 +112,11 @@ class ScribeLocalizations {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Suggestion Dialog
|
// Suggestion Dialog
|
||||||
String get generatingResponse {
|
|
||||||
return Intl.message('Generating AI response...',
|
|
||||||
name: 'generatingResponse');
|
|
||||||
}
|
|
||||||
|
|
||||||
String get failedToGenerate {
|
String get failedToGenerate {
|
||||||
return Intl.message('Failed to generate AI response',
|
return Intl.message('Failed to generate AI response',
|
||||||
name: 'failedToGenerate');
|
name: 'failedToGenerate');
|
||||||
}
|
}
|
||||||
|
|
||||||
String get noDataReceived {
|
|
||||||
return Intl.message('No data received',
|
|
||||||
name: 'noDataReceived');
|
|
||||||
}
|
|
||||||
|
|
||||||
String get insertButton {
|
String get insertButton {
|
||||||
return Intl.message('Insert',
|
return Intl.message('Insert',
|
||||||
name: 'insertButton');
|
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),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user