diff --git a/.gitignore b/.gitignore index 96486fd..0c5355d 100644 --- a/.gitignore +++ b/.gitignore @@ -23,7 +23,7 @@ migrate_working_dir/ # Flutter/Dart/Pub related # Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. -/pubspec.lock +**/pubspec.lock **/doc/api/ .dart_tool/ .packages diff --git a/README.md b/README.md index 02fe8ec..562cd67 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,8 @@ and the Flutter guide for [developing packages and plugins](https://flutter.dev/developing-packages). --> +![languagetool popup presentation](readme/languagetool.gif) + TODO: Put a short description of the package here that helps potential users know whether this package might be useful for them. diff --git a/example/android/build.gradle b/example/android/build.gradle index 58a8c74..713d7f6 100644 --- a/example/android/build.gradle +++ b/example/android/build.gradle @@ -26,6 +26,6 @@ subprojects { project.evaluationDependsOn(':app') } -task clean(type: Delete) { +tasks.register("clean", Delete) { delete rootProject.buildDir } diff --git a/example/lib/app.dart b/example/lib/app.dart index b6b2ad6..fa7dcba 100644 --- a/example/lib/app.dart +++ b/example/lib/app.dart @@ -25,16 +25,39 @@ class _AppState extends State { final ColoredTextEditingController _controller = ColoredTextEditingController(languageCheckService: _debouncedLangService); + static const List alignments = [ + MainAxisAlignment.center, + MainAxisAlignment.start, + MainAxisAlignment.end, + ]; + int currentAlignmentIndex = 0; + @override Widget build(BuildContext context) { return Material( - child: LanguageToolTextField( - style: const TextStyle(), - decoration: const InputDecoration(), - mistakeBuilder: () { - return Container(); - }, - coloredController: _controller, + child: Scaffold( + body: Column( + mainAxisAlignment: alignments[currentAlignmentIndex], + children: [ + LanguageToolTextField( + style: const TextStyle(), + decoration: const InputDecoration(), + coloredController: _controller, + mistakePopup: MistakePopup(popupRenderer: PopupOverlayRenderer()), + ), + DropdownMenu( + hintText: "Select alignment...", + onSelected: (value) => setState(() { + currentAlignmentIndex = value ?? 0; + }), + dropdownMenuEntries: const [ + DropdownMenuEntry(value: 0, label: "Center alignment"), + DropdownMenuEntry(value: 1, label: "Top alignment"), + DropdownMenuEntry(value: 2, label: "Bottom alignment"), + ], + ) + ], + ), ), ); } diff --git a/example/pubspec.lock b/example/pubspec.lock deleted file mode 100644 index 6cae1f7..0000000 --- a/example/pubspec.lock +++ /dev/null @@ -1,404 +0,0 @@ -# Generated by pub -# See https://dart.dev/tools/pub/glossary#lockfile -packages: - _fe_analyzer_shared: - dependency: transitive - description: - name: _fe_analyzer_shared - sha256: d976d24314f193899a3079b14fe336215a63a3b1e1c3743eabba8f83e049e9a9 - url: "https://pub.dev" - source: hosted - version: "49.0.0" - analyzer: - dependency: transitive - description: - name: analyzer - sha256: "40ba2c6d2ab41a66476f8f1f099da6be0795c1b47221f5e2c5f8ad6048cdffae" - url: "https://pub.dev" - source: hosted - version: "5.1.0" - analyzer_plugin: - dependency: transitive - description: - name: analyzer_plugin - sha256: c1d5f167683de03d5ab6c3b53fc9aeefc5d59476e7810ba7bbddff50c6f4392d - url: "https://pub.dev" - source: hosted - version: "0.11.2" - ansicolor: - dependency: transitive - description: - name: ansicolor - sha256: "607f8fa9786f392043f169898923e6c59b4518242b68b8862eb8a8b7d9c30b4a" - url: "https://pub.dev" - source: hosted - version: "2.0.1" - args: - dependency: transitive - description: - name: args - sha256: "4cab82a83ffef80b262ddedf47a0a8e56ee6fbf7fe21e6e768b02792034dd440" - url: "https://pub.dev" - source: hosted - version: "2.4.0" - async: - dependency: transitive - description: - name: async - sha256: bfe67ef28df125b7dddcea62755991f807aa39a2492a23e1550161692950bbe0 - url: "https://pub.dev" - source: hosted - version: "2.10.0" - boolean_selector: - dependency: transitive - description: - name: boolean_selector - sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" - url: "https://pub.dev" - source: hosted - version: "2.1.1" - characters: - dependency: transitive - description: - name: characters - sha256: e6a326c8af69605aec75ed6c187d06b349707a27fbff8222ca9cc2cff167975c - url: "https://pub.dev" - source: hosted - version: "1.2.1" - clock: - dependency: transitive - description: - name: clock - sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf - url: "https://pub.dev" - source: hosted - version: "1.1.1" - collection: - dependency: transitive - description: - name: collection - sha256: cfc915e6923fe5ce6e153b0723c753045de46de1b4d63771530504004a45fae0 - url: "https://pub.dev" - source: hosted - version: "1.17.0" - convert: - dependency: transitive - description: - name: convert - sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592" - url: "https://pub.dev" - source: hosted - version: "3.1.1" - crypto: - dependency: transitive - description: - name: crypto - sha256: aa274aa7774f8964e4f4f38cc994db7b6158dd36e9187aaceaddc994b35c6c67 - url: "https://pub.dev" - source: hosted - version: "3.0.2" - csslib: - dependency: transitive - description: - name: csslib - sha256: b36c7f7e24c0bdf1bf9a3da461c837d1de64b9f8beb190c9011d8c72a3dfd745 - url: "https://pub.dev" - source: hosted - version: "0.17.2" - dart_code_metrics: - dependency: transitive - description: - name: dart_code_metrics - sha256: "219607f5abbf4c0d254ca39ee009f9ff28df91c40aef26718fde15af6b7a6c24" - url: "https://pub.dev" - source: hosted - version: "4.21.3" - dart_style: - dependency: transitive - description: - name: dart_style - sha256: "5be16bf1707658e4c03078d4a9b90208ded217fb02c163e207d334082412f2fb" - url: "https://pub.dev" - source: hosted - version: "2.2.5" - fake_async: - dependency: transitive - description: - name: fake_async - sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" - url: "https://pub.dev" - source: hosted - version: "1.3.1" - file: - dependency: transitive - description: - name: file - sha256: "1b92bec4fc2a72f59a8e15af5f52cd441e4a7860b49499d69dfa817af20e925d" - url: "https://pub.dev" - source: hosted - version: "6.1.4" - flutter: - dependency: "direct main" - description: flutter - source: sdk - version: "0.0.0" - flutter_test: - dependency: "direct dev" - description: flutter - source: sdk - version: "0.0.0" - glob: - dependency: transitive - description: - name: glob - sha256: "4515b5b6ddb505ebdd242a5f2cc5d22d3d6a80013789debfbda7777f47ea308c" - url: "https://pub.dev" - source: hosted - version: "2.1.1" - html: - dependency: transitive - description: - name: html - sha256: "79d498e6d6761925a34ee5ea8fa6dfef38607781d2fa91e37523474282af55cb" - url: "https://pub.dev" - source: hosted - version: "0.15.2" - http: - dependency: transitive - description: - name: http - sha256: "6aa2946395183537c8b880962d935877325d6a09a2867c3970c05c0fed6ac482" - url: "https://pub.dev" - source: hosted - version: "0.13.5" - http_parser: - dependency: transitive - description: - name: http_parser - sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" - url: "https://pub.dev" - source: hosted - version: "4.0.2" - js: - dependency: transitive - description: - name: js - sha256: "5528c2f391ededb7775ec1daa69e65a2d61276f7552de2b5f7b8d34ee9fd4ab7" - url: "https://pub.dev" - source: hosted - version: "0.6.5" - json_annotation: - dependency: transitive - description: - name: json_annotation - sha256: c33da08e136c3df0190bd5bbe51ae1df4a7d96e7954d1d7249fea2968a72d317 - url: "https://pub.dev" - source: hosted - version: "4.8.0" - language_tool: - dependency: transitive - description: - name: language_tool - sha256: "90ceb6f0a0b57fb3a5b88be82ffd676c90639cd06d622d25f76add30d5a2acd6" - url: "https://pub.dev" - source: hosted - version: "2.1.1" - languagetool_textfield: - dependency: "direct dev" - description: - path: ".." - relative: true - source: path - version: "0.0.1" - matcher: - dependency: transitive - description: - name: matcher - sha256: "16db949ceee371e9b99d22f88fa3a73c4e59fd0afed0bd25fc336eb76c198b72" - url: "https://pub.dev" - source: hosted - version: "0.12.13" - material_color_utilities: - dependency: transitive - description: - name: material_color_utilities - sha256: d92141dc6fe1dad30722f9aa826c7fbc896d021d792f80678280601aff8cf724 - url: "https://pub.dev" - source: hosted - version: "0.2.0" - meta: - dependency: transitive - description: - name: meta - sha256: "6c268b42ed578a53088d834796959e4a1814b5e9e164f147f580a386e5decf42" - url: "https://pub.dev" - source: hosted - version: "1.8.0" - package_config: - dependency: transitive - description: - name: package_config - sha256: "1c5b77ccc91e4823a5af61ee74e6b972db1ef98c2ff5a18d3161c982a55448bd" - url: "https://pub.dev" - source: hosted - version: "2.1.0" - path: - dependency: transitive - description: - name: path - sha256: db9d4f58c908a4ba5953fcee2ae317c94889433e5024c27ce74a37f94267945b - url: "https://pub.dev" - source: hosted - version: "1.8.2" - petitparser: - dependency: transitive - description: - name: petitparser - sha256: "49392a45ced973e8d94a85fdb21293fbb40ba805fc49f2965101ae748a3683b4" - url: "https://pub.dev" - source: hosted - version: "5.1.0" - platform: - dependency: transitive - description: - name: platform - sha256: "4a451831508d7d6ca779f7ac6e212b4023dd5a7d08a27a63da33756410e32b76" - url: "https://pub.dev" - source: hosted - version: "3.1.0" - process: - dependency: transitive - description: - name: process - sha256: "53fd8db9cec1d37b0574e12f07520d582019cb6c44abf5479a01505099a34a09" - url: "https://pub.dev" - source: hosted - version: "4.2.4" - pub_semver: - dependency: transitive - description: - name: pub_semver - sha256: "307de764d305289ff24ad257ad5c5793ce56d04947599ad68b3baa124105fc17" - url: "https://pub.dev" - source: hosted - version: "2.1.3" - pub_updater: - dependency: transitive - description: - name: pub_updater - sha256: "42890302ab2672adf567dc2b20e55b4ecc29d7e19c63b6b98143ab68dd717d3a" - url: "https://pub.dev" - source: hosted - version: "0.2.4" - sky_engine: - dependency: transitive - description: flutter - source: sdk - version: "0.0.99" - solid_lints: - dependency: "direct dev" - description: - name: solid_lints - sha256: "5c8e7de1244bc9701ed9b0e5c29886308d0275c4ac9c9a953c7b2bcf2454a7ec" - url: "https://pub.dev" - source: hosted - version: "0.0.14" - source_span: - dependency: transitive - description: - name: source_span - sha256: dd904f795d4b4f3b870833847c461801f6750a9fa8e61ea5ac53f9422b31f250 - url: "https://pub.dev" - source: hosted - version: "1.9.1" - stack_trace: - dependency: transitive - description: - name: stack_trace - sha256: c3c7d8edb15bee7f0f74debd4b9c5f3c2ea86766fe4178eb2a18eb30a0bdaed5 - url: "https://pub.dev" - source: hosted - version: "1.11.0" - stream_channel: - dependency: transitive - description: - name: stream_channel - sha256: "83615bee9045c1d322bbbd1ba209b7a749c2cbcdcb3fdd1df8eb488b3279c1c8" - url: "https://pub.dev" - source: hosted - version: "2.1.1" - string_scanner: - dependency: transitive - description: - name: string_scanner - sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" - url: "https://pub.dev" - source: hosted - version: "1.2.0" - term_glyph: - dependency: transitive - description: - name: term_glyph - sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 - url: "https://pub.dev" - source: hosted - version: "1.2.1" - test_api: - dependency: transitive - description: - name: test_api - sha256: ad540f65f92caa91bf21dfc8ffb8c589d6e4dc0c2267818b4cc2792857706206 - url: "https://pub.dev" - source: hosted - version: "0.4.16" - throttling: - dependency: transitive - description: - name: throttling - sha256: "0b328adb283db092373b2835574fbcdf7d0e472d24f0455717c836ef9824def9" - url: "https://pub.dev" - source: hosted - version: "1.0.0" - typed_data: - dependency: transitive - description: - name: typed_data - sha256: "26f87ade979c47a150c9eaab93ccd2bebe70a27dc0b4b29517f2904f04eb11a5" - url: "https://pub.dev" - source: hosted - version: "1.3.1" - vector_math: - dependency: transitive - description: - name: vector_math - sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" - url: "https://pub.dev" - source: hosted - version: "2.1.4" - watcher: - dependency: transitive - description: - name: watcher - sha256: "6a7f46926b01ce81bfc339da6a7f20afbe7733eff9846f6d6a5466aa4c6667c0" - url: "https://pub.dev" - source: hosted - version: "1.0.2" - xml: - dependency: transitive - description: - name: xml - sha256: "979ee37d622dec6365e2efa4d906c37470995871fe9ae080d967e192d88286b5" - url: "https://pub.dev" - source: hosted - version: "6.2.2" - yaml: - dependency: transitive - description: - name: yaml - sha256: "23812a9b125b48d4007117254bca50abb6c712352927eece9e155207b1db2370" - url: "https://pub.dev" - source: hosted - version: "3.1.1" -sdks: - dart: ">=2.19.2 <3.0.0" - flutter: ">=1.17.0" diff --git a/lib/core/controllers/colored_text_editing_controller.dart b/lib/core/controllers/colored_text_editing_controller.dart index 3eb93cd..f499d50 100644 --- a/lib/core/controllers/colored_text_editing_controller.dart +++ b/lib/core/controllers/colored_text_editing_controller.dart @@ -1,8 +1,10 @@ +import 'package:flutter/gestures.dart'; import 'package:flutter/material.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/domain/typedefs.dart'; /// A TextEditingController with overrides buildTextSpan for building /// marked TextSpans with tap recognizer @@ -16,6 +18,12 @@ class ColoredTextEditingController extends TextEditingController { /// List which contains Mistake objects spans are built from List _mistakes = []; + /// List of that is used to dispose recognizers after mistakes rebuilt + final List _recognizers = []; + + /// Callback that will be executed after mistake clicked + ShowPopupCallback? showPopup; + @override set value(TextEditingValue newValue) { _handleTextChange(newValue.text); @@ -28,20 +36,6 @@ class ColoredTextEditingController extends TextEditingController { this.highlightStyle = const HighlightStyle(), }); - /// Clear mistakes list when text mas modified and get a new list of mistakes - /// via API - Future _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; - _mistakes.clear(); - final mistakes = await languageCheckService.findMistakes(newText); - if (mistakes.isNotEmpty) { - _mistakes = mistakes; - notifyListeners(); - } - } - /// Generates TextSpan from Mistake list @override TextSpan buildTextSpan({ @@ -50,6 +44,7 @@ class ColoredTextEditingController extends TextEditingController { required bool withComposing, }) { final formattedTextSpans = _generateSpans( + context, style: style, ); @@ -58,8 +53,36 @@ class ColoredTextEditingController extends TextEditingController { ); } + /// Replaces mistake with given replacement + void replaceMistake(Mistake mistake, String replacement) { + text = text.replaceRange(mistake.offset, mistake.endOffset, replacement); + _mistakes.remove(mistake); + selection = TextSelection.fromPosition( + TextPosition(offset: mistake.offset + replacement.length), + ); + } + + /// Clear mistakes list when text mas modified and get a new list of mistakes + /// via API + Future _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 == text) return; + + _mistakes.clear(); + for (final recognizer in _recognizers) { + recognizer.dispose(); + } + _recognizers.clear(); + + final mistakes = await languageCheckService.findMistakes(newText); + _mistakes = mistakes; + notifyListeners(); + } + /// Generator function to create TextSpan instances - Iterable _generateSpans({ + Iterable _generateSpans( + BuildContext context, { TextStyle? style, }) sync* { int currentOffset = 0; // enter index @@ -77,12 +100,20 @@ class ColoredTextEditingController extends TextEditingController { /// Get a highlight color final Color mistakeColor = _getMistakeColor(mistake.type); + /// Create a gesture recognizer for mistake + final _onTap = TapGestureRecognizer() + ..onTapDown = (details) { + showPopup?.call(context, mistake, details.globalPosition, this); + }; + + // /// Adding recognizer to the list for future disposing + _recognizers.add(_onTap); + /// Mistake highlighted TextSpan yield TextSpan( children: [ TextSpan( - text: - text.substring(mistake.offset, mistake.offset + mistake.length), + text: text.substring(mistake.offset, mistake.endOffset), mouseCursor: MaterialStateMouseCursor.clickable, style: style?.copyWith( backgroundColor: mistakeColor.withOpacity( @@ -92,11 +123,12 @@ class ColoredTextEditingController extends TextEditingController { decorationColor: mistakeColor, decorationThickness: highlightStyle.mistakeLineThickness, ), + recognizer: _onTap, ), ], ); - currentOffset = mistake.offset + mistake.length; + currentOffset = mistake.endOffset; } /// TextSpan after mistake diff --git a/lib/domain/mistake.dart b/lib/domain/mistake.dart index 7c556b0..537b208 100644 --- a/lib/domain/mistake.dart +++ b/lib/domain/mistake.dart @@ -19,6 +19,9 @@ class Mistake { /// Sorted by probability. final List replacements; + /// A position of the end of this mistake. + int get endOffset => offset + length; + /// Creates a new instance of the [Mistake] class. const Mistake({ required this.message, diff --git a/lib/domain/typedefs.dart b/lib/domain/typedefs.dart new file mode 100644 index 0000000..353381b --- /dev/null +++ b/lib/domain/typedefs.dart @@ -0,0 +1,19 @@ +import 'package:flutter/widgets.dart'; +import 'package:languagetool_textfield/core/controllers/colored_text_editing_controller.dart'; +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, +); + +/// Function called after mistake was clicked +typedef ShowPopupCallback = void Function( + BuildContext context, + Mistake mistake, + Offset mistakePosition, + ColoredTextEditingController controller, +); diff --git a/lib/languagetool_textfield.dart b/lib/languagetool_textfield.dart index 1050872..46d764d 100644 --- a/lib/languagetool_textfield.dart +++ b/lib/languagetool_textfield.dart @@ -9,4 +9,6 @@ export 'domain/mistake.dart'; export 'implementations/debounce_lang_tool_service.dart'; export 'implementations/lang_tool_service.dart'; export 'implementations/throttling_lang_tool_service.dart'; -export "presentation/language_tool_text_field.dart"; +export 'presentation/language_tool_text_field.dart'; +export 'utils/mistake_popup.dart'; +export 'utils/popup_overlay_renderer.dart'; diff --git a/lib/presentation/language_tool_text_field.dart b/lib/presentation/language_tool_text_field.dart index b51523c..3f9ceea 100644 --- a/lib/presentation/language_tool_text_field.dart +++ b/lib/presentation/language_tool_text_field.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:languagetool_textfield/core/controllers/colored_text_editing_controller.dart'; +import 'package:languagetool_textfield/utils/mistake_popup.dart'; /// A TextField widget that checks the grammar using the given /// [coloredController] @@ -10,19 +11,19 @@ class LanguageToolTextField extends StatefulWidget { /// A decoration of this [TextField]. final InputDecoration decoration; - /// A builder function used to build errors. - final Widget Function()? mistakeBuilder; - /// Color scheme to highlight mistakes final ColoredTextEditingController coloredController; + /// Mistake popup window + final MistakePopup mistakePopup; + /// Creates a widget that checks grammar errors. const LanguageToolTextField({ Key? key, required this.style, required this.decoration, - this.mistakeBuilder, required this.coloredController, + required this.mistakePopup, }) : super(key: key); @override @@ -30,6 +31,12 @@ class LanguageToolTextField extends StatefulWidget { } class _LanguageToolTextFieldState extends State { + @override + void initState() { + widget.coloredController.showPopup = widget.mistakePopup.show; + super.initState(); + } + @override Widget build(BuildContext context) { return Padding( diff --git a/lib/utils/mistake_popup.dart b/lib/utils/mistake_popup.dart new file mode 100644 index 0000000..b669add --- /dev/null +++ b/lib/utils/mistake_popup.dart @@ -0,0 +1,113 @@ +import 'package:flutter/material.dart'; +import 'package:languagetool_textfield/domain/typedefs.dart'; +import 'package:languagetool_textfield/languagetool_textfield.dart'; + +/// Builder class that uses specified [popupRenderer] and [mistakeBuilder] +/// to create mistake popup +class MistakePopup { + /// PopupRenderer class that used to render popup on the screen + final PopupOverlayRenderer popupRenderer; + + /// Optional builder function that creates popup widget + final MistakeBuilderCallback? mistakeBuilder; + + /// [MistakePopup] constructor + const MistakePopup({required this.popupRenderer, this.mistakeBuilder}); + + /// Show popup at specified [popupPosition] with info about [mistake] + void show( + BuildContext context, + Mistake mistake, + Offset popupPosition, + ColoredTextEditingController controller, + ) { + popupRenderer.render( + context, + position: popupPosition, + popupBuilder: (context) => + mistakeBuilder?.call(popupRenderer, mistake, controller) ?? + LanguageToolMistakePopup( + popupRenderer: popupRenderer, + mistake: mistake, + controller: controller, + ), + ); + } +} + +/// 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, + }); + + /// Renderer used to display this window. + final PopupOverlayRenderer popupRenderer; + + /// Mistake object + final Mistake mistake; + + /// Controller of the text where mistake was found + final ColoredTextEditingController controller; + + @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), + ), + 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: 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( + onPressed: () { + controller.replaceMistake(mistake, replacement); + popupRenderer.dismiss(); + }, + child: Text(replacement), + ), + ) + .toList(growable: false), + ), + ], + ), + ); + } +} diff --git a/lib/utils/popup_overlay_renderer.dart b/lib/utils/popup_overlay_renderer.dart new file mode 100644 index 0000000..9cf980f --- /dev/null +++ b/lib/utils/popup_overlay_renderer.dart @@ -0,0 +1,123 @@ +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}); + + /// Render overlay entry on the screen with dismiss logic + OverlayEntry render( + BuildContext context, { + required Offset position, + required WidgetBuilder popupBuilder, + }) { + final _createdEntry = OverlayEntry( + builder: (context) => GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: dismiss, + child: Material( + color: Colors.transparent, + type: MaterialType.canvas, + child: Stack( + children: [ + CustomSingleChildLayout( + delegate: PopupOverlayLayoutDelegate(position), + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: width), + child: popupBuilder(context), + ), + ), + ], + ), + ), + ), + ); + + Overlay.of(context).insert(_createdEntry); + _overlayEntry = _createdEntry; + + return _createdEntry; + } + + /// Remove popup + void dismiss() { + _overlayEntry?.remove(); + } +} + +/// 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, + }); + + @override + Offset getPositionForChild(Size size, Size childSize) { + return _calculatePosition(size, position, childSize); + } + + Offset _calculatePosition(Size size, Offset position, Size childSize) { + final _popupRect = Rect.fromCenter( + center: position, + width: childSize.width, + height: childSize.height, + ); + double dx = _popupRect.left; + // limiting X offset + dx = max(horizontalPadding, dx); + final rightBorderPosition = dx + childSize.width; + final rightScreenBorderOverflow = rightBorderPosition - size.width; + if (rightScreenBorderOverflow >= 0) { + dx -= rightScreenBorderOverflow + horizontalPadding; + } + + // under the desired position + double dy = position.dy + verticalPadding; + final bottomBorderPosition = dy + childSize.height; + final bottomScreenBorderOverflow = bottomBorderPosition - size.height; + // if not enough space underneath, rendering above the desired position + if (bottomScreenBorderOverflow >= 0) { + final newBottomBorderPosition = position.dy - childSize.height; + dy = newBottomBorderPosition - verticalPadding; + } + + return Offset(dx, dy); + } + + @override + bool shouldRelayout(covariant PopupOverlayLayoutDelegate oldDelegate) { + return false; + } +} diff --git a/readme/languagetool.gif b/readme/languagetool.gif new file mode 100644 index 0000000..c4bdc72 Binary files /dev/null and b/readme/languagetool.gif differ