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/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_input.dart';
export 'scribe/ai/presentation/model/modal/anchored_modal_layout_result.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_cross_axis_alignment.dart';
export 'scribe/ai/presentation/model/modal/modal_placement.dart'; export 'scribe/ai/presentation/model/modal/modal_placement.dart';
export 'scribe/ai/presentation/model/text_selection_model.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:flutter/animation.dart';
import 'package:scribe/scribe.dart'; import 'package:scribe/scribe.dart';
@@ -138,4 +140,42 @@ class AnchoredModalLayoutCalculator {
return anchorStart + anchorSize - menuSize; 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 'dart:math';
import 'package:core/presentation/resources/image_paths.dart'; 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:dartz/dartz.dart' as dartz;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
@@ -32,147 +34,164 @@ class AiScribeSuggestionWidget extends StatefulWidget {
@override @override
State<AiScribeSuggestionWidget> createState() => State<AiScribeSuggestionWidget> createState() =>
_AiScribeSuggestionWidgetModalState(); _AiScribeSuggestionWidgetState();
} }
class _AiScribeSuggestionWidgetModalState class _AiScribeSuggestionWidgetState extends State<AiScribeSuggestionWidget> {
extends State<AiScribeSuggestionWidget> { GenerateAITextInteractor? _interactor;
GenerateAITextInteractor? _generateAITextInteractor;
final ValueNotifier<dynamic> _valueNotifier = final ValueNotifier<dartz.Either<Failure, Success>> _state =
ValueNotifier(dartz.Right(GenerateAITextLoading())); ValueNotifier(dartz.Right(GenerateAITextLoading()));
@override @override
void initState() { void initState() {
super.initState(); super.initState();
if (Get.isRegistered<GenerateAITextInteractor>()) {
_generateAITextInteractor = Get.find<GenerateAITextInteractor>(); if (!Get.isRegistered<GenerateAITextInteractor>()) {
_getAiSuggestion( _state.value = dartz.Left(
_generateAITextInteractor!,
widget.aiAction,
widget.content,
);
} else {
_valueNotifier.value = dartz.Left(
GenerateAITextFailure( GenerateAITextFailure(
GenerateAITextInteractorIsNotRegisteredException(), GenerateAITextInteractorIsNotRegisteredException(),
), ),
); );
return;
} }
_interactor = Get.find<GenerateAITextInteractor>();
_loadSuggestion();
} }
Future<void> _getAiSuggestion( Future<void> _loadSuggestion() async {
GenerateAITextInteractor interactor, final result = await _interactor!.execute(
AIAction action, widget.aiAction,
String? content, widget.content,
) async { );
final result = await interactor.execute(action, content);
result.fold((failure) { result.fold(
_valueNotifier.value = failure; (failure) => _state.value = dartz.Left(failure),
}, (success) { (success) => _state.value = dartz.Right(success),
_valueNotifier.value = success; );
});
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final localizations = ScribeLocalizations.of(context);
final screenSize = MediaQuery.sizeOf(context); final screenSize = MediaQuery.sizeOf(context);
final modalWidth = min( final modalWidth = min(
screenSize.width * AIScribeSizes.mobileFactor, screenSize.width * AIScribeSizes.mobileFactor,
AIScribeSizes.suggestionModalMaxWidth, AIScribeSizes.suggestionModalMaxWidth,
); );
final modalHeight = min(
final modalMaxHeight = min(
screenSize.height * AIScribeSizes.mobileFactor, screenSize.height * AIScribeSizes.mobileFactor,
AIScribeSizes.suggestionModalMaxHeight, AIScribeSizes.suggestionModalMaxHeight,
); );
final dialogContent = Container( final dialogContent = _buildDialogContent(context);
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,
);
}
},
),
),
],
),
);
if (widget.buttonPosition != null && widget.buttonSize != null) { if (!_hasAnchor) {
final layoutResult = AnchoredModalLayoutCalculator.calculate( return Center(
input: AnchoredModalLayoutInput( child: _buildModalContainer(
screenSize: MediaQuery.of(context).size, width: modalWidth,
anchorPosition: widget.buttonPosition!, maxHeight: modalMaxHeight,
anchorSize: widget.buttonSize!, child: dialogContent,
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,
),
],
),
), ),
); );
} }
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;
} }