diff --git a/lib/domain/typedefs.dart b/lib/domain/typedefs.dart index 353381b..41ca87e 100644 --- a/lib/domain/typedefs.dart +++ b/lib/domain/typedefs.dart @@ -4,11 +4,12 @@ import 'package:languagetool_textfield/domain/mistake.dart'; import 'package:languagetool_textfield/utils/popup_overlay_renderer.dart'; /// Callback used to build popup body -typedef MistakeBuilderCallback = Widget Function( - PopupOverlayRenderer popupRenderer, - Mistake mistake, - ColoredTextEditingController controller, -); +typedef MistakeBuilderCallback = Widget Function({ + required PopupOverlayRenderer popupRenderer, + required Mistake mistake, + required ColoredTextEditingController controller, + required Offset mistakePosition, +}); /// Function called after mistake was clicked typedef ShowPopupCallback = void Function( diff --git a/lib/utils/mistake_popup.dart b/lib/utils/mistake_popup.dart index b669add..cb0f78c 100644 --- a/lib/utils/mistake_popup.dart +++ b/lib/utils/mistake_popup.dart @@ -1,3 +1,5 @@ +import 'dart:math'; + import 'package:flutter/material.dart'; import 'package:languagetool_textfield/domain/typedefs.dart'; import 'package:languagetool_textfield/languagetool_textfield.dart'; @@ -21,29 +23,27 @@ class MistakePopup { Offset popupPosition, ColoredTextEditingController controller, ) { + final MistakeBuilderCallback builder = + mistakeBuilder ?? LanguageToolMistakePopup.new; + popupRenderer.render( context, position: popupPosition, - popupBuilder: (context) => - mistakeBuilder?.call(popupRenderer, mistake, controller) ?? - LanguageToolMistakePopup( - popupRenderer: popupRenderer, - mistake: mistake, - controller: controller, - ), + popupBuilder: (context) => builder.call( + popupRenderer: popupRenderer, + mistake: mistake, + controller: controller, + mistakePosition: popupPosition, + ), ); } } /// Default mistake window that looks similar to LanguageTool popup class LanguageToolMistakePopup extends StatelessWidget { - /// [LanguageToolMistakePopup] constructor - const LanguageToolMistakePopup({ - super.key, - required this.popupRenderer, - required this.mistake, - required this.controller, - }); + static const double _defaultVerticalMargin = 25.0; + static const double _defaultHorizontalMargin = 10.0; + static const double _defaultMaxWidth = 250.0; /// Renderer used to display this window. final PopupOverlayRenderer popupRenderer; @@ -54,6 +54,37 @@ class LanguageToolMistakePopup extends StatelessWidget { /// Controller of the text where mistake was found final ColoredTextEditingController controller; + /// An on-screen position of the mistake + final Offset mistakePosition; + + /// A maximum width of the popup. + /// If infinity, the popup will use all the available horizontal space. + final double maxWidth; + + /// A maximum height of the popup. + /// If infinity, the popup will use all the available height between the + /// [mistakePosition] and the furthest border of the layout constraints. + final double maxHeight; + + /// Horizontal popup margin. + final double horizontalMargin; + + /// Vertical popup margin. + final double verticalMargin; + + /// [LanguageToolMistakePopup] constructor + const LanguageToolMistakePopup({ + super.key, + required this.popupRenderer, + required this.mistake, + required this.controller, + required this.mistakePosition, + this.maxWidth = _defaultMaxWidth, + this.maxHeight = double.infinity, + this.horizontalMargin = _defaultHorizontalMargin, + this.verticalMargin = _defaultVerticalMargin, + }); + @override Widget build(BuildContext context) { const _borderRadius = 10.0; @@ -61,53 +92,83 @@ class LanguageToolMistakePopup extends StatelessWidget { const _mistakeMessageFontSize = 15.0; const _replacementButtonsSpacing = 10.0; - return Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - boxShadow: const [BoxShadow(color: Colors.grey, blurRadius: 20)], - color: Colors.white, - borderRadius: BorderRadius.circular(_borderRadius), + const padding = 10.0; + + final availableSpace = _calculateAvailableSpace(context); + + return ConstrainedBox( + constraints: BoxConstraints( + maxWidth: maxWidth, + maxHeight: availableSpace, ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - // mistake type - Text( - mistake.type.name, - style: TextStyle( - color: Colors.grey.shade700, - fontSize: _mistakeNameFontSize, - fontWeight: FontWeight.w500, + child: Container( + margin: EdgeInsets.symmetric( + horizontal: horizontalMargin, + vertical: verticalMargin, + ), + padding: const EdgeInsets.all(padding), + decoration: BoxDecoration( + boxShadow: const [BoxShadow(color: Colors.grey, blurRadius: 20)], + color: Colors.white, + borderRadius: BorderRadius.circular(_borderRadius), + ), + child: CustomScrollView( + shrinkWrap: true, + slivers: [ + SliverToBoxAdapter( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // mistake type + Text( + mistake.type.name, + style: TextStyle( + color: Colors.grey.shade700, + fontSize: _mistakeNameFontSize, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: padding), + + // mistake message + Text( + mistake.message, + style: const TextStyle(fontSize: _mistakeMessageFontSize), + ), + const SizedBox(height: padding), + ], + ), ), - ), - const SizedBox(height: 10), - - // mistake message - Text( - mistake.message, - style: const TextStyle(fontSize: _mistakeMessageFontSize), - ), - const SizedBox(height: 10), - - // replacements - Wrap( - spacing: _replacementButtonsSpacing, - direction: Axis.horizontal, - children: mistake.replacements - .map( - (replacement) => ElevatedButton( + SliverList.builder( + itemCount: mistake.replacements.length, + itemBuilder: (context, index) { + final replacement = mistake.replacements[index]; + + return Padding( + padding: const EdgeInsets.all(_replacementButtonsSpacing / 2), + child: ElevatedButton( onPressed: () { controller.replaceMistake(mistake, replacement); popupRenderer.dismiss(); }, child: Text(replacement), ), - ) - .toList(growable: false), - ), - ], + ); + }, + ), + ], + ), ), ); } + + double _calculateAvailableSpace(BuildContext context) { + final mediaQuery = MediaQuery.of(context); + + final availableSpaceBottom = mediaQuery.size.height - mistakePosition.dy; + final availableSpaceTop = mistakePosition.dy; + + return min(max(availableSpaceBottom, availableSpaceTop), maxHeight); + } } diff --git a/lib/utils/popup_overlay_renderer.dart b/lib/utils/popup_overlay_renderer.dart index 9cf980f..7ec71f5 100644 --- a/lib/utils/popup_overlay_renderer.dart +++ b/lib/utils/popup_overlay_renderer.dart @@ -2,24 +2,12 @@ import 'dart:math'; import 'package:flutter/material.dart'; -/// defaultPopupWidth -const defaultPopupWidth = 250.0; - -/// defaultHorizontalPadding -const defaultHorizontalPadding = 10.0; - -/// defaultVerticalMargin -const defaultVerticalPadding = 30.0; - /// Renderer used to show popup window overlay class PopupOverlayRenderer { OverlayEntry? _overlayEntry; - /// Max width of popup window - final double width; - /// [PopupOverlayRenderer] constructor - PopupOverlayRenderer({this.width = defaultPopupWidth}); + PopupOverlayRenderer(); /// Render overlay entry on the screen with dismiss logic OverlayEntry render( @@ -38,10 +26,7 @@ class PopupOverlayRenderer { children: [ CustomSingleChildLayout( delegate: PopupOverlayLayoutDelegate(position), - child: ConstrainedBox( - constraints: BoxConstraints(maxWidth: width), - child: popupBuilder(context), - ), + child: popupBuilder(context), ), ], ), @@ -63,25 +48,11 @@ class PopupOverlayRenderer { /// Class that calculates where to place popup window on the screen class PopupOverlayLayoutDelegate extends SingleChildLayoutDelegate { - /// max width of popup window - final double width; - /// desired position of popup window final Offset position; - /// padding of screen for popup window - final double horizontalPadding; - - /// vertical distance to offset from [position] - final double verticalPadding; - /// [PopupOverlayLayoutDelegate] constructor - const PopupOverlayLayoutDelegate( - this.position, { - this.width = defaultPopupWidth, - this.horizontalPadding = defaultHorizontalPadding, - this.verticalPadding = defaultVerticalPadding, - }); + const PopupOverlayLayoutDelegate(this.position); @override Offset getPositionForChild(Size size, Size childSize) { @@ -96,21 +67,21 @@ class PopupOverlayLayoutDelegate extends SingleChildLayoutDelegate { ); double dx = _popupRect.left; // limiting X offset - dx = max(horizontalPadding, dx); + dx = max(0, dx); final rightBorderPosition = dx + childSize.width; final rightScreenBorderOverflow = rightBorderPosition - size.width; - if (rightScreenBorderOverflow >= 0) { - dx -= rightScreenBorderOverflow + horizontalPadding; + if (rightScreenBorderOverflow > 0) { + dx -= rightScreenBorderOverflow; } // under the desired position - double dy = position.dy + verticalPadding; + double dy = max(0, position.dy); final bottomBorderPosition = dy + childSize.height; final bottomScreenBorderOverflow = bottomBorderPosition - size.height; // if not enough space underneath, rendering above the desired position - if (bottomScreenBorderOverflow >= 0) { + if (bottomScreenBorderOverflow > 0) { final newBottomBorderPosition = position.dy - childSize.height; - dy = newBottomBorderPosition - verticalPadding; + dy = newBottomBorderPosition; } return Offset(dx, dy);