From dced5b5dc7e2c375aa81ec59229b9637e768bef0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Poizat?= Date: Wed, 10 Dec 2025 10:50:35 +0100 Subject: [PATCH] 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 --- lib/l10n/intl_messages.arb | 2 +- scribe/lib/scribe/ai/l10n/intl_en.arb | 12 - scribe/lib/scribe/ai/l10n/intl_fr.arb | 12 - scribe/lib/scribe/ai/l10n/intl_messages.arb | 14 +- scribe/lib/scribe/ai/l10n/intl_ru.arb | 12 - scribe/lib/scribe/ai/l10n/intl_vi.arb | 12 - .../localizations/scribe_localizations.dart | 10 - .../presentation/styles/ai_scribe_styles.dart | 82 ++++++ .../presentation/widgets/ai_scribe_bar.dart | 89 +++++++ .../widgets/ai_scribe_button.dart | 28 ++ .../presentation/widgets/ai_scribe_menu.dart | 250 ++++++++++++++++++ .../widgets/ai_scribe_suggestion.dart | 198 ++++++++++++++ 12 files changed, 649 insertions(+), 72 deletions(-) create mode 100644 scribe/lib/scribe/ai/presentation/styles/ai_scribe_styles.dart create mode 100644 scribe/lib/scribe/ai/presentation/widgets/ai_scribe_bar.dart create mode 100644 scribe/lib/scribe/ai/presentation/widgets/ai_scribe_button.dart create mode 100644 scribe/lib/scribe/ai/presentation/widgets/ai_scribe_menu.dart create mode 100644 scribe/lib/scribe/ai/presentation/widgets/ai_scribe_suggestion.dart diff --git a/lib/l10n/intl_messages.arb b/lib/l10n/intl_messages.arb index ec2a57bcb..e70ce5321 100644 --- a/lib/l10n/intl_messages.arb +++ b/lib/l10n/intl_messages.arb @@ -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", diff --git a/scribe/lib/scribe/ai/l10n/intl_en.arb b/scribe/lib/scribe/ai/l10n/intl_en.arb index 139ccf8ea..72657c82a 100644 --- a/scribe/lib/scribe/ai/l10n/intl_en.arb +++ b/scribe/lib/scribe/ai/l10n/intl_en.arb @@ -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", diff --git a/scribe/lib/scribe/ai/l10n/intl_fr.arb b/scribe/lib/scribe/ai/l10n/intl_fr.arb index 0a53f2e9a..6d910f946 100644 --- a/scribe/lib/scribe/ai/l10n/intl_fr.arb +++ b/scribe/lib/scribe/ai/l10n/intl_fr.arb @@ -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", diff --git a/scribe/lib/scribe/ai/l10n/intl_messages.arb b/scribe/lib/scribe/ai/l10n/intl_messages.arb index ac45198fa..1ca452e3f 100644 --- a/scribe/lib/scribe/ai/l10n/intl_messages.arb +++ b/scribe/lib/scribe/ai/l10n/intl_messages.arb @@ -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", diff --git a/scribe/lib/scribe/ai/l10n/intl_ru.arb b/scribe/lib/scribe/ai/l10n/intl_ru.arb index b894b86fb..bb406d97b 100644 --- a/scribe/lib/scribe/ai/l10n/intl_ru.arb +++ b/scribe/lib/scribe/ai/l10n/intl_ru.arb @@ -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", diff --git a/scribe/lib/scribe/ai/l10n/intl_vi.arb b/scribe/lib/scribe/ai/l10n/intl_vi.arb index 255caaaf7..3fa174a21 100644 --- a/scribe/lib/scribe/ai/l10n/intl_vi.arb +++ b/scribe/lib/scribe/ai/l10n/intl_vi.arb @@ -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", diff --git a/scribe/lib/scribe/ai/localizations/scribe_localizations.dart b/scribe/lib/scribe/ai/localizations/scribe_localizations.dart index 1b7bfb76d..5c6e4f90a 100644 --- a/scribe/lib/scribe/ai/localizations/scribe_localizations.dart +++ b/scribe/lib/scribe/ai/localizations/scribe_localizations.dart @@ -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'); diff --git a/scribe/lib/scribe/ai/presentation/styles/ai_scribe_styles.dart b/scribe/lib/scribe/ai/presentation/styles/ai_scribe_styles.dart new file mode 100644 index 000000000..a0cb83eab --- /dev/null +++ b/scribe/lib/scribe/ai/presentation/styles/ai_scribe_styles.dart @@ -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 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); +} diff --git a/scribe/lib/scribe/ai/presentation/widgets/ai_scribe_bar.dart b/scribe/lib/scribe/ai/presentation/widgets/ai_scribe_bar.dart new file mode 100644 index 000000000..ae8ae2dfe --- /dev/null +++ b/scribe/lib/scribe/ai/presentation/widgets/ai_scribe_bar.dart @@ -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 createState() => _AIScribeBarState(); +} + +class _AIScribeBarState extends State { + 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, + ) + ], + ), + ); + } +} diff --git a/scribe/lib/scribe/ai/presentation/widgets/ai_scribe_button.dart b/scribe/lib/scribe/ai/presentation/widgets/ai_scribe_button.dart new file mode 100644 index 000000000..9d9bfb8da --- /dev/null +++ b/scribe/lib/scribe/ai/presentation/widgets/ai_scribe_button.dart @@ -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, + ); + } +} diff --git a/scribe/lib/scribe/ai/presentation/widgets/ai_scribe_menu.dart b/scribe/lib/scribe/ai/presentation/widgets/ai_scribe_menu.dart new file mode 100644 index 000000000..ec51f9ad1 --- /dev/null +++ b/scribe/lib/scribe/ai/presentation/widgets/ai_scribe_menu.dart @@ -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? availableCategories; + + const AIScribeMenu({ + super.key, + required this.onActionSelected, + this.useSubmenuItemStyle = true, + this.availableCategories, + }); + + @override + State createState() => _AIScribeMenuContentState(); +} + +class _AIScribeMenuContentState extends State { + AIScribeMenuCategory? _hoveredCategory; + final Map _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(), + ), + ), + ), + ), + ), + ], + ); + } +} diff --git a/scribe/lib/scribe/ai/presentation/widgets/ai_scribe_suggestion.dart b/scribe/lib/scribe/ai/presentation/widgets/ai_scribe_suggestion.dart new file mode 100644 index 000000000..520a9c857 --- /dev/null +++ b/scribe/lib/scribe/ai/presentation/widgets/ai_scribe_suggestion.dart @@ -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 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 createState() => _AIScribeSuggestionModalState(); +} + +class _AIScribeSuggestionModalState extends State { + @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( + 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), + ) + ], + ), + ); + } +}