diff --git a/CHANGELOG.md b/CHANGELOG.md index c345970..570c657 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## 1.0.1 + +- Fixes a bug where changing text programmatically can sometimes throw a RangeError +- Fixes notifying listeners after async gap when disposed + ## 1.0.0 - BREAKING: require flutter 3.27.0 or higher @@ -64,7 +69,7 @@ - Moved implementation into the `src` directory - Updated dependencies - Add some properties (cursorColor, onTextChange, focusNode, onTextSubmitted, ...) for TextField - Credits: @dab246 + Credits: @dab246 - Replace deprecated MaterialStateMouseCursor ## 0.0.6 diff --git a/example/lib/main.dart b/example/lib/main.dart index 968adf1..2e65dab 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -10,7 +10,7 @@ void main() { } /// Example App main page -// ignore: prefer_match_file_name +// ignore: prefer_match_file_name, prefer-match-file-name class App extends StatefulWidget { /// Example app constructor const App({super.key}); diff --git a/lib/src/core/controllers/language_tool_controller.dart b/lib/src/core/controllers/language_tool_controller.dart index 40867a4..9e6a0ef 100644 --- a/lib/src/core/controllers/language_tool_controller.dart +++ b/lib/src/core/controllers/language_tool_controller.dart @@ -12,6 +12,7 @@ import 'package:languagetool_textfield/src/utils/keep_latest_response_service.da /// marked TextSpans with tap recognizer class LanguageToolController extends TextEditingController { bool _isEnabled; + bool _isDisposed = false; /// Color scheme to highlight mistakes final HighlightStyle highlightStyle; @@ -54,7 +55,7 @@ class LanguageToolController extends TextEditingController { bool get isEnabled => _isEnabled; set isEnabled(bool value) { - if (value == _isEnabled) return; + if (value == _isEnabled || _isDisposed) return; _isEnabled = value; @@ -96,9 +97,9 @@ class LanguageToolController extends TextEditingController { /// [delay] - Represents the duration of the delay for language checking. /// If the delay is [Duration.zero], no delaying is applied. /// - /// You can optionally provide a custom [languageCheckService] to fully control - /// how text is analyzed and processed. When provided, [delayType] and [delay] - /// are ignored. + /// You can optionally provide a custom [languageCheckService] to + /// fully control how text is analyzed and processed. + /// When provided, [delayType] and [delay] are ignored. LanguageToolController({ bool isEnabled = true, this.highlightStyle = const HighlightStyle(), @@ -121,7 +122,11 @@ class LanguageToolController extends TextEditingController { }) { final languageToolService = LanguageToolService(languageToolClient); - 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: @@ -161,6 +166,7 @@ class LanguageToolController extends TextEditingController { @override void dispose() { + _isDisposed = true; _languageCheckService?.dispose(); super.dispose(); } @@ -191,8 +197,7 @@ class LanguageToolController extends TextEditingController { ///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 (spellCheckSameText || newText != text && newText.isNotEmpty) { - final filteredMistakes = _filterMistakesOnChanged(newText); - _mistakes = filteredMistakes.toList(); + _mistakes = _filterMistakesOnChanged(_mistakes, newText); // If we have a text change and we have a popup on hold // it will close the popup @@ -208,7 +213,11 @@ class LanguageToolController extends TextEditingController { () => _languageCheckService?.findMistakes(newText) ?? Future(() => null), ); - if (mistakesWrapper == null || !mistakesWrapper.hasResult) return; + if (mistakesWrapper == null || + !mistakesWrapper.hasResult || + _isDisposed) { + return; + } final mistakes = mistakesWrapper.result(); _fetchError = mistakesWrapper.error; @@ -303,27 +312,31 @@ class LanguageToolController extends TextEditingController { ); } - /// Filters the list of mistakes based on the changes - /// in the text when it is changed. - Iterable _filterMistakesOnChanged(String newText) sync* { + List _filterMistakesOnChanged( + List mistakes, + String newText, + ) { final isSelectionRangeEmpty = selection.end == selection.start; final lengthDiscrepancy = newText.length - text.length; - for (final mistake in _mistakes) { - Mistake? newMistake; - - newMistake = isSelectionRangeEmpty - ? _adjustMistakeOffsetWithCaretCursor( - mistake: mistake, - lengthDiscrepancy: lengthDiscrepancy, - ) - : _adjustMistakeOffsetWithSelectionRange( - mistake: mistake, - lengthDiscrepancy: lengthDiscrepancy, - ); - - if (newMistake != null) yield newMistake; - } + return mistakes + .map( + (mistake) => isSelectionRangeEmpty + ? _adjustMistakeOffsetWithCaretCursor( + mistake: mistake, + lengthDiscrepancy: lengthDiscrepancy, + ) + : _adjustMistakeOffsetWithSelectionRange( + mistake: mistake, + lengthDiscrepancy: lengthDiscrepancy, + ), + ) + .nonNulls + .where( + (mistake) => + mistake.offset >= 0 && mistake.endOffset <= newText.length, + ) + .toList(); } /// Adjusts the mistake offset when the selection is a caret cursor. diff --git a/lib/src/domain/language_check_service.dart b/lib/src/domain/language_check_service.dart index 1ccd56f..4fb2421 100644 --- a/lib/src/domain/language_check_service.dart +++ b/lib/src/domain/language_check_service.dart @@ -10,7 +10,8 @@ abstract class LanguageCheckService { /// Sets the language code to be used for language checking. /// - /// [language] A string representing the language code (e.g., 'en-US', 'de-DE'). + /// [language] A string representing the language code + /// (e.g., 'en-US', 'de-DE'). /// This determines which language rules will be applied during text analysis. set language(String language); diff --git a/lib/src/language_check_services/language_tool_service.dart b/lib/src/language_check_services/language_tool_service.dart index 344a8fd..0b885aa 100644 --- a/lib/src/language_check_services/language_tool_service.dart +++ b/lib/src/language_check_services/language_tool_service.dart @@ -27,7 +27,7 @@ class LanguageToolService extends LanguageCheckService { .then(Result.success) .catchError(Result>.error); - final mistakesWrapper = writingMistakesWrapper.map( + return writingMistakesWrapper.map( (mistakes) { return mistakes.map( (m) { @@ -42,7 +42,5 @@ class LanguageToolService extends LanguageCheckService { ).toList(growable: false); }, ); - - return mistakesWrapper; } } diff --git a/lib/src/utils/mistake_popup.dart b/lib/src/utils/mistake_popup.dart index 89463b1..fd30741 100644 --- a/lib/src/utils/mistake_popup.dart +++ b/lib/src/utils/mistake_popup.dart @@ -86,11 +86,11 @@ class LanguageToolMistakePopup extends StatelessWidget { /// [LanguageToolMistakePopup] constructor const LanguageToolMistakePopup({ - super.key, required this.popupRenderer, required this.mistake, required this.controller, required this.mistakePosition, + super.key, this.maxWidth = _defaultMaxWidth, this.maxHeight = double.infinity, this.horizontalMargin = _defaultHorizontalMargin, @@ -100,14 +100,14 @@ class LanguageToolMistakePopup extends StatelessWidget { @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 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; @@ -128,7 +128,7 @@ class LanguageToolMistakePopup extends StatelessWidget { ), decoration: BoxDecoration( color: colorScheme.surface.withValues(alpha: 0.9), - borderRadius: BorderRadius.circular(_borderRadius), + borderRadius: BorderRadius.circular(borderRadius), boxShadow: [ BoxShadow( color: colorScheme.onSurface.withValues(alpha: 0.5), @@ -174,7 +174,7 @@ class LanguageToolMistakePopup extends StatelessWidget { ), constraints: const BoxConstraints(), padding: EdgeInsets.zero, - splashRadius: _dismissSplashRadius, + splashRadius: dismissSplashRadius, onPressed: () { _dismissDialog(); controller.onClosePopup(); @@ -188,7 +188,7 @@ class LanguageToolMistakePopup extends StatelessWidget { padding: const EdgeInsets.all(padding), decoration: BoxDecoration( color: colorScheme.surface, - borderRadius: BorderRadius.circular(_borderRadius), + borderRadius: BorderRadius.circular(borderRadius), ), child: SingleChildScrollView( child: Column( @@ -196,16 +196,16 @@ class LanguageToolMistakePopup extends StatelessWidget { children: [ Padding( padding: const EdgeInsets.only( - bottom: _paddingBetweenTitle, + bottom: paddingBetweenTitle, ), child: Text( mistake.type.name.capitalize(), style: TextStyle( color: colorScheme.onSurface.withValues(alpha: 0.7), - fontSize: _mistakeNameFontSize, + fontSize: mistakeNameFontSize, fontWeight: FontWeight.w600, - letterSpacing: _titleLetterSpacing, + letterSpacing: titleLetterSpacing, ), ), ), @@ -214,15 +214,15 @@ class LanguageToolMistakePopup extends StatelessWidget { child: Text( mistake.message, style: const TextStyle( - fontSize: _mistakeMessageFontSize, + fontSize: mistakeMessageFontSize, ), ), ), Wrap( - spacing: _replacementButtonsSpacing, + spacing: replacementButtonsSpacing, runSpacing: kIsWeb - ? _replacementButtonsSpacing - : _replacementButtonsSpacingMobile, + ? replacementButtonsSpacing + : replacementButtonsSpacingMobile, children: mistake.replacements .map( (replacement) => ElevatedButton( diff --git a/lib/src/utils/popup_overlay_renderer.dart b/lib/src/utils/popup_overlay_renderer.dart index ead45d3..aaafdce 100644 --- a/lib/src/utils/popup_overlay_renderer.dart +++ b/lib/src/utils/popup_overlay_renderer.dart @@ -12,17 +12,16 @@ class PopupOverlayRenderer { /// Render overlay entry on the screen with dismiss logic OverlayEntry render( BuildContext context, { - ValueChanged? onClose, required Offset position, required WidgetBuilder popupBuilder, + ValueChanged? onClose, }) { - final _createdEntry = OverlayEntry( + final createdEntry = OverlayEntry( builder: (context) => GestureDetector( behavior: HitTestBehavior.opaque, onTapDown: onClose, child: Material( color: Colors.transparent, - type: MaterialType.canvas, child: Stack( children: [ CustomSingleChildLayout( @@ -38,10 +37,10 @@ class PopupOverlayRenderer { ), ); - Overlay.of(context).insert(_createdEntry); - _overlayEntry = _createdEntry; + Overlay.of(context).insert(createdEntry); + _overlayEntry = createdEntry; - return _createdEntry; + return createdEntry; } /// Remove popup @@ -65,12 +64,12 @@ class PopupOverlayLayoutDelegate extends SingleChildLayoutDelegate { } Offset _calculatePosition(Size size, Offset position, Size childSize) { - final _popupRect = Rect.fromCenter( + final popupRect = Rect.fromCenter( center: position, width: childSize.width, height: childSize.height, ); - double dx = _popupRect.left; + double dx = popupRect.left; // limiting X offset dx = max(0, dx); final rightBorderPosition = dx + childSize.width;