Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion example/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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});
Expand Down
65 changes: 39 additions & 26 deletions lib/src/core/controllers/language_tool_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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(),
Expand All @@ -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:
Expand Down Expand Up @@ -161,6 +166,7 @@ class LanguageToolController extends TextEditingController {

@override
void dispose() {
_isDisposed = true;
_languageCheckService?.dispose();
super.dispose();
}
Expand Down Expand Up @@ -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
Expand All @@ -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;
Expand Down Expand Up @@ -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<Mistake> _filterMistakesOnChanged(String newText) sync* {
List<Mistake> _filterMistakesOnChanged(
List<Mistake> 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();
}
Comment on lines +315 to 340
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

When the text is changed programmatically or before the text field is focused, selection can be invalid (i.e., selection.isValid is false, with offsets of -1).

If selection is invalid, isSelectionRangeEmpty is true, and _adjustMistakeOffsetWithCaretCursor is called with caretLocation = -1. Since -1 <= mistake.offset is always true, shouldAdjustOffset evaluates to true, causing all mistakes to be incorrectly shifted by lengthDiscrepancy (for example, when appending text).

To prevent this incorrect shifting, we should check if selection.isValid is false and return the mistakes without adjustment, only filtering out those that exceed the new text range.

  List<Mistake> _filterMistakesOnChanged(
    List<Mistake> mistakes,
    String newText,
  ) {
    if (!selection.isValid) {
      return mistakes
          .where((mistake) => mistake.endOffset <= newText.length)
          .toList();
    }

    final isSelectionRangeEmpty = selection.end == selection.start;
    final lengthDiscrepancy = newText.length - text.length;

    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();
  }

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But it will be handled by the .where filter anyway?


/// Adjusts the mistake offset when the selection is a caret cursor.
Expand Down
3 changes: 2 additions & 1 deletion lib/src/domain/language_check_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
4 changes: 1 addition & 3 deletions lib/src/language_check_services/language_tool_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ class LanguageToolService extends LanguageCheckService {
.then(Result.success)
.catchError(Result<List<WritingMistake>>.error);

final mistakesWrapper = writingMistakesWrapper.map(
return writingMistakesWrapper.map(
(mistakes) {
return mistakes.map(
(m) {
Expand All @@ -42,7 +42,5 @@ class LanguageToolService extends LanguageCheckService {
).toList(growable: false);
},
);

return mistakesWrapper;
}
}
38 changes: 19 additions & 19 deletions lib/src/utils/mistake_popup.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;

Expand All @@ -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),
Expand Down Expand Up @@ -174,7 +174,7 @@ class LanguageToolMistakePopup extends StatelessWidget {
),
constraints: const BoxConstraints(),
padding: EdgeInsets.zero,
splashRadius: _dismissSplashRadius,
splashRadius: dismissSplashRadius,
onPressed: () {
_dismissDialog();
controller.onClosePopup();
Expand All @@ -188,24 +188,24 @@ 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(
crossAxisAlignment: CrossAxisAlignment.stretch,
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,
),
),
),
Expand All @@ -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(
Expand Down
15 changes: 7 additions & 8 deletions lib/src/utils/popup_overlay_renderer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,16 @@ class PopupOverlayRenderer {
/// Render overlay entry on the screen with dismiss logic
OverlayEntry render(
BuildContext context, {
ValueChanged<TapDownDetails>? onClose,
required Offset position,
required WidgetBuilder popupBuilder,
ValueChanged<TapDownDetails>? 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(
Expand All @@ -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
Expand All @@ -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;
Expand Down
Loading