Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fixed popup overflow by suggestions #35

Merged
merged 12 commits into from
May 29, 2023
11 changes: 6 additions & 5 deletions lib/domain/typedefs.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
165 changes: 113 additions & 52 deletions lib/utils/mistake_popup.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
Expand All @@ -54,60 +54,121 @@ 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;
const _mistakeNameFontSize = 13.0;
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);
}
}
47 changes: 9 additions & 38 deletions lib/utils/popup_overlay_renderer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -38,10 +26,7 @@ class PopupOverlayRenderer {
children: [
CustomSingleChildLayout(
delegate: PopupOverlayLayoutDelegate(position),
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: width),
child: popupBuilder(context),
),
child: popupBuilder(context),
),
],
),
Expand All @@ -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) {
Expand All @@ -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);
Expand Down