From 96b73aa971608a2351552db73e6abeb8dc596441 Mon Sep 17 00:00:00 2001 From: dab246 Date: Thu, 18 Dec 2025 15:55:13 +0700 Subject: [PATCH] feat(ai-scribe): Avoid the prompt being obscured when there is a lot of content. --- scribe/lib/scribe.dart | 1 + .../anchored_suggestion_layout_result.dart | 15 ++ .../anchored_modal_layout_calculator.dart | 40 +++ .../modal/ai_scribe_suggestion_widget.dart | 237 ++++++++++-------- 4 files changed, 184 insertions(+), 109 deletions(-) create mode 100644 scribe/lib/scribe/ai/presentation/model/modal/anchored_suggestion_layout_result.dart diff --git a/scribe/lib/scribe.dart b/scribe/lib/scribe.dart index 8d9107881..c8be70284 100644 --- a/scribe/lib/scribe.dart +++ b/scribe/lib/scribe.dart @@ -16,6 +16,7 @@ export 'scribe/ai/presentation/model/context_menu/ai_scribe_context_menu_action. export 'scribe/ai/presentation/model/context_menu/ai_scribe_suggestion_actions.dart'; export 'scribe/ai/presentation/model/modal/anchored_modal_layout_input.dart'; export 'scribe/ai/presentation/model/modal/anchored_modal_layout_result.dart'; +export 'scribe/ai/presentation/model/modal/anchored_suggestion_layout_result.dart'; export 'scribe/ai/presentation/model/modal/modal_cross_axis_alignment.dart'; export 'scribe/ai/presentation/model/modal/modal_placement.dart'; export 'scribe/ai/presentation/model/text_selection_model.dart'; diff --git a/scribe/lib/scribe/ai/presentation/model/modal/anchored_suggestion_layout_result.dart b/scribe/lib/scribe/ai/presentation/model/modal/anchored_suggestion_layout_result.dart new file mode 100644 index 000000000..0101928d4 --- /dev/null +++ b/scribe/lib/scribe/ai/presentation/model/modal/anchored_suggestion_layout_result.dart @@ -0,0 +1,15 @@ +import 'package:flutter/cupertino.dart'; + +class AnchoredSuggestionLayoutResult { + final Offset offset; + final double availableHeight; + final double? top; + final double? bottom; + + const AnchoredSuggestionLayoutResult({ + required this.offset, + required this.availableHeight, + this.top, + this.bottom, + }); +} diff --git a/scribe/lib/scribe/ai/presentation/utils/modal/anchored_modal_layout_calculator.dart b/scribe/lib/scribe/ai/presentation/utils/modal/anchored_modal_layout_calculator.dart index 2e4ea6e25..ec85d4e5a 100644 --- a/scribe/lib/scribe/ai/presentation/utils/modal/anchored_modal_layout_calculator.dart +++ b/scribe/lib/scribe/ai/presentation/utils/modal/anchored_modal_layout_calculator.dart @@ -1,3 +1,5 @@ +import 'dart:math'; + import 'package:flutter/animation.dart'; import 'package:scribe/scribe.dart'; @@ -138,4 +140,42 @@ class AnchoredModalLayoutCalculator { return anchorStart + anchorSize - menuSize; } } + + static AnchoredSuggestionLayoutResult calculateAnchoredSuggestLayout({ + required Size screenSize, + required Offset anchorPosition, + required Size anchorSize, + required double modalMaxHeight, + ModalPlacement? preferredPlacement, + double gap = 8, + double padding = AIScribeSizes.screenEdgePadding, + }) { + final isTop = preferredPlacement == ModalPlacement.top; + + if (isTop) { + final availableHeight = anchorPosition.dy - padding - gap; + final positionBottom = screenSize.height - anchorPosition.dy + gap; + + return AnchoredSuggestionLayoutResult( + offset: Offset( + anchorPosition.dx, + positionBottom, + ), + availableHeight: availableHeight, + bottom: positionBottom, + ); + } + + final spaceBelow = screenSize.height - anchorPosition.dy - padding - gap; + final positionTop = anchorPosition.dx + anchorSize.width + gap; + + return AnchoredSuggestionLayoutResult( + offset: Offset( + positionTop, + anchorPosition.dy, + ), + availableHeight: min(spaceBelow, modalMaxHeight), + top: anchorPosition.dy, + ); + } } diff --git a/scribe/lib/scribe/ai/presentation/widgets/modal/ai_scribe_suggestion_widget.dart b/scribe/lib/scribe/ai/presentation/widgets/modal/ai_scribe_suggestion_widget.dart index 529002370..b4b8c9b58 100644 --- a/scribe/lib/scribe/ai/presentation/widgets/modal/ai_scribe_suggestion_widget.dart +++ b/scribe/lib/scribe/ai/presentation/widgets/modal/ai_scribe_suggestion_widget.dart @@ -1,6 +1,8 @@ import 'dart:math'; import 'package:core/presentation/resources/image_paths.dart'; +import 'package:core/presentation/state/failure.dart'; +import 'package:core/presentation/state/success.dart'; import 'package:dartz/dartz.dart' as dartz; import 'package:flutter/material.dart'; import 'package:get/get.dart'; @@ -32,147 +34,164 @@ class AiScribeSuggestionWidget extends StatefulWidget { @override State createState() => - _AiScribeSuggestionWidgetModalState(); + _AiScribeSuggestionWidgetState(); } -class _AiScribeSuggestionWidgetModalState - extends State { - GenerateAITextInteractor? _generateAITextInteractor; - final ValueNotifier _valueNotifier = +class _AiScribeSuggestionWidgetState extends State { + GenerateAITextInteractor? _interactor; + + final ValueNotifier> _state = ValueNotifier(dartz.Right(GenerateAITextLoading())); @override void initState() { super.initState(); - if (Get.isRegistered()) { - _generateAITextInteractor = Get.find(); - _getAiSuggestion( - _generateAITextInteractor!, - widget.aiAction, - widget.content, - ); - } else { - _valueNotifier.value = dartz.Left( + + if (!Get.isRegistered()) { + _state.value = dartz.Left( GenerateAITextFailure( GenerateAITextInteractorIsNotRegisteredException(), ), ); + return; } + + _interactor = Get.find(); + _loadSuggestion(); } - Future _getAiSuggestion( - GenerateAITextInteractor interactor, - AIAction action, - String? content, - ) async { - final result = await interactor.execute(action, content); + Future _loadSuggestion() async { + final result = await _interactor!.execute( + widget.aiAction, + widget.content, + ); - result.fold((failure) { - _valueNotifier.value = failure; - }, (success) { - _valueNotifier.value = success; - }); + result.fold( + (failure) => _state.value = dartz.Left(failure), + (success) => _state.value = dartz.Right(success), + ); } @override Widget build(BuildContext context) { - final localizations = ScribeLocalizations.of(context); final screenSize = MediaQuery.sizeOf(context); + final modalWidth = min( screenSize.width * AIScribeSizes.mobileFactor, AIScribeSizes.suggestionModalMaxWidth, ); - final modalHeight = min( + + final modalMaxHeight = min( screenSize.height * AIScribeSizes.mobileFactor, AIScribeSizes.suggestionModalMaxHeight, ); - final dialogContent = Container( - width: modalWidth, - constraints: BoxConstraints( - minHeight: AIScribeSizes.suggestionModalMinHeight, - maxHeight: modalHeight, - ), - decoration: BoxDecoration( - color: AIScribeColors.background, - borderRadius: const BorderRadius.all( - Radius.circular(AIScribeSizes.menuRadius), - ), - boxShadow: AIScribeShadows.modal, - ), - padding: AIScribeSizes.suggestionContentPadding, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - spacing: 8, - children: [ - AiScribeSuggestionHeader( - title: widget.aiAction.getLabel(localizations), - imagePaths: widget.imagePaths, - ), - Flexible( - child: ValueListenableBuilder( - valueListenable: _valueNotifier, - builder: (_, value, __) { - if (value is GenerateAITextSuccess) { - return AiScribeSuggestionSuccess( - imagePaths: widget.imagePaths, - suggestionText: value.response.result, - onSelectAction: widget.onSelectAiScribeSuggestionAction, - ); - } else if (value is GenerateAITextFailure) { - return AiScribeSuggestionError(imagePaths: widget.imagePaths); - } else { - return AiScribeSuggestionLoading( - imagePaths: widget.imagePaths, - ); - } - }, - ), - ), - ], - ), - ); + final dialogContent = _buildDialogContent(context); - if (widget.buttonPosition != null && widget.buttonSize != null) { - final layoutResult = AnchoredModalLayoutCalculator.calculate( - input: AnchoredModalLayoutInput( - screenSize: MediaQuery.of(context).size, - anchorPosition: widget.buttonPosition!, - anchorSize: widget.buttonSize!, - menuSize: Size( - modalWidth, - AIScribeSizes.suggestionModalMinHeight, - ), - preferredPlacement: widget.preferredPlacement, - crossAxisAlignment: widget.crossAxisAlignment, - ), - padding: AIScribeSizes.screenEdgePadding, - ); - - final position = layoutResult.position; - - final top = widget.preferredPlacement == ModalPlacement.top - ? position.dy - AIScribeSizes.modalWithoutContentSpacing - : position.dy; - - return PointerInterceptor( - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: Navigator.of(context).pop, - child: Stack( - children: [ - PositionedDirectional( - start: position.dx, - top: top, - child: dialogContent, - ), - ], - ), + if (!_hasAnchor) { + return Center( + child: _buildModalContainer( + width: modalWidth, + maxHeight: modalMaxHeight, + child: dialogContent, ), ); } - return Center(child: dialogContent); + final layout = AnchoredModalLayoutCalculator.calculateAnchoredSuggestLayout( + screenSize: screenSize, + anchorPosition: widget.buttonPosition!, + anchorSize: widget.buttonSize!, + modalMaxHeight: modalMaxHeight, + preferredPlacement: widget.preferredPlacement, + ); + + return PointerInterceptor( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: Navigator.of(context).pop, + child: Stack( + children: [ + PositionedDirectional( + start: layout.offset.dx, + top: layout.top, + bottom: layout.bottom, + child: _buildModalContainer( + width: modalWidth, + maxHeight: layout.availableHeight, + child: dialogContent, + ), + ), + ], + ), + ), + ); } + + Widget _buildDialogContent(BuildContext context) { + final localizations = ScribeLocalizations.of(context); + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8, + children: [ + AiScribeSuggestionHeader( + title: widget.aiAction.getLabel(localizations), + imagePaths: widget.imagePaths, + ), + Flexible( + child: ValueListenableBuilder>( + valueListenable: _state, + builder: (_, state, __) { + return state.fold( + (_) => AiScribeSuggestionError( + imagePaths: widget.imagePaths, + ), + (value) { + if (value is GenerateAITextSuccess) { + return AiScribeSuggestionSuccess( + imagePaths: widget.imagePaths, + suggestionText: value.response.result, + onSelectAction: widget.onSelectAiScribeSuggestionAction, + ); + } + + return AiScribeSuggestionLoading( + imagePaths: widget.imagePaths, + ); + }, + ); + }, + ), + ), + ], + ); + } + + Widget _buildModalContainer({ + required double width, + required double maxHeight, + required Widget child, + }) { + return Container( + width: width, + constraints: BoxConstraints( + minHeight: AIScribeSizes.suggestionModalMinHeight, + maxHeight: maxHeight, + ), + padding: AIScribeSizes.suggestionContentPadding, + decoration: BoxDecoration( + color: AIScribeColors.background, + borderRadius: BorderRadius.circular( + AIScribeSizes.menuRadius, + ), + boxShadow: AIScribeShadows.modal, + ), + child: child, + ); + } + + bool get _hasAnchor => + widget.buttonPosition != null && widget.buttonSize != null; }