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

Suggestions popup #28

Closed
wants to merge 16 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 163 additions & 1 deletion lib/core/controllers/colored_text_editing_controller.dart
Original file line number Diff line number Diff line change
@@ -1,12 +1,25 @@
import 'package:flutter/material.dart';
import 'package:flutter/gestures.dart';

import 'package:languagetool_textfield/core/enums/mistake_type.dart';
import 'package:languagetool_textfield/domain/highlight_style.dart';
import 'package:languagetool_textfield/domain/language_check_service.dart';
import 'package:languagetool_textfield/domain/mistake.dart';
import 'package:languagetool_textfield/presentation/suggestions_popup.dart';

/// A TextEditingController with overrides buildTextSpan for building
/// marked TextSpans with tap recognizer
class ColoredTextEditingController extends TextEditingController {
/// An [OverlayEntry] for the [Overlay] class.
/// This entry represents the floating popup
/// that appears on a mistake.
OverlayEntry? _overlayEntry;

/// Represents the maximum numbers of suggestions allowed.
final int suggestionsLimit;

static const int _defaultSuggestionLimit = 4;

/// Color scheme to highlight mistakes
final HighlightStyle highlightStyle;

Expand All @@ -16,6 +29,17 @@ class ColoredTextEditingController extends TextEditingController {
/// List which contains Mistake objects spans are built from
List<Mistake> _mistakes = [];

/// A builder function to build a custom mistake widget.
/// If it is not provided then a default widget will be displayed.
final Widget Function(
String name,
String message,
Color color,
List<String> replacements,
Function(String) onSuggestionTap,
Function() onClose,
)? mistakeBuilder;
Comment on lines +34 to +41
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. No need to provide the colour ourselves. Let it be resolved dynamically from MistakeType.
  2. Use typedef for better readability.
Suggested change
final Widget Function(
String name,
String message,
Color color,
List<String> replacements,
Function(String) onSuggestionTap,
Function() onClose,
)? mistakeBuilder;
typedef ReplacementSuggestionsBuilder = Widget Function(
Mistake mistake,
List<String> replacements,
Function(String) onSuggestionTap,
Function() onClose,
);
final ReplacementSuggestionsBuilder? mistakeBuilder;


@override
set value(TextEditingValue newValue) {
_handleTextChange(newValue.text);
Expand All @@ -25,21 +49,33 @@ class ColoredTextEditingController extends TextEditingController {
/// Controller constructor
ColoredTextEditingController({
required this.languageCheckService,
this.mistakeBuilder,
this.highlightStyle = const HighlightStyle(),
this.suggestionsLimit = _defaultSuggestionLimit,
});

/// Clear mistakes list when text mas modified and get a new list of mistakes
/// via API
Future<void> _handleTextChange(String newText) async {
///set value triggers each time, even when cursor changes its location
///so this check avoid cleaning Mistake list when text wasn't really changed
if (newText.length == text.length) return;
if (newText == text) return;
_mistakes.clear();
final mistakes = await languageCheckService.findMistakes(newText);
if (mistakes.isNotEmpty) {
_mistakes = mistakes;
notifyListeners();
}

/// To remove the overlay when new text is entered in the field.
_removeHighlightOverlay();
}

@override
void dispose() {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Move this to the bottom of the class.
That's company convention -- all cleanup/disposal should be placed at the very bottom.

/// Removing the overlay when the controller gets disposed.
_removeHighlightOverlay();
super.dispose();
}

/// Generates TextSpan from Mistake list
Expand All @@ -50,6 +86,7 @@ class ColoredTextEditingController extends TextEditingController {
required bool withComposing,
}) {
final formattedTextSpans = _generateSpans(
context: context,
style: style,
);

Expand All @@ -58,8 +95,27 @@ class ColoredTextEditingController extends TextEditingController {
);
}

Widget _widgetBuilder(
String name,
String message,
Color color,
List<String> replacements,
void Function(String) onSuggestionTap,
void Function() onClose,
) {
return SuggestionsPopup(
mistakeName: name,
mistakeMessage: message,
mistakeColor: color,
replacements: replacements,
onTapCallback: onSuggestionTap,
closeCallBack: onClose,
);
}

/// Generator function to create TextSpan instances
Iterable<TextSpan> _generateSpans({
required BuildContext context,
TextStyle? style,
}) sync* {
int currentOffset = 0; // enter index
Expand All @@ -77,10 +133,73 @@ class ColoredTextEditingController extends TextEditingController {
/// Get a highlight color
final Color mistakeColor = _getMistakeColor(mistake.type);

/// Number of suggestions depends on a limit.
/// By default it is being set in _defaultSuggestionLimit
final List<String> replacements =
mistake.replacements.length <= suggestionsLimit
? mistake.replacements
: mistake.replacements.sublist(0, suggestionsLimit);

/// Parsing the mistake enum types to string type
final String mistakeName = mistake.name;

/// Mistake highlighted TextSpan
yield TextSpan(
children: [
TextSpan(
recognizer: TapGestureRecognizer()
..onTapDown = (TapDownDetails pressDetails) {
/// getting the position of the user's finger
final position = pressDetails.globalPosition;
final screenWidth = MediaQuery.of(context).size.width;
final containerWidth = screenWidth / 1.5;

final newDx = _calculateDxOfPopup(
position.dx,
screenWidth,
containerWidth,
);

final newDy = _calculateDyOfPopup(context, position.dy);

/// To remove overlay if present.
_removeHighlightOverlay();

final callback = mistakeBuilder ?? _widgetBuilder;

final overlayEntry = OverlayEntry(
builder: (BuildContext context) {
return Positioned(
top: newDy,
left: newDx,
child: Material(
type: MaterialType.transparency,
child: SizedBox(
width: containerWidth,
child: callback(
mistakeName,
mistake.message,
mistakeColor,
replacements,
(newValue) {
text = text.replaceRange(
mistake.offset,
mistake.offset + mistake.length,
newValue,
);
_removeHighlightOverlay();
},
_removeHighlightOverlay,
),
),
),
);
},
);

_overlayEntry = overlayEntry;
Overlay.of(context).insert(overlayEntry);
},
text:
text.substring(mistake.offset, mistake.offset + mistake.length),
mouseCursor: MaterialStateMouseCursor.clickable,
Expand All @@ -106,6 +225,11 @@ class ColoredTextEditingController extends TextEditingController {
);
}

void _removeHighlightOverlay() {
_overlayEntry?.remove();
_overlayEntry = null;
}

/// Returns color for mistake TextSpan style
Color _getMistakeColor(MistakeType type) {
switch (type) {
Expand All @@ -125,4 +249,42 @@ class ColoredTextEditingController extends TextEditingController {
return highlightStyle.otherMistakeColor;
}
}

/// Calculates the new x axis for the popup so that it wont exceed the screen.
double _calculateDxOfPopup(
double dxOfTap,
double screenWidth,
double containerWidth,
) {
/// The x axis point after which the popup exceeds the screen
final dxBoundary = screenWidth - containerWidth;

/// Calculating final x axis
final newDx = dxOfTap >= dxBoundary ? dxBoundary : dxOfTap;

return newDx;
}

/// Calculates y axis for the popup so that it appears right below the mistake
double _calculateDyOfPopup(BuildContext context, double originalDy) {
/// getting the rendering information of the TextSpan widget
final textSpanRenderBox = context.findRenderObject() as RenderBox?;

/// This will be the default y axis
/// if the rendering information is not available
var newDy = originalDy + 10;

if (textSpanRenderBox != null) {
/// Top-Left corner of the TextSpan widget
final offset = textSpanRenderBox.localToGlobal(Offset.zero);

final textSpanHeight = textSpanRenderBox.size.height;

/// Adding the pixels in the height of text span to
/// the top left corner point of text span
newDy = offset.dy + textSpanHeight;
}

return newDy;
}
}
4 changes: 4 additions & 0 deletions lib/domain/mistake.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ class Mistake {
/// Sorted by probability.
final List<String> replacements;

/// Name of the mistake in string form.
/// The first letter will be capitalized
String get name => type.name[0].toUpperCase() + type.name.substring(1);

/// Creates a new instance of the [Mistake] class.
const Mistake({
required this.message,
Expand Down
119 changes: 119 additions & 0 deletions lib/presentation/suggestions_popup.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import 'package:flutter/material.dart';

/// A [StatelessWidget] that is inserted in the [Overlay].
/// This widget represents the pop up that appears when a mistake is present in
/// the text.
class SuggestionsPopup extends StatelessWidget {
/// represents the [Color] of the mistake for which
/// the [SuggestionsPopup] is being displayed.
final Color mistakeColor;

/// represents the name of the mistake.
final String mistakeName;

/// The description of the mistake.
final String mistakeMessage;

/// List that contains the replacement for the given mistake
final List<String> replacements;

/// A callback that will run when a suggestion is pressed.
final void Function(String suggestion) onTapCallback;

/// A callback function that will pop [SuggestionsPopup] from the [Overlay]
final void Function() closeCallBack;

static const double _mistakeCircleSize = 10.0;
static const double _iconSize = 20.0;

/// Constructor for the [SuggestionsPopup] widget.
const SuggestionsPopup({
required this.mistakeName,
required this.mistakeMessage,
required this.mistakeColor,
required this.replacements,
required this.onTapCallback,
required this.closeCallBack,
Key? key,
}) : super(key: key);

@override
Widget build(BuildContext context) {
return Card(
child: Container(
padding: const EdgeInsets.all(10.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: _mistakeCircleSize,
height: _mistakeCircleSize,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: mistakeColor,
),
),
const SizedBox(width: 5.0),
Expanded(
child: Text(
mistakeName,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
),
IconButton(
icon: const Icon(Icons.close),
iconSize: _iconSize,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
onPressed: closeCallBack,
),
],
),
const SizedBox(height: 20.0),
Text(
mistakeMessage,
softWrap: true,
),
const SizedBox(height: 20.0),
Wrap(
children: replacements
.map(
(elem) => GestureDetector(
onTap: () {
onTapCallback(elem);
},
child: Container(
margin: const EdgeInsets.only(
right: 5.0,
bottom: 5.0,
),
decoration: const BoxDecoration(
color: Colors.lightBlue,
borderRadius: BorderRadius.all(
Radius.circular(5.0),
),
),
padding: const EdgeInsets.all(8.0),
child: Text(
elem,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
),
)
.toList(),
),
],
),
),
);
}
}