Unverified Commit e0742ebb authored by Camille Simon's avatar Camille Simon Committed by GitHub

[Android] Add spell check suggestions toolbar (#114460)

* Add spell check suggestions toolbar

* Fix test and move menu

* Cleanup

* Cleanup and fix bug

* More cleanup

* Make height dynamic and use localized delete

* Begin adding tests

* Create var checking for results

* Add tests

* Fix analyze (sorta)

* Add back hideToolbar call for testing

* Add back hidetoolbar in ts and delete one in et

* Remove unecessary calls to hidToolbar

* Fix analyze and docs

* Test fix

* Fix container issue

* Clean up

* Fix analyze

* Move delegate

* Fix typos

* Start addressing review

* Continue addressing review

* Add assert

* Some refactoring

* Add test for button behavior

* Undo test change

* Make spell check results public

* Rearrange test

* Add comment

* Address review

* Finish addressing review

* remove unused imports

* Address nits

* Address review

* Fix formatting

* Refactor findsuggestionspanatcursorindex and textselectiontoolbar constraints

* Fix analyze:
parent fa3777bd
......@@ -159,6 +159,8 @@ export 'src/material/slider.dart';
export 'src/material/slider_theme.dart';
export 'src/material/snack_bar.dart';
export 'src/material/snack_bar_theme.dart';
export 'src/material/spell_check_suggestions_toolbar.dart';
export 'src/material/spell_check_suggestions_toolbar_layout_delegate.dart';
export 'src/material/stepper.dart';
export 'src/material/switch.dart';
export 'src/material/switch_list_tile.dart';
......
......@@ -97,6 +97,7 @@ class CupertinoTextSelectionToolbarButton extends StatelessWidget {
return localizations.pasteButtonLabel;
case ContextMenuButtonType.selectAll:
return localizations.selectAllButtonLabel;
case ContextMenuButtonType.delete:
case ContextMenuButtonType.custom:
return '';
}
......
......@@ -211,6 +211,8 @@ class AdaptiveTextSelectionToolbar extends StatelessWidget {
return localizations.pasteButtonLabel;
case ContextMenuButtonType.selectAll:
return localizations.selectAllButtonLabel;
case ContextMenuButtonType.delete:
return localizations.deleteButtonTooltip.toUpperCase();
case ContextMenuButtonType.custom:
return '';
}
......
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/services.dart' show SuggestionSpan;
import 'package:flutter/widgets.dart';
import 'adaptive_text_selection_toolbar.dart';
import 'colors.dart';
import 'material.dart';
import 'spell_check_suggestions_toolbar_layout_delegate.dart';
import 'text_selection_toolbar.dart';
import 'text_selection_toolbar_text_button.dart';
// The default height of the SpellCheckSuggestionsToolbar, which
// assumes there are the maximum number of spell check suggestions available, 3.
// Size eyeballed on Pixel 4 emulator running Android API 31.
const double _kDefaultToolbarHeight = 193.0;
/// The default spell check suggestions toolbar for Android.
///
/// Tries to position itself below the [anchor], but if it doesn't fit, then it
/// readjusts to fit above bottom view insets.
class SpellCheckSuggestionsToolbar extends StatelessWidget {
/// Constructs a [SpellCheckSuggestionsToolbar].
const SpellCheckSuggestionsToolbar({
super.key,
required this.anchor,
required this.buttonItems,
}) : assert(buttonItems != null);
/// {@template flutter.material.SpellCheckSuggestionsToolbar.anchor}
/// The focal point below which the toolbar attempts to position itself.
/// {@endtemplate}
final Offset anchor;
/// The [ContextMenuButtonItem]s that will be turned into the correct button
/// widgets and displayed in the spell check suggestions toolbar.
///
/// See also:
///
/// * [AdaptiveTextSelectionToolbar.buttonItems], the list of
/// [ContextMenuButtonItem]s that are used to build the buttons of the
/// text selection toolbar.
final List<ContextMenuButtonItem> buttonItems;
/// Padding between the toolbar and the anchor. Eyeballed on Pixel 4 emulator
/// running Android API 31.
static const double kToolbarContentDistanceBelow = TextSelectionToolbar.kHandleSize - 3.0;
/// Builds the default Android Material spell check suggestions toolbar.
static Widget _spellCheckSuggestionsToolbarBuilder(BuildContext context, Widget child) {
return _SpellCheckSuggestionsToolbarContainer(
child: child,
);
}
/// Builds the button items for the toolbar based on the available
/// spell check suggestions.
static List<ContextMenuButtonItem>? buildButtonItems(
BuildContext context,
EditableTextState editableTextState,
) {
// Determine if composing region is misspelled.
final SuggestionSpan? spanAtCursorIndex =
editableTextState.findSuggestionSpanAtCursorIndex(
editableTextState.currentTextEditingValue.selection.baseOffset,
);
if (spanAtCursorIndex == null) {
return null;
}
final List<ContextMenuButtonItem> buttonItems = <ContextMenuButtonItem>[];
// Build suggestion buttons.
for (final String suggestion in spanAtCursorIndex.suggestions) {
buttonItems.add(ContextMenuButtonItem(
onPressed: () {
editableTextState
.replaceComposingRegion(
SelectionChangedCause.toolbar,
suggestion,
);
},
label: suggestion,
));
}
// Build delete button.
final ContextMenuButtonItem deleteButton =
ContextMenuButtonItem(
onPressed: () {
editableTextState.replaceComposingRegion(
SelectionChangedCause.toolbar,
'',
);
},
type: ContextMenuButtonType.delete,
);
buttonItems.add(deleteButton);
return buttonItems;
}
/// Determines the Offset that the toolbar will be anchored to.
static Offset getToolbarAnchor(TextSelectionToolbarAnchors anchors) {
return anchors.secondaryAnchor == null ? anchors.primaryAnchor : anchors.secondaryAnchor!;
}
/// Builds the toolbar buttons based on the [buttonItems].
List<Widget> _buildToolbarButtons(BuildContext context) {
return buttonItems.map((ContextMenuButtonItem buttonItem) {
final TextSelectionToolbarTextButton button =
TextSelectionToolbarTextButton(
padding: const EdgeInsets.fromLTRB(20, 0, 0, 0),
onPressed: buttonItem.onPressed,
alignment: Alignment.centerLeft,
child: Text(
AdaptiveTextSelectionToolbar.getButtonLabel(context, buttonItem),
style: buttonItem.type == ContextMenuButtonType.delete ? const TextStyle(color: Colors.blue) : null,
),
);
if (buttonItem.type != ContextMenuButtonType.delete) {
return button;
}
return DecoratedBox(
decoration: const BoxDecoration(border: Border(top: BorderSide(color: Colors.grey))),
child: button,
);
}).toList();
}
@override
Widget build(BuildContext context) {
// Adjust toolbar height if needed.
final double spellCheckSuggestionsToolbarHeight =
_kDefaultToolbarHeight - (48.0 * (4 - buttonItems.length));
// Incorporate the padding distance between the content and toolbar.
final Offset anchorPadded =
anchor + const Offset(0.0, kToolbarContentDistanceBelow);
final MediaQueryData mediaQueryData = MediaQuery.of(context);
final double softKeyboardViewInsetsBottom = mediaQueryData.viewInsets.bottom;
final double paddingAbove = mediaQueryData.padding.top + TextSelectionToolbar.kToolbarScreenPadding;
// Makes up for the Padding.
final Offset localAdjustment = Offset(TextSelectionToolbar.kToolbarScreenPadding, paddingAbove);
return Padding(
padding: EdgeInsets.fromLTRB(
TextSelectionToolbar.kToolbarScreenPadding,
kToolbarContentDistanceBelow,
TextSelectionToolbar.kToolbarScreenPadding,
TextSelectionToolbar.kToolbarScreenPadding + softKeyboardViewInsetsBottom,
),
child: CustomSingleChildLayout(
delegate: SpellCheckSuggestionsToolbarLayoutDelegate(
anchor: anchorPadded - localAdjustment,
),
child: AnimatedSize(
// This duration was eyeballed on a Pixel 2 emulator running Android
// API 28 for the Material TextSelectionToolbar.
duration: const Duration(milliseconds: 140),
child: _spellCheckSuggestionsToolbarBuilder(context, _SpellCheckSuggestsionsToolbarItemsLayout(
height: spellCheckSuggestionsToolbarHeight,
children: <Widget>[..._buildToolbarButtons(context)],
)),
),
),
);
}
}
/// The Material-styled toolbar outline for the spell check suggestions
/// toolbar.
class _SpellCheckSuggestionsToolbarContainer extends StatelessWidget {
const _SpellCheckSuggestionsToolbarContainer({
required this.child,
});
final Widget child;
@override
Widget build(BuildContext context) {
return Material(
// This elevation was eyeballed on a Pixel 4 emulator running Android
// API 31 for the SpellCheckSuggestionsToolbar.
elevation: 2.0,
type: MaterialType.card,
child: child,
);
}
}
/// Renders the spell check suggestions toolbar items in the correct positions
/// in the menu.
class _SpellCheckSuggestsionsToolbarItemsLayout extends StatelessWidget {
const _SpellCheckSuggestsionsToolbarItemsLayout({
required this.height,
required this.children,
});
final double height;
final List<Widget> children;
@override
Widget build(BuildContext context) {
return SizedBox(
// This width was eyeballed on a Pixel 4 emulator running Android
// API 31 for the SpellCheckSuggestionsToolbar.
width: 165,
height: height,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: children,
),
);
}
}
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart' show TextSelectionToolbarLayoutDelegate;
/// Positions the toolbar below [anchor] or adjusts it higher to fit above
/// the bottom view insets, if applicable.
///
/// See also:
///
/// * [SpellCheckSuggestionsToolbar], which uses this to position itself.
class SpellCheckSuggestionsToolbarLayoutDelegate extends SingleChildLayoutDelegate {
/// Creates an instance of [SpellCheckSuggestionsToolbarLayoutDelegate].
SpellCheckSuggestionsToolbarLayoutDelegate({
required this.anchor,
});
/// {@macro flutter.material.SpellCheckSuggestionsToolbar.anchor}
///
/// Should be provided in local coordinates.
final Offset anchor;
@override
BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
return constraints.loosen();
}
@override
Offset getPositionForChild(Size size, Size childSize) {
return Offset(
TextSelectionToolbarLayoutDelegate.centerOn(
anchor.dx,
childSize.width,
size.width,
),
// Positions child (of childSize) just enough upwards to fit within size
// if it otherwise does not fit below the anchor.
anchor.dy + childSize.height > size.height
? size.height - childSize.height
: anchor.dy,
);
}
@override
bool shouldRelayout(SpellCheckSuggestionsToolbarLayoutDelegate oldDelegate) {
return anchor != oldDelegate.anchor;
}
}
......@@ -20,6 +20,7 @@ import 'magnifier.dart';
import 'material_localizations.dart';
import 'material_state.dart';
import 'selectable_text.dart' show iOSHorizontalOffset;
import 'spell_check_suggestions_toolbar.dart';
import 'text_selection.dart';
import 'theme.dart';
......@@ -800,6 +801,32 @@ class TextField extends StatefulWidget {
decorationStyle: TextDecorationStyle.wavy,
);
/// Default builder for the spell check suggestions toolbar in the Material
/// style.
///
/// See also:
/// * [SpellCheckConfiguration.spellCheckSuggestionsToolbarBuilder], the
// builder configured to show a spell check suggestions toolbar.
@visibleForTesting
static Widget defaultSpellCheckSuggestionsToolbarBuilder(
BuildContext context,
EditableTextState editableTextState,
) {
final Offset anchor =
SpellCheckSuggestionsToolbar.getToolbarAnchor(editableTextState.contextMenuAnchors);
final List<ContextMenuButtonItem>? buttonItems =
SpellCheckSuggestionsToolbar.buildButtonItems(context, editableTextState);
if (buttonItems == null){
return const SizedBox.shrink();
}
return SpellCheckSuggestionsToolbar(
anchor: anchor,
buttonItems: buttonItems,
);
}
@override
State<TextField> createState() => _TextFieldState();
......@@ -1192,7 +1219,11 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements
widget.spellCheckConfiguration != const SpellCheckConfiguration.disabled()
? widget.spellCheckConfiguration!.copyWith(
misspelledTextStyle: widget.spellCheckConfiguration!.misspelledTextStyle
?? TextField.materialMisspelledTextStyle)
?? TextField.materialMisspelledTextStyle,
spellCheckSuggestionsToolbarBuilder:
widget.spellCheckConfiguration!.spellCheckSuggestionsToolbarBuilder
?? TextField.defaultSpellCheckSuggestionsToolbarBuilder
)
: const SpellCheckConfiguration.disabled();
TextSelectionControls? textSelectionControls = widget.selectionControls;
......
......@@ -14,15 +14,7 @@ import 'icons.dart';
import 'material.dart';
import 'material_localizations.dart';
// Minimal padding from all edges of the selection toolbar to all edges of the
// viewport.
const double _kToolbarScreenPadding = 8.0;
const double _kToolbarHeight = 44.0;
const double _kHandleSize = 22.0;
// Padding between the toolbar and the anchor.
const double _kToolbarContentDistanceBelow = _kHandleSize - 2.0;
const double _kToolbarContentDistance = 8.0;
/// A fully-functional Material-style text selection toolbar.
......@@ -84,6 +76,26 @@ class TextSelectionToolbar extends StatelessWidget {
/// {@endtemplate}
final ToolbarBuilder toolbarBuilder;
/// Minimal padding from all edges of the selection toolbar to all edges of the
/// viewport.
///
/// See also:
///
/// * [SpellCheckSuggestionsToolbar], which uses this same value for its
/// padding from the edges of the viewport.
static const double kToolbarScreenPadding = 8.0;
/// The size of the text selection handles.
///
/// See also:
///
/// * [SpellCheckSuggestionsToolbar], which references this value to calculate
/// the padding between the toolbar and anchor.
static const double kHandleSize = 22.0;
/// Padding between the toolbar and the anchor.
static const double kToolbarContentDistanceBelow = kHandleSize - 2.0;
// Build the default Android Material text selection menu toolbar.
static Widget _defaultToolbarBuilder(BuildContext context, Widget child) {
return _TextSelectionToolbarContainer(
......@@ -97,21 +109,21 @@ class TextSelectionToolbar extends StatelessWidget {
final Offset anchorAbovePadded =
anchorAbove - const Offset(0.0, _kToolbarContentDistance);
final Offset anchorBelowPadded =
anchorBelow + const Offset(0.0, _kToolbarContentDistanceBelow);
anchorBelow + const Offset(0.0, kToolbarContentDistanceBelow);
final double paddingAbove = MediaQuery.paddingOf(context).top
+ _kToolbarScreenPadding;
+ kToolbarScreenPadding;
final double availableHeight = anchorAbovePadded.dy - _kToolbarContentDistance - paddingAbove;
final bool fitsAbove = _kToolbarHeight <= availableHeight;
// Makes up for the Padding above the Stack.
final Offset localAdjustment = Offset(_kToolbarScreenPadding, paddingAbove);
final Offset localAdjustment = Offset(kToolbarScreenPadding, paddingAbove);
return Padding(
padding: EdgeInsets.fromLTRB(
_kToolbarScreenPadding,
kToolbarScreenPadding,
paddingAbove,
_kToolbarScreenPadding,
_kToolbarScreenPadding,
kToolbarScreenPadding,
kToolbarScreenPadding,
),
child: CustomSingleChildLayout(
delegate: TextSelectionToolbarLayoutDelegate(
......
......@@ -31,6 +31,7 @@ class TextSelectionToolbarTextButton extends StatelessWidget {
required this.child,
required this.padding,
this.onPressed,
this.alignment,
});
// These values were eyeballed to match the native text selection menu on a
......@@ -62,6 +63,15 @@ class TextSelectionToolbarTextButton extends StatelessWidget {
/// * [ButtonStyle.padding], which is where this padding is applied.
final EdgeInsets padding;
/// The alignment of the button's child.
///
/// By default, this will be [Alignment.center].
///
/// See also:
///
/// * [ButtonStyle.alignment], which is where this alignment is applied.
final AlignmentGeometry? alignment;
/// Returns the standard padding for a button at index out of a total number
/// of buttons.
///
......@@ -104,6 +114,22 @@ class TextSelectionToolbarTextButton extends StatelessWidget {
return _TextSelectionToolbarItemPosition.middle;
}
/// Returns a copy of the current [TextSelectionToolbarTextButton] instance
/// with specific overrides.
TextSelectionToolbarTextButton copyWith({
Widget? child,
VoidCallback? onPressed,
EdgeInsets? padding,
AlignmentGeometry? alignment,
}) {
return TextSelectionToolbarTextButton(
onPressed: onPressed ?? this.onPressed,
padding: padding ?? this.padding,
alignment: alignment ?? this.alignment,
child: child ?? this.child,
);
}
@override
Widget build(BuildContext context) {
// TODO(hansmuller): Should be colorScheme.onSurface
......@@ -117,6 +143,7 @@ class TextSelectionToolbarTextButton extends StatelessWidget {
shape: const RoundedRectangleBorder(),
minimumSize: const Size(kMinInteractiveDimension, kMinInteractiveDimension),
padding: padding,
alignment: alignment,
),
onPressed: onPressed,
child: child,
......
......@@ -23,6 +23,9 @@ enum ContextMenuButtonType {
/// A button that selects all the contents of the focused text field.
selectAll,
/// A button that deletes the current text selection.
delete,
/// Anything other than the default button types.
custom,
}
......
......@@ -1947,7 +1947,9 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
/// These results will be updated via calls to spell check through a
/// [SpellCheckService] and used by this widget to build the [TextSpan] tree
/// for text input and menus for replacement suggestions of misspelled words.
SpellCheckResults? _spellCheckResults;
SpellCheckResults? spellCheckResults;
bool get _spellCheckResultsReceived => spellCheckEnabled && spellCheckResults != null && spellCheckResults!.suggestionSpans.isNotEmpty;
/// Whether to create an input connection with the platform for text editing
/// or not.
......@@ -2190,6 +2192,63 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
}
}
/// Replace composing region with specified text.
void replaceComposingRegion(SelectionChangedCause cause, String text) {
// Replacement cannot be performed if the text is read only or obscured.
assert(!widget.readOnly && !widget.obscureText);
_replaceText(ReplaceTextIntent(textEditingValue, text, textEditingValue.composing, cause));
if (cause == SelectionChangedCause.toolbar) {
// Schedule a call to bringIntoView() after renderEditable updates.
SchedulerBinding.instance.addPostFrameCallback((_) {
if (mounted) {
bringIntoView(textEditingValue.selection.extent);
}
});
hideToolbar();
}
}
/// Finds specified [SuggestionSpan] that matches the provided index using
/// binary search.
///
/// See also:
///
/// * [SpellCheckSuggestionsToolbar], the Material style spell check
/// suggestions toolbar that uses this method to render the correct
/// suggestions in the toolbar for a misspelled word.
SuggestionSpan? findSuggestionSpanAtCursorIndex(int cursorIndex) {
if (!_spellCheckResultsReceived
|| spellCheckResults!.suggestionSpans.last.range.end < cursorIndex) {
// No spell check results have been recieved or the cursor index is out
// of range that suggestionSpans covers.
return null;
}
final List<SuggestionSpan> suggestionSpans = spellCheckResults!.suggestionSpans;
int leftIndex = 0;
int rightIndex = suggestionSpans.length - 1;
int midIndex = 0;
while (leftIndex <= rightIndex) {
midIndex = ((leftIndex + rightIndex) / 2).floor();
final int currentSpanStart = suggestionSpans[midIndex].range.start;
final int currentSpanEnd = suggestionSpans[midIndex].range.end;
if (cursorIndex <= currentSpanEnd && cursorIndex >= currentSpanStart) {
return suggestionSpans[midIndex];
}
else if (cursorIndex <= currentSpanStart) {
rightIndex = midIndex - 1;
}
else {
leftIndex = midIndex + 1;
}
}
return null;
}
/// Infers the [SpellCheckConfiguration] used to perform spell check.
///
/// If spell check is enabled, this will try to infer a value for
......@@ -2562,9 +2621,12 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
// `selection` is the only change.
_handleSelectionChanged(value.selection, (_textInputConnection?.scribbleInProgress ?? false) ? SelectionChangedCause.scribble : SelectionChangedCause.keyboard);
} else {
// Only hide the toolbar overlay, the selection handle's visibility will be handled
if (value.text != _value.text) {
// Hide the toolbar if the text was changed, but only hide the toolbar
// overlay; the selection handle's visibility will be handled
// by `_handleSelectionChanged`. https://github.com/flutter/flutter/issues/108673
hideToolbar(false);
}
_currentPromptRectRange = null;
final bool revealObscuredInput = _hasInputConnection
......@@ -3256,17 +3318,17 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
'Locale must be specified in widget or Localization widget must be in scope',
);
final List<SuggestionSpan>? spellCheckResults = await
final List<SuggestionSpan>? suggestions = await
_spellCheckConfiguration
.spellCheckService!
.fetchSpellCheckSuggestions(localeForSpellChecking!, text);
if (spellCheckResults == null) {
if (suggestions == null) {
// The request to fetch spell check suggestions was canceled due to ongoing request.
return;
}
_spellCheckResults = SpellCheckResults(text, spellCheckResults);
spellCheckResults = SpellCheckResults(text, suggestions);
renderEditable.text = buildTextSpan();
} catch (exception, stack) {
FlutterError.reportError(FlutterErrorDetails(
......@@ -3665,6 +3727,38 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
}
}
/// Shows toolbar with spell check suggestions of misspelled words that are
/// available for click-and-replace.
bool showSpellCheckSuggestionsToolbar() {
if (!spellCheckEnabled
|| widget.readOnly
|| _selectionOverlay == null
|| !_spellCheckResultsReceived) {
// Only attempt to show the spell check suggestions toolbar if there
// is a toolbar specified and spell check suggestions available to show.
return false;
}
assert(
_spellCheckConfiguration.spellCheckSuggestionsToolbarBuilder != null,
'spellCheckSuggestionsToolbarBuilder must be defined in '
'SpellCheckConfiguration to show a toolbar with spell check '
'suggestions',
);
_selectionOverlay!
.showSpellCheckSuggestionsToolbar(
(BuildContext context) {
return _spellCheckConfiguration
.spellCheckSuggestionsToolbarBuilder!(
context,
this,
);
},
);
return true;
}
/// Shows the magnifier at the position given by `positionToShow`,
/// if there is no magnifier visible.
///
......@@ -4321,9 +4415,8 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
],
);
}
final bool spellCheckResultsReceived = spellCheckEnabled && _spellCheckResults != null;
final bool withComposing = !widget.readOnly && _hasFocus;
if (spellCheckResultsReceived) {
if (_spellCheckResultsReceived) {
// If the composing range is out of range for the current text, ignore it to
// preserve the tree integrity, otherwise in release mode a RangeError will
// be thrown and this EditableText will be built with a broken subtree.
......@@ -4336,7 +4429,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
composingRegionOutOfRange,
widget.style,
_spellCheckConfiguration.misspelledTextStyle!,
_spellCheckResults!,
spellCheckResults!,
);
}
......
......@@ -2,11 +2,13 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';
import 'package:flutter/services.dart'
show SpellCheckResults, SpellCheckService, SuggestionSpan, TextEditingValue;
import 'editable_text.dart' show EditableTextContextMenuBuilder;
import 'framework.dart' show immutable;
/// Controls how spell check is performed for text input.
///
/// This configuration determines the [SpellCheckService] used to fetch the
......@@ -19,12 +21,14 @@ class SpellCheckConfiguration {
const SpellCheckConfiguration({
this.spellCheckService,
this.misspelledTextStyle,
this.spellCheckSuggestionsToolbarBuilder,
}) : _spellCheckEnabled = true;
/// Creates a configuration that disables spell check.
const SpellCheckConfiguration.disabled()
: _spellCheckEnabled = false,
spellCheckService = null,
spellCheckSuggestionsToolbarBuilder = null,
misspelledTextStyle = null;
/// The service used to fetch spell check results for text input.
......@@ -38,6 +42,10 @@ class SpellCheckConfiguration {
/// assertion error.
final TextStyle? misspelledTextStyle;
/// Builds the toolbar used to display spell check suggestions for misspelled
/// words.
final EditableTextContextMenuBuilder? spellCheckSuggestionsToolbarBuilder;
final bool _spellCheckEnabled;
/// Whether or not the configuration should enable or disable spell check.
......@@ -47,7 +55,8 @@ class SpellCheckConfiguration {
/// specified overrides.
SpellCheckConfiguration copyWith({
SpellCheckService? spellCheckService,
TextStyle? misspelledTextStyle}) {
TextStyle? misspelledTextStyle,
EditableTextContextMenuBuilder? spellCheckSuggestionsToolbarBuilder}) {
if (!_spellCheckEnabled) {
// A new configuration should be constructed to enable spell check.
return const SpellCheckConfiguration.disabled();
......@@ -56,6 +65,7 @@ class SpellCheckConfiguration {
return SpellCheckConfiguration(
spellCheckService: spellCheckService ?? this.spellCheckService,
misspelledTextStyle: misspelledTextStyle ?? this.misspelledTextStyle,
spellCheckSuggestionsToolbarBuilder : spellCheckSuggestionsToolbarBuilder ?? this.spellCheckSuggestionsToolbarBuilder,
);
}
......@@ -65,6 +75,7 @@ class SpellCheckConfiguration {
spell check enabled : $_spellCheckEnabled
spell check service : $spellCheckService
misspelled text style : $misspelledTextStyle
spell check suggesstions toolbar builder: $spellCheckSuggestionsToolbarBuilder
'''
.trim();
}
......@@ -78,11 +89,12 @@ class SpellCheckConfiguration {
return other is SpellCheckConfiguration
&& other.spellCheckService == spellCheckService
&& other.misspelledTextStyle == misspelledTextStyle
&& other.spellCheckSuggestionsToolbarBuilder == spellCheckSuggestionsToolbarBuilder
&& other._spellCheckEnabled == _spellCheckEnabled;
}
@override
int get hashCode => Object.hash(spellCheckService, misspelledTextStyle, _spellCheckEnabled);
int get hashCode => Object.hash(spellCheckService, misspelledTextStyle, spellCheckSuggestionsToolbarBuilder, _spellCheckEnabled);
}
// Methods for displaying spell check results:
......
......@@ -469,6 +469,20 @@ class TextSelectionOverlay {
return;
}
/// Shows toolbar with spell check suggestions of misspelled words that are
/// available for click-and-replace.
void showSpellCheckSuggestionsToolbar(
WidgetBuilder spellCheckSuggestionsToolbarBuilder
) {
_updateSelectionOverlay();
assert(context.mounted);
_selectionOverlay
.showSpellCheckSuggestionsToolbar(
context: context,
builder: spellCheckSuggestionsToolbarBuilder,
);
}
/// {@macro flutter.widgets.SelectionOverlay.showMagnifier}
void showMagnifier(Offset positionToShow) {
final TextPosition position = renderObject.getPositionForPoint(positionToShow);
......@@ -1347,6 +1361,29 @@ class SelectionOverlay {
);
}
/// Shows toolbar with spell check suggestions of misspelled words that are
/// available for click-and-replace.
void showSpellCheckSuggestionsToolbar({
BuildContext? context,
required WidgetBuilder builder,
}) {
if (context == null) {
return;
}
final RenderBox renderBox = context.findRenderObject()! as RenderBox;
_contextMenuController.show(
context: context,
contextMenuBuilder: (BuildContext context) {
return _SelectionToolbarWrapper(
layerLink: toolbarLayerLink,
offset: -renderBox.localToGlobal(Offset.zero),
child: builder(context),
);
},
);
}
bool _buildScheduled = false;
/// Rebuilds the selection toolbar or handles if they are present.
......@@ -2124,6 +2161,15 @@ class TextSelectionGestureDetectorBuilder {
}
break;
case TargetPlatform.android:
editableText.hideToolbar();
editableText.showSpellCheckSuggestionsToolbar();
if (isShiftPressedValid) {
_isShiftTapping = true;
_extendSelection(details.globalPosition, SelectionChangedCause.tap);
return;
}
renderEditable.selectPosition(cause: SelectionChangedCause.tap);
break;
case TargetPlatform.fuchsia:
editableText.hideToolbar();
if (isShiftPressedValid) {
......
......@@ -41,9 +41,9 @@ class TextSelectionToolbarLayoutDelegate extends SingleChildLayoutDelegate {
/// If not provided, it will be calculated.
final bool? fitsAbove;
// Return the value that centers width as closely as possible to position
// while fitting inside of min and max.
static double _centerOn(double position, double width, double max) {
/// Return the value that centers width as closely as possible to position
/// while fitting inside of min and max.
static double centerOn(double position, double width, double max) {
// If it overflows on the left, put it as far left as possible.
if (position - width / 2.0 < 0.0) {
return 0.0;
......@@ -69,7 +69,7 @@ class TextSelectionToolbarLayoutDelegate extends SingleChildLayoutDelegate {
final Offset anchor = fitsAbove ? anchorAbove : anchorBelow;
return Offset(
_centerOn(
centerOn(
anchor.dx,
childSize.width,
size.width,
......
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('positions itself at anchorAbove if it fits and shifts up when not', (WidgetTester tester) async {
late StateSetter setState;
const double toolbarOverlap = 100;
const double height = 500;
double anchorY = 200.0;
await tester.pumpWidget(
MediaQuery(
data: const MediaQueryData(),
child: Directionality(
textDirection: TextDirection.ltr,
child: StatefulBuilder(
builder: (BuildContext context, StateSetter setter) {
setState = setter;
return CustomSingleChildLayout(
delegate: SpellCheckSuggestionsToolbarLayoutDelegate(
anchor: Offset(50.0, anchorY),
),
child: Container(
width: 200.0,
height: height,
color: const Color(0xffff0000),
),
);
},
),
),
),
);
// When the toolbar doesn't fit below anchor, it positions itself such that
// it can just fit.
double toolbarY = tester.getTopLeft(find.byType(Container)).dy;
// Total height available is 600.
expect(toolbarY, equals(toolbarOverlap));
// When it does fit below anchor, it positions itself there.
setState(() {
anchorY = anchorY - toolbarOverlap;
});
await tester.pump();
toolbarY = tester.getTopLeft(find.byType(Container)).dy;
expect(toolbarY, equals(anchorY));
});
}
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
// Vertical position at which to anchor the toolbar for testing.
const double _kAnchor = 200;
// Amount for toolbar to overlap bottom padding for testing.
const double _kTestToolbarOverlap = 10;
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
/// Builds test button items for each of the suggestions provided.
List<ContextMenuButtonItem> buildSuggestionButtons(List<String> suggestions) {
final List<ContextMenuButtonItem> buttonItems = <ContextMenuButtonItem>[];
for (final String suggestion in suggestions) {
buttonItems.add(ContextMenuButtonItem(
onPressed: () {},
label: suggestion,
));
}
final ContextMenuButtonItem deleteButton =
ContextMenuButtonItem(
onPressed: () {},
type: ContextMenuButtonType.delete,
label: 'DELETE',
);
buttonItems.add(deleteButton);
return buttonItems;
}
/// Finds the container of the [SpellCheckSuggestionsToolbar] so that
/// the position of the toolbar itself may be determined.
Finder findSpellCheckSuggestionsToolbar() {
return find.descendant(
of: find.byType(MaterialApp),
matching: find.byWidgetPredicate(
(Widget w) => '${w.runtimeType}' == '_SpellCheckSuggestionsToolbarContainer'),
);
}
testWidgets('positions toolbar below anchor when it fits above bottom view padding', (WidgetTester tester) async {
// We expect the toolbar to be positioned right below the anchor with padding accounted for.
const double expectedToolbarY =
_kAnchor + (2 * SpellCheckSuggestionsToolbar.kToolbarContentDistanceBelow) - TextSelectionToolbar.kToolbarScreenPadding;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SpellCheckSuggestionsToolbar(
anchor: const Offset(0.0, _kAnchor),
buttonItems: buildSuggestionButtons(<String>['hello', 'yellow', 'yell']),
),
),
),
);
final double toolbarY = tester.getTopLeft(findSpellCheckSuggestionsToolbar()).dy;
expect(toolbarY, equals(expectedToolbarY));
});
testWidgets('re-positions toolbar higher below anchor when it does not fit above bottom view padding', (WidgetTester tester) async {
// We expect the toolbar to be positioned _kTestToolbarOverlap pixels above the anchor with padding accounted for.
const double expectedToolbarY =
_kAnchor + (2 * SpellCheckSuggestionsToolbar.kToolbarContentDistanceBelow) - TextSelectionToolbar.kToolbarScreenPadding - _kTestToolbarOverlap;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SpellCheckSuggestionsToolbar(
anchor: const Offset(0.0, _kAnchor - _kTestToolbarOverlap),
buttonItems: buildSuggestionButtons(<String>['hello', 'yellow', 'yell']),
),
),
),
);
final double toolbarY = tester.getTopLeft(findSpellCheckSuggestionsToolbar()).dy;
expect(toolbarY, equals(expectedToolbarY));
});
}
......@@ -8,8 +8,6 @@ import 'package:flutter_test/flutter_test.dart';
import '../widgets/editable_text_utils.dart' show textOffsetToPosition;
const double _kHandleSize = 22.0;
const double _kToolbarContentDistanceBelow = _kHandleSize - 2.0;
const double _kToolbarContentDistance = 8.0;
// A custom text selection menu that just displays a single custom button.
......@@ -35,7 +33,7 @@ class _CustomMaterialTextSelectionControls extends MaterialTextSelectionControls
);
final Offset anchorBelow = Offset(
globalEditableRegion.left + selectionMidpoint.dx,
globalEditableRegion.top + endTextSelectionPoint.point.dy + _kToolbarContentDistanceBelow,
globalEditableRegion.top + endTextSelectionPoint.point.dy + TextSelectionToolbar.kToolbarContentDistanceBelow,
);
return TextSelectionToolbar(
......@@ -155,7 +153,7 @@ void main() {
// When the toolbar doesn't fit above aboveAnchor, it positions itself below
// belowAnchor.
double toolbarY = tester.getTopLeft(findToolbar()).dy;
expect(toolbarY, equals(anchorBelowY + _kToolbarContentDistanceBelow));
expect(toolbarY, equals(anchorBelowY + TextSelectionToolbar.kToolbarContentDistanceBelow));
// Even when it barely doesn't fit.
setState(() {
......@@ -163,7 +161,7 @@ void main() {
});
await tester.pump();
toolbarY = tester.getTopLeft(findToolbar()).dy;
expect(toolbarY, equals(anchorBelowY + _kToolbarContentDistanceBelow));
expect(toolbarY, equals(anchorBelowY + TextSelectionToolbar.kToolbarContentDistanceBelow));
// When it does fit above aboveAnchor, it positions itself there.
setState(() {
......
......@@ -14041,6 +14041,284 @@ testWidgets('Floating cursor ending with selection', (WidgetTester tester) async
expect(tester.takeException(), isA<AssertionError>());
tester.binding.platformDispatcher.clearNativeSpellCheckServiceDefined();
});
testWidgets(
'findSuggestionSpanAtCursorIndex finds correct span with cursor in middle of a word',
(WidgetTester tester) async {
tester.binding.platformDispatcher.nativeSpellCheckServiceDefinedTestValue =
true;
await tester.pumpWidget(
MaterialApp(
home: EditableText(
controller: TextEditingController(text: 'A'),
focusNode: FocusNode(),
style: const TextStyle(),
cursorColor: Colors.blue,
backgroundCursorColor: Colors.grey,
cursorOpacityAnimates: true,
autofillHints: null,
spellCheckConfiguration:
const SpellCheckConfiguration(
misspelledTextStyle: TextField.materialMisspelledTextStyle,
),
),
),
);
final EditableTextState state =
tester.state<EditableTextState>(find.byType(EditableText));
const int cursorIndex = 21;
const SuggestionSpan expectedSpan = SuggestionSpan(TextRange(start: 20, end: 23), <String>['Hey', 'He']);
const List<SuggestionSpan> suggestionSpans =
<SuggestionSpan>[
SuggestionSpan(
TextRange(start: 13, end: 18), <String>['world', 'word', 'old']),
expectedSpan,
SuggestionSpan(
TextRange(start: 25, end: 30), <String>['green', 'grey', 'great']),
];
// Omitting actual text in results for brevity. Same for following tests that test the findSuggestionSpanAtCursorIndex method.
state.spellCheckResults = const SpellCheckResults('', suggestionSpans);
final SuggestionSpan? suggestionSpan = state.findSuggestionSpanAtCursorIndex(cursorIndex);
expect(suggestionSpan, equals(expectedSpan));
});
testWidgets(
'findSuggestionSpanAtCursorIndex finds correct span with cursor on edge of a word',
(WidgetTester tester) async {
tester.binding.platformDispatcher.nativeSpellCheckServiceDefinedTestValue =
true;
await tester.pumpWidget(
MaterialApp(
home: EditableText(
controller: TextEditingController(text: 'A'),
focusNode: FocusNode(),
style: const TextStyle(),
cursorColor: Colors.blue,
backgroundCursorColor: Colors.grey,
cursorOpacityAnimates: true,
autofillHints: null,
spellCheckConfiguration:
const SpellCheckConfiguration(
misspelledTextStyle: TextField.materialMisspelledTextStyle,
),
),
),
);
final EditableTextState state =
tester.state<EditableTextState>(find.byType(EditableText));
const int cursorIndex = 23;
const SuggestionSpan expectedSpan = SuggestionSpan(TextRange(start: 20, end: 23), <String>['Hey', 'He']);
const List<SuggestionSpan> suggestionSpans =
<SuggestionSpan>[
SuggestionSpan(
TextRange(start: 13, end: 18), <String>['world', 'word', 'old']),
expectedSpan,
SuggestionSpan(
TextRange(start: 25, end: 30), <String>['green', 'grey', 'great']),
];
state.spellCheckResults = const SpellCheckResults('', suggestionSpans);
final SuggestionSpan? suggestionSpan = state.findSuggestionSpanAtCursorIndex(cursorIndex);
expect(suggestionSpan, equals(expectedSpan));
});
testWidgets(
'findSuggestionSpanAtCursorIndex finds no span when cursor out of range of spans',
(WidgetTester tester) async {
tester.binding.platformDispatcher.nativeSpellCheckServiceDefinedTestValue =
true;
await tester.pumpWidget(
MaterialApp(
home: EditableText(
controller: TextEditingController(text: 'A'),
focusNode: FocusNode(),
style: const TextStyle(),
cursorColor: Colors.blue,
backgroundCursorColor: Colors.grey,
cursorOpacityAnimates: true,
autofillHints: null,
spellCheckConfiguration:
const SpellCheckConfiguration(
misspelledTextStyle: TextField.materialMisspelledTextStyle,
),
),
),
);
final EditableTextState state =
tester.state<EditableTextState>(find.byType(EditableText));
const int cursorIndex = 33;
const SuggestionSpan expectedSpan = SuggestionSpan(TextRange(start: 20, end: 23), <String>['Hey', 'He']);
const List<SuggestionSpan> suggestionSpans =
<SuggestionSpan>[
SuggestionSpan(
TextRange(start: 13, end: 18), <String>['world', 'word', 'old']),
expectedSpan,
SuggestionSpan(
TextRange(start: 25, end: 30), <String>['green', 'grey', 'great']),
];
state.spellCheckResults = const SpellCheckResults('', suggestionSpans);
final SuggestionSpan? suggestionSpan = state.findSuggestionSpanAtCursorIndex(cursorIndex);
expect(suggestionSpan, isNull);
});
testWidgets(
'findSuggestionSpanAtCursorIndex finds no span when word correctly spelled',
(WidgetTester tester) async {
tester.binding.platformDispatcher.nativeSpellCheckServiceDefinedTestValue =
true;
await tester.pumpWidget(
MaterialApp(
home: EditableText(
controller: TextEditingController(text: 'A'),
focusNode: FocusNode(),
style: const TextStyle(),
cursorColor: Colors.blue,
backgroundCursorColor: Colors.grey,
cursorOpacityAnimates: true,
autofillHints: null,
spellCheckConfiguration:
const SpellCheckConfiguration(
misspelledTextStyle: TextField.materialMisspelledTextStyle,
),
),
),
);
final EditableTextState state =
tester.state<EditableTextState>(find.byType(EditableText));
const int cursorIndex = 5;
const SuggestionSpan expectedSpan = SuggestionSpan(TextRange(start: 20, end: 23), <String>['Hey', 'He']);
const List<SuggestionSpan> suggestionSpans =
<SuggestionSpan>[
SuggestionSpan(
TextRange(start: 13, end: 18), <String>['world', 'word', 'old']),
expectedSpan,
SuggestionSpan(
TextRange(start: 25, end: 30), <String>['green', 'grey', 'great']),
];
state.spellCheckResults = const SpellCheckResults('', suggestionSpans);
final SuggestionSpan? suggestionSpan = state.findSuggestionSpanAtCursorIndex(cursorIndex);
expect(suggestionSpan, isNull);
});
testWidgets('can show spell check suggestions toolbar when there are spell check results', (WidgetTester tester) async {
tester.binding.platformDispatcher.nativeSpellCheckServiceDefinedTestValue =
true;
const TextEditingValue value = TextEditingValue(
text: 'tset test test',
selection: TextSelection(affinity: TextAffinity.upstream, baseOffset: 0, extentOffset: 4),
);
controller.value = value;
await tester.pumpWidget(
MaterialApp(
home: EditableText(
backgroundCursorColor: Colors.grey,
controller: controller,
focusNode: focusNode,
style: textStyle,
cursorColor: cursorColor,
selectionControls: materialTextSelectionControls,
spellCheckConfiguration:
const SpellCheckConfiguration(
misspelledTextStyle: TextField.materialMisspelledTextStyle,
spellCheckSuggestionsToolbarBuilder: TextField.defaultSpellCheckSuggestionsToolbarBuilder,
),
),
),
);
final EditableTextState state =
tester.state<EditableTextState>(find.byType(EditableText));
// Can't show the toolbar when there's no focus.
expect(state.showSpellCheckSuggestionsToolbar(), false);
await tester.pumpAndSettle();
expect(find.text('DELETE'), findsNothing);
// Can't show the toolbar when there are no spell check results.
expect(state.showSpellCheckSuggestionsToolbar(), false);
await tester.pumpAndSettle();
expect(find.text('test'), findsNothing);
expect(find.text('sets'), findsNothing);
expect(find.text('set'), findsNothing);
expect(find.text('DELETE'), findsNothing);
// Can show the toolbar when there are spell check results.
state.spellCheckResults = const SpellCheckResults('test tset test', <SuggestionSpan>[SuggestionSpan(TextRange(start: 0, end: 4), <String>['test', 'sets', 'set'])]);
state.renderEditable.selectWordsInRange(
from: Offset.zero,
cause: SelectionChangedCause.tap,
);
await tester.pumpAndSettle();
expect(state.showSpellCheckSuggestionsToolbar(), true);
await tester.pumpAndSettle();
expect(find.text('test'), findsOneWidget);
expect(find.text('sets'), findsOneWidget);
expect(find.text('set'), findsOneWidget);
expect(find.text('DELETE'), findsOneWidget);
});
testWidgets('spell check suggestions toolbar buttons correctly change the composing region', (WidgetTester tester) async {
tester.binding.platformDispatcher.nativeSpellCheckServiceDefinedTestValue =
true;
const TextEditingValue value = TextEditingValue(
text: 'tset test test',
composing: TextRange(start: 0, end: 4),
selection: TextSelection(affinity: TextAffinity.upstream, baseOffset: 0, extentOffset: 4),
);
controller.value = value;
await tester.pumpWidget(
MaterialApp(
home: EditableText(
backgroundCursorColor: Colors.grey,
controller: controller,
focusNode: focusNode,
style: textStyle,
cursorColor: cursorColor,
selectionControls: materialTextSelectionControls,
spellCheckConfiguration:
const SpellCheckConfiguration(
misspelledTextStyle: TextField.materialMisspelledTextStyle,
spellCheckSuggestionsToolbarBuilder: TextField.defaultSpellCheckSuggestionsToolbarBuilder,
),
),
),
);
final EditableTextState state =
tester.state<EditableTextState>(find.byType(EditableText));
state.spellCheckResults = const SpellCheckResults('tset test test', <SuggestionSpan>[SuggestionSpan(TextRange(start: 0, end: 4), <String>['test', 'sets', 'set'])]);
state.renderEditable.selectWordsInRange(
from: Offset.zero,
cause: SelectionChangedCause.tap,
);
await tester.pumpAndSettle();
// Test misspelled word replacement buttons.
state.showSpellCheckSuggestionsToolbar();
await tester.pumpAndSettle();
expect(find.text('sets'), findsOneWidget);
await tester.tap(find.text('sets'));
await tester.pumpAndSettle();
expect(state.currentTextEditingValue.text, equals('sets test test'));
// Test delete button.
state.showSpellCheckSuggestionsToolbar();
await tester.pumpAndSettle();
await tester.tap(find.text('DELETE'));
await tester.pumpAndSettle();
expect(state.currentTextEditingValue.text, equals(' test test'));
});
});
group('magnifier', () {
......@@ -14205,7 +14483,6 @@ class _CustomTextSelectionControls extends TextSelectionControls {
this.onCut,
});
static const double _kToolbarContentDistanceBelow = 20.0;
static const double _kToolbarContentDistance = 8.0;
final VoidCallback? onPaste;
......@@ -14233,7 +14510,7 @@ class _CustomTextSelectionControls extends TextSelectionControls {
);
final Offset anchorBelow = Offset(
globalEditableRegion.left + selectionMidpoint.dx,
globalEditableRegion.top + endTextSelectionPoint.point.dy + _kToolbarContentDistanceBelow,
globalEditableRegion.top + endTextSelectionPoint.point.dy + TextSelectionToolbar.kToolbarContentDistanceBelow,
);
return _CustomTextSelectionToolbar(
anchorAbove: anchorAbove,
......
......@@ -618,6 +618,27 @@ void main() {
}
}, variant: TargetPlatformVariant.all());
testWidgets('test TextSelectionGestureDetectorBuilder shows spell check toolbar on single tap on Android', (WidgetTester tester) async {
await pumpTextSelectionGestureDetectorBuilder(tester);
final FakeEditableTextState state = tester.state(find.byType(FakeEditableText));
final FakeRenderEditable renderEditable = tester.renderObject(find.byType(FakeEditable));
expect(state.showSpellCheckSuggestionsToolbarCalled, isFalse);
renderEditable.selection = const TextSelection(baseOffset: 2, extentOffset: 6);
renderEditable.hasFocus = true;
final TestGesture gesture = await tester.startGesture(
const Offset(25.0, 200.0),
pointer: 0,
);
await gesture.up();
await tester.pumpAndSettle();
expect(state.showSpellCheckSuggestionsToolbarCalled, isTrue);
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.android }));
testWidgets('test TextSelectionGestureDetectorBuilder double tap', (WidgetTester tester) async {
await pumpTextSelectionGestureDetectorBuilder(tester);
final TestGesture gesture = await tester.startGesture(
......@@ -1568,6 +1589,7 @@ class FakeEditableTextState extends EditableTextState {
final GlobalKey _editableKey = GlobalKey();
bool showToolbarCalled = false;
bool toggleToolbarCalled = false;
bool showSpellCheckSuggestionsToolbarCalled = false;
@override
RenderEditable get renderEditable => _editableKey.currentContext!.findRenderObject()! as RenderEditable;
......@@ -1584,6 +1606,12 @@ class FakeEditableTextState extends EditableTextState {
return;
}
@override
bool showSpellCheckSuggestionsToolbar() {
showSpellCheckSuggestionsToolbarCalled = true;
return true;
}
@override
Widget build(BuildContext context) {
super.build(context);
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment