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": {
|
||||
"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",
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user