diff --git a/example/lib/app.dart b/example/lib/app.dart index f3d9ca4..8134142 100644 --- a/example/lib/app.dart +++ b/example/lib/app.dart @@ -1,6 +1,11 @@ import 'package:flutter/material.dart'; +import 'package:language_tool/language_tool.dart'; +import 'package:languagetool_textfield/domain/mistake.dart'; +import 'package:languagetool_textfield/languagetool_textfield.dart'; +/// A main screen widget demonstrating library usage example class App extends StatefulWidget { + /// Creates a new instance of main screen widget const App({super.key}); @override @@ -8,8 +13,38 @@ class App extends StatefulWidget { } class _AppState extends State { + final _langToolService = LangToolService(LanguageTool()); + final _textController = LanguageToolTextEditingController( + text: 'OKAYOKAYOKAYOKAYOKAY', + mistakes: [ + const Mistake( + message: 'bad', + type: 'bad', + offset: 0, + length: 3, + ), + const Mistake( + message: 'bad', + type: 'bad', + offset: 8, + length: 5, + ), + ], + ); + @override Widget build(BuildContext context) { - return const Placeholder(); + return Scaffold( + body: Center( + child: Padding( + padding: const EdgeInsets.all(20.0), + child: LanguageToolTextField( + langService: _langToolService, + controller: _textController, + style: const TextStyle(), + ), + ), + ), + ); } } diff --git a/example/pubspec.lock b/example/pubspec.lock index 6cae1f7..6aba7bb 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -196,7 +196,7 @@ packages: source: hosted version: "4.8.0" language_tool: - dependency: transitive + dependency: "direct main" description: name: language_tool sha256: "90ceb6f0a0b57fb3a5b88be82ffd676c90639cd06d622d25f76add30d5a2acd6" @@ -204,7 +204,7 @@ packages: source: hosted version: "2.1.1" languagetool_textfield: - dependency: "direct dev" + dependency: "direct main" description: path: ".." relative: true diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 75fe569..f0491c3 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -9,12 +9,13 @@ environment: dependencies: flutter: sdk: flutter + language_tool: ^2.1.1 + languagetool_textfield: + path: ../ dev_dependencies: flutter_test: sdk: flutter - languagetool_textfield: - path: ../ solid_lints: ^0.0.14 flutter: diff --git a/lib/domain/mistake.dart b/lib/domain/mistake.dart index ecbd3a8..25a1b85 100644 --- a/lib/domain/mistake.dart +++ b/lib/domain/mistake.dart @@ -17,6 +17,9 @@ class Mistake { /// Sorted by probability. final List replacements; + /// A range of this mistake from offset to the end. + int get range => offset + length; + /// Creates a new instance of the [Mistake] class. const Mistake({ required this.message, diff --git a/lib/languagetool_textfield.dart b/lib/languagetool_textfield.dart index abe7c4c..7d83b3c 100644 --- a/lib/languagetool_textfield.dart +++ b/lib/languagetool_textfield.dart @@ -1 +1,6 @@ library languagetool_textfield; + +export 'domain/language_check_service.dart'; +export 'implementations/lang_tool_service.dart'; +export 'presentation/language_tool_text_field.dart'; +export 'presentation/widgets/language_tool_text_editing_controller.dart'; diff --git a/lib/presentation/language_tool_text_field.dart b/lib/presentation/language_tool_text_field.dart index 0652baf..480e8c2 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/domain/language_check_service.dart'; +import 'package:languagetool_textfield/presentation/widgets/language_tool_text_editing_controller.dart'; /// A TextField widget that checks the grammar using the given [langService] class LanguageToolTextField extends StatefulWidget { @@ -7,21 +8,25 @@ class LanguageToolTextField extends StatefulWidget { final LanguageCheckService langService; /// A style to use for the text being edited. - final TextStyle style; + final TextStyle? style; /// A decoration of this [TextField]. - final InputDecoration decoration; + final InputDecoration? decoration; /// A builder function used to build errors. final Widget Function()? mistakeBuilder; + /// A text controller used to highlight errors. + final LanguageToolTextEditingController? controller; + /// Creates a widget that checks grammar errors. const LanguageToolTextField({ Key? key, required this.langService, required this.style, - required this.decoration, + this.decoration, this.mistakeBuilder, + this.controller, }) : super(key: key); @override @@ -29,8 +34,33 @@ class LanguageToolTextField extends StatefulWidget { } class _LanguageToolTextFieldState extends State { + static const _borderRadius = 15.0; + static const _borderOpacity = 0.5; + + final _textFieldController = LanguageToolTextEditingController(); + final _textFieldBorder = OutlineInputBorder( + borderSide: BorderSide( + color: Colors.grey.withOpacity(_borderOpacity), + ), + borderRadius: BorderRadius.circular(_borderRadius), + ); + @override Widget build(BuildContext context) { - return const Placeholder(); + return TextField( + autocorrect: false, + enableSuggestions: false, + textCapitalization: TextCapitalization.none, + keyboardType: TextInputType.multiline, + maxLines: null, + style: widget.style, + decoration: widget.decoration ?? + InputDecoration( + focusedBorder: _textFieldBorder, + enabledBorder: _textFieldBorder, + border: _textFieldBorder, + ), + controller: widget.controller ?? _textFieldController, + ); } } diff --git a/lib/presentation/widgets/language_tool_text_editing_controller.dart b/lib/presentation/widgets/language_tool_text_editing_controller.dart new file mode 100644 index 0000000..c62b760 --- /dev/null +++ b/lib/presentation/widgets/language_tool_text_editing_controller.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; +import 'package:languagetool_textfield/domain/mistake.dart'; + +/// A custom controller for an editable text field, that supports +/// mistakes highlighting. +class LanguageToolTextEditingController extends TextEditingController { + /// A list of mistakes in the text. + final List mistakes; + + /// Creates a controller for an editable text field. + LanguageToolTextEditingController({ + String? text, + this.mistakes = const [], + }) : super(text: text); + + @override + TextSpan buildTextSpan({ + required BuildContext context, + TextStyle? style, + required bool withComposing, + }) { + final children = []; + const underlineThickness = 2.0; + const backgroundOpacity = 0.2; + + if (mistakes.isEmpty) { + return TextSpan(text: text, style: style); + } + + final lastMistakeIndex = mistakes.length - 1; + for (int i = 0; i < mistakes.length; i++) { + final mistake = mistakes[i]; + int previousMistakeEnd = 0; + if (i > 0) { + final previousMistake = mistakes[i - 1]; + previousMistakeEnd = previousMistake.offset + previousMistake.length; + } + final mistakeStart = mistake.offset; + + if (mistake.range > text.length) { + children.add( + TextSpan( + text: text.substring(previousMistakeEnd), + ), + ); + break; + } + + children.add( + TextSpan(text: text.substring(previousMistakeEnd, mistakeStart)), + ); + + final textStyle = style ?? const TextStyle(); + final mistakeText = text.substring(mistakeStart, mistake.range); + + // WidgetSpans with mistake text characters are used here to + // calculate the correct caret position, which can be + // incorrectly positioned because of the WidgetSpan issue, + // described here: https://github.com/flutter/flutter/issues/107432. + // + // TextSpan recognizer to process clicks can't be used, + // because it requires the RichText widget but the TextField + // widget does not contain one. + // Issue described here: https://github.com/flutter/flutter/issues/34931 + + children.add( + TextSpan( + style: textStyle.copyWith( + color: Colors.green, + decoration: TextDecoration.underline, + decorationColor: Colors.red, + decorationThickness: underlineThickness, + backgroundColor: Colors.red.withOpacity(backgroundOpacity), + ), + children: [ + for (final mistakeCharacter in mistakeText.characters) + WidgetSpan( + child: Text( + mistakeCharacter, + style: style, + ), + ), + ], + ), + ); + + if (i == lastMistakeIndex) { + children.add( + TextSpan( + text: text.substring(mistake.range), + ), + ); + } + } + + return TextSpan(children: children, style: style); + } +}