feat(ai-scribe): Avoid the prompt being obscured when there is a lot of content.

This commit is contained in:
dab246
2025-12-18 15:55:13 +07:00
committed by Dat H. Pham
parent 3e337d7f3d
commit 96b73aa971
4 changed files with 184 additions and 109 deletions
+1
View File
@@ -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';
@@ -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,
});
}
@@ -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,
);
}
}
@@ -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<AiScribeSuggestionWidget> createState() =>
_AiScribeSuggestionWidgetModalState();
_AiScribeSuggestionWidgetState();
}
class _AiScribeSuggestionWidgetModalState
extends State<AiScribeSuggestionWidget> {
GenerateAITextInteractor? _generateAITextInteractor;
final ValueNotifier<dynamic> _valueNotifier =
class _AiScribeSuggestionWidgetState extends State<AiScribeSuggestionWidget> {
GenerateAITextInteractor? _interactor;
final ValueNotifier<dartz.Either<Failure, Success>> _state =
ValueNotifier(dartz.Right(GenerateAITextLoading()));
@override
void initState() {
super.initState();
if (Get.isRegistered<GenerateAITextInteractor>()) {
_generateAITextInteractor = Get.find<GenerateAITextInteractor>();
_getAiSuggestion(
_generateAITextInteractor!,
widget.aiAction,
widget.content,
);
} else {
_valueNotifier.value = dartz.Left(
if (!Get.isRegistered<GenerateAITextInteractor>()) {
_state.value = dartz.Left(
GenerateAITextFailure(
GenerateAITextInteractorIsNotRegisteredException(),
),
);
return;
}
_interactor = Get.find<GenerateAITextInteractor>();
_loadSuggestion();
}
Future<void> _getAiSuggestion(
GenerateAITextInteractor interactor,
AIAction action,
String? content,
) async {
final result = await interactor.execute(action, content);
Future<void> _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<dartz.Either<Failure, Success>>(
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;
}