From b122928a3861a084261f067870d5ce8f8739ec69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Poizat?= Date: Wed, 10 Dec 2025 10:51:09 +0100 Subject: [PATCH] Add methods to display AI scribe --- .../ai/presentation/widgets/ai_scribe.dart | 219 ++++++++++++++++++ 1 file changed, 219 insertions(+) create mode 100644 scribe/lib/scribe/ai/presentation/widgets/ai_scribe.dart diff --git a/scribe/lib/scribe/ai/presentation/widgets/ai_scribe.dart b/scribe/lib/scribe/ai/presentation/widgets/ai_scribe.dart new file mode 100644 index 000000000..a732d1301 --- /dev/null +++ b/scribe/lib/scribe/ai/presentation/widgets/ai_scribe.dart @@ -0,0 +1,219 @@ +import 'package:flutter/material.dart'; +import 'package:dio/dio.dart'; +import 'package:pointer_interceptor/pointer_interceptor.dart'; +import 'package:core/presentation/resources/image_paths.dart'; +import 'package:core/utils/app_logger.dart'; +import 'package:scribe/scribe/ai/data/datasource_impl/ai_datasource_impl.dart'; +import 'package:scribe/scribe/ai/data/repository/ai_repository_impl.dart'; +import 'package:scribe/scribe/ai/domain/state/generate_ai_text_state.dart'; +import 'package:scribe/scribe/ai/domain/usecases/generate_ai_text_interactor.dart'; +import 'package:scribe/scribe/ai/presentation/model/ai_action.dart'; +import 'package:scribe/scribe/ai/presentation/model/ai_scribe_menu_action.dart'; +import 'package:scribe/scribe/ai/presentation/styles/ai_scribe_styles.dart'; +import 'package:scribe/scribe/ai/presentation/widgets/ai_scribe_bar.dart'; +import 'package:scribe/scribe/ai/presentation/widgets/ai_scribe_menu.dart'; +import 'package:scribe/scribe/ai/presentation/widgets/ai_scribe_suggestion.dart'; + +typedef AIScribeResultCallback = void Function(String result); + +Future showAIScribeDialog({ + required BuildContext context, + required ImagePaths imagePaths, + required String content, + required AIScribeResultCallback onInsertText, + List? availableCategories, + Offset? buttonPosition, +}) async { + final hasContent = content.isNotEmpty; + + final selectedAction = await showDialog( + context: context, + barrierDismissible: true, + builder: (context) { + final dialogContent = Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (hasContent) ...[ + Material( + color: Colors.white, + elevation: AIScribeSizes.dialogElevation, + borderRadius: BorderRadius.circular(AIScribeSizes.menuBorderRadius), + child: SizedBox( + width: AIScribeSizes.menuWidth, + child: AIScribeMenu( + onActionSelected: (action) { + Navigator.of(context).pop(PredefinedAction(action)); + }, + availableCategories: availableCategories, + ), + ), + ), + const SizedBox(height: AIScribeSizes.fieldSpacing), + ], + Material( + color: Colors.white, + elevation: AIScribeSizes.dialogElevation, + borderRadius: BorderRadius.circular(AIScribeSizes.menuBorderRadius), + child: SizedBox( + width: AIScribeSizes.barWidth, + child: AIScribeBar( + onCustomPrompt: (customPrompt) { + Navigator.of(context).pop(CustomPromptAction(customPrompt)); + }, + imagePaths: imagePaths + ), + ), + ), + ], + ); + + if (buttonPosition != null) { + final position = _calculateModalPosition( + context: context, + buttonPosition: buttonPosition, + modalWidth: AIScribeSizes.barWidth, + ); + + return PointerInterceptor( + child: GestureDetector( + onTap: () { + Navigator.of(context).pop(); + }, + behavior: HitTestBehavior.opaque, + child: Stack( + children: [ + Positioned( + left: position.left, + bottom: position.bottom, + child: dialogContent, + ), + ], + ), + ), + ); + } + + return Center(child: dialogContent); + }, + ); + + if (selectedAction == null) { + log('showAIScribeDialog: No action selected'); + return; + } + + if (!context.mounted) return; + + await showDialog( + context: context, + barrierDismissible: true, + builder: (context) { + final suggestionFuture = _executeAIRequest(selectedAction, content); + final title = selectedAction.getLabel(context); + + final modalContent = AIScribeSuggestion( + title: title, + suggestionFuture: suggestionFuture, + onClose: () => Navigator.of(context).pop(), + onInsert: (result) { + onInsertText(result); + Navigator.of(context).pop(); + }, + imagePaths: imagePaths, + ); + + if (buttonPosition != null) { + final screenSize = MediaQuery.of(context).size; + final modalWidth = screenSize.width < AIScribeSizes.mobileBreakpoint + ? screenSize.width * AIScribeSizes.mobileWidthPercentage + : AIScribeSizes.modalMaxWidthLargeScreen; + + final position = _calculateModalPosition( + context: context, + buttonPosition: buttonPosition, + modalWidth: modalWidth, + modalHeight: AIScribeSizes.modalMaxHeight, + ); + + return PointerInterceptor( + child: GestureDetector( + onTap: () { + Navigator.of(context).pop(); + }, + behavior: HitTestBehavior.opaque, + child: Stack( + children: [ + Positioned( + left: position.left, + bottom: position.bottom, + child: modalContent, + ), + ], + ), + ), + ); + } + + return Center(child: modalContent); + }, + ); +} + +({double left, double bottom}) _calculateModalPosition({ + required BuildContext context, + required Offset buttonPosition, + required double modalWidth, + double? modalHeight, +}) { + final screenSize = MediaQuery.of(context).size; + + // Calculate position above the button + double left = buttonPosition.dx; + double bottom = screenSize.height - buttonPosition.dy + AIScribeSizes.fieldSpacing; + + // Ensure modal doesn't go off screen horizontally + if (left + modalWidth > screenSize.width) { + left = screenSize.width - modalWidth - AIScribeSizes.screenEdgePadding; + } + if (left < AIScribeSizes.screenEdgePadding) { + left = AIScribeSizes.screenEdgePadding; + } + + // Ensure modal doesn't go off screen vertically (if height is provided) + if (modalHeight != null) { + if (bottom + modalHeight > screenSize.height) { + bottom = screenSize.height - modalHeight - AIScribeSizes.screenEdgePadding; + } + if (bottom < AIScribeSizes.screenEdgePadding) { + bottom = AIScribeSizes.screenEdgePadding; + } + } + + return (left: left, bottom: bottom); +} + +Future _executeAIRequest( + AIAction action, + String content, +) async { + final dio = Dio(); + final dataSource = AIDataSourceImpl(dio: dio); + final repository = AIScribeRepositoryImpl(dataSource); + final interactor = GenerateAITextInteractor(repository); + + final result = await interactor.execute(action, content); + + return result.fold( + (failure) { + throw Exception('Failed to generate AI response: $failure'); + }, + (success) { + if (success is GenerateAITextSuccess) { + return success.response.result; + } else { + throw Exception('Unexpected success state: $success'); + } + }, + ); +} \ No newline at end of file