diff --git a/CHANGELOG.md b/CHANGELOG.md index 84cc605..6178db7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,8 +11,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/analysis_options.yaml b/example/analysis_options.yaml index 21bc10a..120f856 100644 --- a/example/analysis_options.yaml +++ b/example/analysis_options.yaml @@ -1 +1,6 @@ include: package:solid_lints/analysis_options.yaml + +custom_lint: + rules: + - avoid_late_keyword: + allow_initialized: true diff --git a/example/lib/main.dart b/example/lib/main.dart index 2e65dab..9828396 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -20,55 +20,173 @@ 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; + late final LanguageToolController _spellCheckController = + LanguageToolController( + languageCheckService: FilteredLanguageCheckService( + languageCheckService: ThrottlingLanguageCheckService( + LanguageToolService(LanguageToolClient()), + const Duration(milliseconds: 250), + ), + shouldIgnoreMistake: (mistake, text) { + final word = text.substring(mistake.offset, mistake.endOffset); + + return _dictionary.contains(word); + }, + ), + ); @override Widget build(BuildContext context) { 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}); + _spellCheckController.recheckText(); + }, + ); + }, + ), ), - ), - 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"), - ], - ), - ], + 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), + ), + ), + ], + ), + ), + ), + ], + ), ), ), ); } + 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/example/pubspec.yaml b/example/pubspec.yaml index 6762603..a1541dd 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -15,7 +15,7 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter - solid_lints: ^0.0.14 + solid_lints: ^0.3.1 flutter: uses-material-design: true diff --git a/lib/languagetool_textfield.dart b/lib/languagetool_textfield.dart index fa751af..69161a7 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/filtered_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 0e7e999..1e1863b 100644 --- a/lib/src/core/controllers/language_tool_controller.dart +++ b/lib/src/core/controllers/language_tool_controller.dart @@ -52,7 +52,7 @@ class LanguageToolController extends TextEditingController { if (!_isEnabled) return; - _handleTextChange(text, spellCheckSameText: true); + recheckText(); } /// Indicates whether spell checking is enabled @@ -64,7 +64,7 @@ class LanguageToolController extends TextEditingController { _isEnabled = value; if (_isEnabled) { - _handleTextChange(text, spellCheckSameText: true); + recheckText(); } else { _mistakes = []; for (final recognizer in _recognizers) { @@ -126,9 +126,11 @@ class LanguageToolController extends TextEditingController { }) { final languageToolService = LanguageToolService(languageToolClient); - // false positive, the same variable is used beyond the return statement - // ignore: avoid_unnecessary_return_variable - if (delay == Duration.zero) return languageToolService; + if (delay == Duration.zero) { + // false positive, the variable might be used after the if statement + // ignore: avoid_unnecessary_return_variable + return languageToolService; + } switch (delayType) { case DelayType.debouncing: @@ -189,6 +191,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 14a1f92..f58adb8 100644 --- a/lib/src/utils/mistake_popup.dart +++ b/lib/src/utils/mistake_popup.dart @@ -52,7 +52,8 @@ 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; /// Renderer used to display this window. final PopupOverlayRenderer popupRenderer; @@ -84,7 +85,10 @@ 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({ required this.popupRenderer, required this.mistake, @@ -95,6 +99,7 @@ class LanguageToolMistakePopup extends StatelessWidget { this.horizontalMargin = _defaultHorizontalMargin, this.verticalMargin = _defaultVerticalMargin, this.mistakeStyle, + this.addWordToDictionary, super.key, }); @@ -149,38 +154,45 @@ 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 != null) + IconButton( + icon: const Icon(Icons.menu_book), + constraints: const BoxConstraints(), + splashRadius: dismissSplashRadius, + onPressed: () => + _addWordToDictionaryAndFix(mistake), + ), + 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( @@ -226,7 +238,8 @@ class LanguageToolMistakePopup extends StatelessWidget { children: mistake.replacements .map( (replacement) => ElevatedButton( - onPressed: () => _fixTheMistake(replacement), + onPressed: () => + _fixTheMistake(mistake, replacement), style: mistakeStyle ?? ElevatedButton.styleFrom( elevation: 0, @@ -261,11 +274,29 @@ class LanguageToolMistakePopup extends StatelessWidget { return min(max(availableSpaceBottom, availableSpaceTop), maxHeight); } + Future _addWordToDictionaryAndFix(Mistake mistake) async { + final word = controller.text.substring( + mistake.offset, + mistake.endOffset, + ); + + await addWordToDictionary?.call(word); + + final mistakeStillExists = + controller.text.substring(mistake.offset, mistake.endOffset) == word; + if (mistakeStillExists) { + // Clear the mistake without actually changing the text + _fixTheMistake(mistake, word); + } else { + _dismissDialog(); + } + } + void _dismissDialog() { popupRenderer.dismiss(); } - void _fixTheMistake(String replacement) { + void _fixTheMistake(Mistake mistake, String replacement) { controller.replaceMistake(mistake, replacement); _dismissDialog(); } diff --git a/lib/src/wrappers/filtered_language_check_service.dart b/lib/src/wrappers/filtered_language_check_service.dart new file mode 100644 index 0000000..f4fa1a5 --- /dev/null +++ b/lib/src/wrappers/filtered_language_check_service.dart @@ -0,0 +1,59 @@ +import 'package:languagetool_textfield/languagetool_textfield.dart'; + +/// A [LanguageToolService] that post-filters mistakes reported by LanguageTool. +/// +/// After delegating to [LanguageToolService.findMistakes], this service keeps +/// only mistakes that pass [_shouldIgnoreMistake]. A common use case is hiding +/// domain-specific vocabulary the user has marked as correct. +/// +/// This class does not throttle or debounce requests. Wrap it in +/// [ThrottlingLanguageCheckService] or [DebounceLanguageCheckService] when +/// needed. +class FilteredLanguageCheckService extends LanguageCheckService { + /// Predicate applied to each mistake returned by LanguageTool. + /// + /// Invoked once per [Mistake]. Mistakes for which this returns `true` + /// are ignored and excluded from the final result. + final bool Function(Mistake, String) _shouldIgnoreMistake; + + /// The underlying language check service that is used to find mistakes. + final LanguageCheckService _languageCheckService; + + @override + String get language => _languageCheckService.language; + + @override + set language(String language) { + _languageCheckService.language = language; + } + + /// Creates an [FilteredLanguageCheckService]. + /// + /// [shouldIgnoreMistake] is required and evaluated for every mistake returned + /// by LanguageTool. + /// An optional [languageCheckService] overrides the default service. + FilteredLanguageCheckService({ + required bool Function(Mistake, String) shouldIgnoreMistake, + LanguageCheckService? languageCheckService, + }) : _shouldIgnoreMistake = shouldIgnoreMistake, + _languageCheckService = + languageCheckService ?? LanguageToolService(LanguageToolClient()); + + @override + Future>?> findMistakes(String text) async { + final result = await _languageCheckService.findMistakes(text); + + return result?.map( + (mistakes) => mistakes + .where((mistake) => !_shouldIgnoreMistake(mistake, text)) + .toList(growable: false), + ); + } + + // ignore: proper_super_calls + @override + Future dispose() async { + await _languageCheckService.dispose(); + await super.dispose(); + } +} diff --git a/lib/src/wrappers/throttling_language_check_service.dart b/lib/src/wrappers/throttling_language_check_service.dart index 2d637db..9173250 100644 --- a/lib/src/wrappers/throttling_language_check_service.dart +++ b/lib/src/wrappers/throttling_language_check_service.dart @@ -28,8 +28,20 @@ class ThrottlingLanguageCheckService extends LanguageCheckService { @override Future>?> findMistakes(String text) async { - return await _throttling + final result = await _throttling .throttle(() => _languageCheckService.findMistakes(text)); + + return result?.map( + (mistakes) => mistakes + .where( + (mistake) => + mistake.offset >= 0 && + mistake.offset < text.length && + mistake.endOffset >= 0 && + mistake.endOffset <= text.length, + ) + .toList(growable: false), + ); } // ignore: proper_super_calls