diff --git a/CHANGELOG.md b/CHANGELOG.md index c345970..0a66c8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,10 @@ - potentially BREAKING: hide baseService and debouncing/throttling from Debouncing and Throttling LanguageService wrappers - potentially BREAKING: rename `DebounceLanguageToolService` to `DebounceLanguageCheckService` - potentially BREAKING: rename `ThrottleLanguageToolService` to `ThrottleLanguageCheckService` +- Support adding words to dictionary through `addWordToDictionary` callback in `LanguageToolMistakePopup` - Allow overriding `languageCheckService` - Add `isEnabled` to toggle spell check +- Use default IconButton padding for mistake popup - Add missing properties from flutter's `TextField` - autofillHints - autofocus diff --git a/example/lib/main.dart b/example/lib/main.dart index 968adf1..90dd867 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -20,55 +20,169 @@ class App extends StatefulWidget { } class _AppState extends State { - /// Initialize LanguageToolController - final LanguageToolController _controller = LanguageToolController(); + Set _dictionary = {}; + final _addWordController = TextEditingController(); - static const List alignments = [ - MainAxisAlignment.center, - MainAxisAlignment.start, - MainAxisAlignment.end, - ]; - int currentAlignmentIndex = 0; + LanguageToolController? _spellCheckController; + + LanguageToolController _nonNullController() { + return _spellCheckController ??= LanguageToolController( + languageCheckService: InMemoryDictionaryLanguageCheckService( + getDictionary: () => _dictionary, + ), + ); + } @override Widget build(BuildContext context) { + final spellCheckController = _nonNullController(); + return Material( child: Scaffold( - body: Column( - mainAxisAlignment: alignments[currentAlignmentIndex], - children: [ - LanguageToolTextField( - controller: _controller, - language: 'en-US', - ), - ValueListenableBuilder( - valueListenable: _controller, - builder: (_, __, ___) => CheckboxListTile( - title: const Text("Enable spell checking"), - value: _controller.isEnabled, - onChanged: (value) => _controller.isEnabled = value ?? false, + body: SingleChildScrollView( + child: Column( + children: [ + LanguageToolTextField( + controller: spellCheckController, + language: 'en-US', + mistakePopup: MistakePopup( + popupRenderer: PopupOverlayRenderer(), + mistakeBuilder: ({ + required LanguageToolController controller, + required Mistake mistake, + required Offset mistakePosition, + required PopupOverlayRenderer popupRenderer, + }) { + return LanguageToolMistakePopup( + popupRenderer: popupRenderer, + mistake: mistake, + mistakePosition: mistakePosition, + controller: controller, + addWordToDictionary: (word) async { + setState(() => _dictionary = {..._dictionary, word}); + }, + ); + }, + ), + ), + ValueListenableBuilder( + valueListenable: spellCheckController, + builder: (_, __, ___) => CheckboxListTile( + title: const Text("Enable spell checking"), + value: spellCheckController.isEnabled, + onChanged: (value) => + spellCheckController.isEnabled = value ?? false, + ), + ), + const SizedBox(height: 20), + Card( + margin: const EdgeInsets.all(16), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Dictionary', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: TextField( + controller: _addWordController, + decoration: const InputDecoration( + labelText: 'Add word to dictionary', + border: OutlineInputBorder(), + ), + onSubmitted: (_) => _addWord(), + ), + ), + const SizedBox(width: 8), + ElevatedButton( + onPressed: _addWord, + child: const Text('Add'), + ), + ], + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Dictionary Words (${_dictionary.length})', + style: const TextStyle(fontWeight: FontWeight.w500), + ), + if (_dictionary.isNotEmpty) + TextButton( + onPressed: _clearAllWords, + child: const Text('Clear All'), + ), + ], + ), + const SizedBox(height: 8), + if (_dictionary.isEmpty) + const Center( + child: Text( + 'No words in dictionary', + style: TextStyle(color: Colors.grey), + ), + ) + else + for (final word in _dictionary) + ListTile( + title: Text(word), + trailing: IconButton( + icon: const Icon(Icons.delete), + onPressed: () => _removeWord(word), + ), + ), + ], + ), + ), ), - ), - 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"), - ], - ), - ], + ], + ), ), ), ); } + void _addWord() { + final word = _addWordController.text.trim(); + + if (word.isNotEmpty && !_dictionary.contains(word)) { + setState(() { + _dictionary = {..._dictionary, word}; + _addWordController.clear(); + _spellCheckController?.recheckText(); + }); + } + } + + void _removeWord(String word) { + setState(() { + _dictionary = _dictionary.difference({word}); + _spellCheckController?.recheckText(); + }); + } + + void _clearAllWords() { + setState(() { + _dictionary = {}; + _spellCheckController?.recheckText(); + }); + } + @override void dispose() { - _controller.dispose(); + _spellCheckController?.dispose(); + _addWordController.dispose(); super.dispose(); } } diff --git a/lib/languagetool_textfield.dart b/lib/languagetool_textfield.dart index fa751af..b7e5828 100644 --- a/lib/languagetool_textfield.dart +++ b/lib/languagetool_textfield.dart @@ -16,5 +16,7 @@ export 'src/language_check_services/language_tool_service.dart'; export 'src/presentation/language_tool_text_field.dart'; export 'src/utils/mistake_popup.dart'; export 'src/utils/popup_overlay_renderer.dart'; +export 'src/utils/result.dart'; export 'src/wrappers/debounce_language_check_service.dart'; +export 'src/wrappers/in_memory_dictionary_language_check_service.dart'; export 'src/wrappers/throttling_language_check_service.dart'; diff --git a/lib/src/core/controllers/language_tool_controller.dart b/lib/src/core/controllers/language_tool_controller.dart index 40867a4..b810def 100644 --- a/lib/src/core/controllers/language_tool_controller.dart +++ b/lib/src/core/controllers/language_tool_controller.dart @@ -59,7 +59,7 @@ class LanguageToolController extends TextEditingController { _isEnabled = value; if (_isEnabled) { - _handleTextChange(text, spellCheckSameText: true); + recheckText(); } else { _mistakes = []; for (final recognizer in _recognizers) { @@ -182,6 +182,16 @@ class LanguageToolController extends TextEditingController { }); } + /// Rechecks the current text for spelling and grammar errors. + /// + /// This method forces a recheck of the existing text + /// This is useful when you want to re-evaluate the text without any actual + /// text changes, such as after changing language settings or updating + /// spell check configurations. + void recheckText() { + _handleTextChange(text, spellCheckSameText: true); + } + /// Clear mistakes list when text mas modified and get a new list of mistakes /// via API Future _handleTextChange( diff --git a/lib/src/utils/mistake_popup.dart b/lib/src/utils/mistake_popup.dart index 89463b1..a4595d3 100644 --- a/lib/src/utils/mistake_popup.dart +++ b/lib/src/utils/mistake_popup.dart @@ -52,7 +52,18 @@ class LanguageToolMistakePopup extends StatelessWidget { static const double _defaultVerticalMargin = 25.0; static const double _defaultHorizontalMargin = 10.0; static const double _defaultMaxWidth = 250.0; - static const _iconSize = 25.0; + static const double _logoSize = 25; + static const double _headerIconSize = 12; + + static const double _borderRadius = 10.0; + static const double _mistakeNameFontSize = 11.0; + static const double _mistakeMessageFontSize = 13.0; + static const double _replacementButtonsSpacing = 4.0; + static const double _replacementButtonsSpacingMobile = -6.0; + static const double _paddingBetweenTitle = 14.0; + static const double _titleLetterSpacing = 0.56; + static const double _dismissSplashRadius = 2.0; + static const double _padding = 10.0; /// Renderer used to display this window. final PopupOverlayRenderer popupRenderer; @@ -84,9 +95,11 @@ class LanguageToolMistakePopup extends StatelessWidget { /// Mistake suggestion style. final ButtonStyle? mistakeStyle; - /// [LanguageToolMistakePopup] constructor + /// Optional builder that adds additional actions to the header. + final Future Function(String)? addWordToDictionary; + + /// Creates a [LanguageToolMistakePopup]. const LanguageToolMistakePopup({ - super.key, required this.popupRenderer, required this.mistake, required this.controller, @@ -96,21 +109,12 @@ class LanguageToolMistakePopup extends StatelessWidget { this.horizontalMargin = _defaultHorizontalMargin, this.verticalMargin = _defaultVerticalMargin, this.mistakeStyle, + this.addWordToDictionary, + super.key, }); @override Widget build(BuildContext context) { - const _borderRadius = 10.0; - const _mistakeNameFontSize = 11.0; - const _mistakeMessageFontSize = 13.0; - const _replacementButtonsSpacing = 4.0; - const _replacementButtonsSpacingMobile = -6.0; - const _paddingBetweenTitle = 14.0; - const _titleLetterSpacing = 0.56; - const _dismissSplashRadius = 2.0; - - const padding = 10.0; - final availableSpace = _calculateAvailableSpace(context); final colorScheme = Theme.of(context).colorScheme; @@ -149,43 +153,58 @@ class LanguageToolMistakePopup extends StatelessWidget { children: [ Padding( padding: const EdgeInsets.only(left: 4), - child: Row( - children: [ - Expanded( - child: Row( - children: [ - Padding( - padding: const EdgeInsets.only(right: 5.0), - child: Image.asset( - LangToolImages.logo, - width: _iconSize, - height: _iconSize, - package: 'languagetool_textfield', + child: IconTheme( + data: const IconThemeData(size: _headerIconSize), + child: Row( + children: [ + Expanded( + child: Row( + children: [ + Padding( + padding: const EdgeInsets.only(right: 5.0), + child: Image.asset( + LangToolImages.logo, + width: _logoSize, + height: _logoSize, + package: 'languagetool_textfield', + ), ), - ), - const Text('Correct'), - ], + const Text('Correct'), + ], + ), ), - ), - IconButton( - icon: const Icon( - Icons.close, - size: 12, + if (addWordToDictionary case final addWordToDictionary?) + IconButton( + icon: const Icon(Icons.menu_book), + constraints: const BoxConstraints(), + splashRadius: _dismissSplashRadius, + onPressed: () async { + final word = controller.text.substring( + mistake.offset, + mistake.endOffset, + ); + + await addWordToDictionary(word); + + _fixTheMistake(word); + }, + ), + IconButton( + icon: const Icon(Icons.close), + constraints: const BoxConstraints(), + splashRadius: _dismissSplashRadius, + onPressed: () { + _dismissDialog(); + controller.onClosePopup(); + }, ), - constraints: const BoxConstraints(), - padding: EdgeInsets.zero, - splashRadius: _dismissSplashRadius, - onPressed: () { - _dismissDialog(); - controller.onClosePopup(); - }, - ), - ], + ], + ), ), ), Container( margin: const EdgeInsets.only(top: 8), - padding: const EdgeInsets.all(padding), + padding: const EdgeInsets.all(_padding), decoration: BoxDecoration( color: colorScheme.surface, borderRadius: BorderRadius.circular(_borderRadius), @@ -210,7 +229,7 @@ class LanguageToolMistakePopup extends StatelessWidget { ), ), Padding( - padding: const EdgeInsets.only(bottom: padding), + padding: const EdgeInsets.only(bottom: _padding), child: Text( mistake.message, style: const TextStyle( diff --git a/lib/src/wrappers/in_memory_dictionary_language_check_service.dart b/lib/src/wrappers/in_memory_dictionary_language_check_service.dart new file mode 100644 index 0000000..871bfa2 --- /dev/null +++ b/lib/src/wrappers/in_memory_dictionary_language_check_service.dart @@ -0,0 +1,54 @@ +import 'package:languagetool_textfield/languagetool_textfield.dart'; + +/// A language-check service that filters LanguageTool suggestions using an in-memory dictionary. +/// +/// This class wraps a LanguageToolService and extends ThrottlingLanguageCheckService to +/// limit the frequency of requests. After performing a check with the underlying service, +/// it removes any reported mistakes whose corresponding word is present in the provided +/// in-memory dictionary (so user-defined or domain-specific words can be treated as correct). +/// +/// The filtering is performed by extracting the substring of the input text using each +/// mistake's offset and endOffset and checking membership against the dictionary returned +/// by [getDictionary]. +/// +/// Note: the underlying service is throttled to avoid excessive requests; the throttling +/// behavior is provided by the superclass. +class InMemoryDictionaryLanguageCheckService + extends ThrottlingLanguageCheckService { + /// Callback that supplies the current set of words to be treated as correct. + /// + /// This function is invoked for each text check so the dictionary can be dynamic + /// (for example, reflecting user edits or settings). It must return a Set + /// containing the words that should be ignored by the language checker. + final Set Function() getDictionary; + + /// Creates an InMemoryDictionaryLanguageCheckService that uses [getDictionary] to filter mistakes. + /// + /// The [getDictionary] callback is required and will be called for every check operation. + /// The service delegates checking to an internal LanguageToolService and then filters + /// the results based on the returned dictionary. + InMemoryDictionaryLanguageCheckService({ + required this.getDictionary, + LanguageCheckService? languageToolService, + Duration? throttlingDuration, + }) : super( + languageToolService ?? LanguageToolService(LanguageToolClient()), + throttlingDuration ?? const Duration(milliseconds: 250), + ); + + @override + Future>?> findMistakes(String text) async { + final result = await super.findMistakes(text); + final dictionary = getDictionary(); + + return result?.map( + (mistakes) => mistakes.where( + (mistake) { + final word = text.substring(mistake.offset, mistake.endOffset); + + return !dictionary.contains(word); + }, + ).toList(), + ); + } +}