diff --git a/README.md b/README.md index 02dac999..f0a61288 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ ## feat +- [x] 动态/评论@用户 - [x] 修改消息设置 - [x] 修改聊天设置 - [x] 展示折叠消息 diff --git a/lib/common/widgets/text_field/adaptive_text_selection_toolbar.dart b/lib/common/widgets/text_field/adaptive_text_selection_toolbar.dart new file mode 100644 index 00000000..18ede2dc --- /dev/null +++ b/lib/common/widgets/text_field/adaptive_text_selection_toolbar.dart @@ -0,0 +1,342 @@ +// 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. + +// ignore_for_file: uri_does_not_exist_in_doc_import + +/// @docImport 'selectable_text.dart'; +/// @docImport 'selection_area.dart'; +/// @docImport 'text_field.dart'; +library; + +import 'package:PiliPlus/common/widgets/text_field/editable_text.dart'; +import 'package:flutter/cupertino.dart' hide EditableText, EditableTextState; +import 'package:flutter/material.dart' hide EditableText, EditableTextState; +import 'package:flutter/rendering.dart'; + +/// The default context menu for text selection for the current platform. +/// +/// {@template flutter.material.AdaptiveTextSelectionToolbar.contextMenuBuilders} +/// Typically, this widget would be passed to `contextMenuBuilder` in a +/// supported parent widget, such as: +/// +/// * [EditableText.contextMenuBuilder] +/// * [TextField.contextMenuBuilder] +/// * [CupertinoTextField.contextMenuBuilder] +/// * [SelectionArea.contextMenuBuilder] +/// * [SelectableText.contextMenuBuilder] +/// {@endtemplate} +/// +/// See also: +/// +/// * [EditableText.getEditableButtonItems], which returns the default +/// [ContextMenuButtonItem]s for [EditableText] on the platform. +/// * [AdaptiveTextSelectionToolbar.getAdaptiveButtons], which builds the button +/// Widgets for the current platform given [ContextMenuButtonItem]s. +/// * [CupertinoAdaptiveTextSelectionToolbar], which does the same thing as this +/// widget but only for Cupertino context menus. +/// * [TextSelectionToolbar], the default toolbar for Android. +/// * [DesktopTextSelectionToolbar], the default toolbar for desktop platforms +/// other than MacOS. +/// * [CupertinoTextSelectionToolbar], the default toolbar for iOS. +/// * [CupertinoDesktopTextSelectionToolbar], the default toolbar for MacOS. +class AdaptiveTextSelectionToolbar extends StatelessWidget { + /// Create an instance of [AdaptiveTextSelectionToolbar] with the + /// given [children]. + /// + /// See also: + /// + /// {@template flutter.material.AdaptiveTextSelectionToolbar.buttonItems} + /// * [AdaptiveTextSelectionToolbar.buttonItems], which takes a list of + /// [ContextMenuButtonItem]s instead of [children] widgets. + /// {@endtemplate} + /// {@template flutter.material.AdaptiveTextSelectionToolbar.editable} + /// * [AdaptiveTextSelectionToolbar.editable], which builds the default + /// children for an editable field. + /// {@endtemplate} + /// {@template flutter.material.AdaptiveTextSelectionToolbar.editableText} + /// * [AdaptiveTextSelectionToolbar.editableText], which builds the default + /// children for an [EditableText]. + /// {@endtemplate} + /// {@template flutter.material.AdaptiveTextSelectionToolbar.selectable} + /// * [AdaptiveTextSelectionToolbar.selectable], which builds the default + /// children for content that is selectable but not editable. + /// {@endtemplate} + const AdaptiveTextSelectionToolbar( + {super.key, required this.children, required this.anchors}) + : buttonItems = null; + + /// Create an instance of [AdaptiveTextSelectionToolbar] whose children will + /// be built from the given [buttonItems]. + /// + /// See also: + /// + /// {@template flutter.material.AdaptiveTextSelectionToolbar.new} + /// * [AdaptiveTextSelectionToolbar.new], which takes the children directly as + /// a list of widgets. + /// {@endtemplate} + /// {@macro flutter.material.AdaptiveTextSelectionToolbar.editable} + /// {@macro flutter.material.AdaptiveTextSelectionToolbar.editableText} + /// {@macro flutter.material.AdaptiveTextSelectionToolbar.selectable} + const AdaptiveTextSelectionToolbar.buttonItems({ + super.key, + required this.buttonItems, + required this.anchors, + }) : children = null; + + /// Create an instance of [AdaptiveTextSelectionToolbar] with the default + /// children for an editable field. + /// + /// If an on* callback parameter is null, then its corresponding button will + /// not be built. + /// + /// These callbacks are called when their corresponding button is activated + /// and only then. For example, `onPaste` is called when the user taps the + /// "Paste" button in the context menu and not when the user pastes with the + /// keyboard. + /// + /// See also: + /// + /// {@macro flutter.material.AdaptiveTextSelectionToolbar.new} + /// {@macro flutter.material.AdaptiveTextSelectionToolbar.editableText} + /// {@macro flutter.material.AdaptiveTextSelectionToolbar.buttonItems} + /// {@macro flutter.material.AdaptiveTextSelectionToolbar.selectable} + AdaptiveTextSelectionToolbar.editable({ + super.key, + required ClipboardStatus clipboardStatus, + required VoidCallback? onCopy, + required VoidCallback? onCut, + required VoidCallback? onPaste, + required VoidCallback? onSelectAll, + required VoidCallback? onLookUp, + required VoidCallback? onSearchWeb, + required VoidCallback? onShare, + required VoidCallback? onLiveTextInput, + required this.anchors, + }) : children = null, + buttonItems = EditableText.getEditableButtonItems( + clipboardStatus: clipboardStatus, + onCopy: onCopy, + onCut: onCut, + onPaste: onPaste, + onSelectAll: onSelectAll, + onLookUp: onLookUp, + onSearchWeb: onSearchWeb, + onShare: onShare, + onLiveTextInput: onLiveTextInput, + ); + + /// Create an instance of [AdaptiveTextSelectionToolbar] with the default + /// children for an [EditableText]. + /// + /// See also: + /// + /// {@macro flutter.material.AdaptiveTextSelectionToolbar.new} + /// {@macro flutter.material.AdaptiveTextSelectionToolbar.editable} + /// {@macro flutter.material.AdaptiveTextSelectionToolbar.buttonItems} + /// {@macro flutter.material.AdaptiveTextSelectionToolbar.selectable} + AdaptiveTextSelectionToolbar.editableText({ + super.key, + required EditableTextState editableTextState, + }) : children = null, + buttonItems = editableTextState.contextMenuButtonItems, + anchors = editableTextState.contextMenuAnchors; + + /// Create an instance of [AdaptiveTextSelectionToolbar] with the default + /// children for selectable, but not editable, content. + /// + /// See also: + /// + /// {@macro flutter.material.AdaptiveTextSelectionToolbar.new} + /// {@macro flutter.material.AdaptiveTextSelectionToolbar.buttonItems} + /// {@macro flutter.material.AdaptiveTextSelectionToolbar.editable} + /// {@macro flutter.material.AdaptiveTextSelectionToolbar.editableText} + AdaptiveTextSelectionToolbar.selectable({ + super.key, + required VoidCallback onCopy, + required VoidCallback onSelectAll, + required VoidCallback? onShare, + required SelectionGeometry selectionGeometry, + required this.anchors, + }) : children = null, + buttonItems = SelectableRegion.getSelectableButtonItems( + selectionGeometry: selectionGeometry, + onCopy: onCopy, + onSelectAll: onSelectAll, + onShare: onShare, + ); + + /// Create an instance of [AdaptiveTextSelectionToolbar] with the default + /// children for a [SelectableRegion]. + /// + /// See also: + /// + /// {@macro flutter.material.AdaptiveTextSelectionToolbar.new} + /// {@macro flutter.material.AdaptiveTextSelectionToolbar.buttonItems} + /// {@macro flutter.material.AdaptiveTextSelectionToolbar.editable} + /// {@macro flutter.material.AdaptiveTextSelectionToolbar.editableText} + /// {@macro flutter.material.AdaptiveTextSelectionToolbar.selectable} + AdaptiveTextSelectionToolbar.selectableRegion({ + super.key, + required SelectableRegionState selectableRegionState, + }) : children = null, + buttonItems = selectableRegionState.contextMenuButtonItems, + anchors = selectableRegionState.contextMenuAnchors; + + /// {@template flutter.material.AdaptiveTextSelectionToolbar.buttonItems} + /// The [ContextMenuButtonItem]s that will be turned into the correct button + /// widgets for the current platform. + /// {@endtemplate} + final List? buttonItems; + + /// The children of the toolbar, typically buttons. + final List? children; + + /// {@template flutter.material.AdaptiveTextSelectionToolbar.anchors} + /// The location on which to anchor the menu. + /// {@endtemplate} + final TextSelectionToolbarAnchors anchors; + + /// Returns the default button label String for the button of the given + /// [ContextMenuButtonType] on any platform. + static String getButtonLabel( + BuildContext context, ContextMenuButtonItem buttonItem) { + if (buttonItem.label != null) { + return buttonItem.label!; + } + + switch (Theme.of(context).platform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + return CupertinoTextSelectionToolbarButton.getButtonLabel( + context, buttonItem); + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + assert(debugCheckHasMaterialLocalizations(context)); + final MaterialLocalizations localizations = + MaterialLocalizations.of(context); + return switch (buttonItem.type) { + ContextMenuButtonType.cut => localizations.cutButtonLabel, + ContextMenuButtonType.copy => localizations.copyButtonLabel, + ContextMenuButtonType.paste => localizations.pasteButtonLabel, + ContextMenuButtonType.selectAll => localizations.selectAllButtonLabel, + ContextMenuButtonType.delete => + localizations.deleteButtonTooltip.toUpperCase(), + ContextMenuButtonType.lookUp => localizations.lookUpButtonLabel, + ContextMenuButtonType.searchWeb => localizations.searchWebButtonLabel, + ContextMenuButtonType.share => localizations.shareButtonLabel, + ContextMenuButtonType.liveTextInput => + localizations.scanTextButtonLabel, + ContextMenuButtonType.custom => '', + }; + } + } + + /// Returns a List of Widgets generated by turning [buttonItems] into the + /// default context menu buttons for the current platform. + /// + /// This is useful when building a text selection toolbar with the default + /// button appearance for the given platform, but where the toolbar and/or the + /// button actions and labels may be custom. + /// + /// {@tool dartpad} + /// This sample demonstrates how to use `getAdaptiveButtons` to generate + /// default button widgets in a custom toolbar. + /// + /// ** See code in examples/api/lib/material/context_menu/editable_text_toolbar_builder.2.dart ** + /// {@end-tool} + /// + /// See also: + /// + /// * [CupertinoAdaptiveTextSelectionToolbar.getAdaptiveButtons], which is the + /// Cupertino equivalent of this class and builds only the Cupertino + /// buttons. + static Iterable getAdaptiveButtons( + BuildContext context, + List buttonItems, + ) { + switch (Theme.of(context).platform) { + case TargetPlatform.iOS: + return buttonItems.map((ContextMenuButtonItem buttonItem) { + return CupertinoTextSelectionToolbarButton.buttonItem( + buttonItem: buttonItem); + }); + case TargetPlatform.fuchsia: + case TargetPlatform.android: + final List buttons = []; + for (int i = 0; i < buttonItems.length; i++) { + final ContextMenuButtonItem buttonItem = buttonItems[i]; + buttons.add( + TextSelectionToolbarTextButton( + padding: TextSelectionToolbarTextButton.getPadding( + i, buttonItems.length), + onPressed: buttonItem.onPressed, + alignment: AlignmentDirectional.centerStart, + child: Text(getButtonLabel(context, buttonItem)), + ), + ); + } + return buttons; + case TargetPlatform.linux: + case TargetPlatform.windows: + return buttonItems.map((ContextMenuButtonItem buttonItem) { + return DesktopTextSelectionToolbarButton.text( + context: context, + onPressed: buttonItem.onPressed, + text: getButtonLabel(context, buttonItem), + ); + }); + case TargetPlatform.macOS: + return buttonItems.map((ContextMenuButtonItem buttonItem) { + return CupertinoDesktopTextSelectionToolbarButton.text( + onPressed: buttonItem.onPressed, + text: getButtonLabel(context, buttonItem), + ); + }); + } + } + + @override + Widget build(BuildContext context) { + // If there aren't any buttons to build, build an empty toolbar. + if ((children != null && children!.isEmpty) || + (buttonItems != null && buttonItems!.isEmpty)) { + return const SizedBox.shrink(); + } + + final List resultChildren = children != null + ? children! + : getAdaptiveButtons(context, buttonItems!).toList(); + + switch (Theme.of(context).platform) { + case TargetPlatform.iOS: + return CupertinoTextSelectionToolbar( + anchorAbove: anchors.primaryAnchor, + anchorBelow: anchors.secondaryAnchor == null + ? anchors.primaryAnchor + : anchors.secondaryAnchor!, + children: resultChildren, + ); + case TargetPlatform.android: + return TextSelectionToolbar( + anchorAbove: anchors.primaryAnchor, + anchorBelow: anchors.secondaryAnchor == null + ? anchors.primaryAnchor + : anchors.secondaryAnchor!, + children: resultChildren, + ); + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + return DesktopTextSelectionToolbar( + anchor: anchors.primaryAnchor, children: resultChildren); + case TargetPlatform.macOS: + return CupertinoDesktopTextSelectionToolbar( + anchor: anchors.primaryAnchor, + children: resultChildren, + ); + } + } +} diff --git a/lib/common/widgets/text_field/cupertino/cupertino_adaptive_text_selection_toolbar.dart b/lib/common/widgets/text_field/cupertino/cupertino_adaptive_text_selection_toolbar.dart new file mode 100644 index 00000000..e1311bad --- /dev/null +++ b/lib/common/widgets/text_field/cupertino/cupertino_adaptive_text_selection_toolbar.dart @@ -0,0 +1,236 @@ +// 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. + +/// @docImport 'package:flutter/material.dart'; +library; + +import 'package:PiliPlus/common/widgets/text_field/editable_text.dart'; +import 'package:flutter/cupertino.dart' hide EditableText, EditableTextState; +import 'package:flutter/foundation.dart' show defaultTargetPlatform; +import 'package:flutter/rendering.dart'; + +/// The default Cupertino context menu for text selection for the current +/// platform with the given children. +/// +/// {@template flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.platforms} +/// Builds the mobile Cupertino context menu on all mobile platforms, not just +/// iOS, and builds the desktop Cupertino context menu on all desktop platforms, +/// not just MacOS. For a widget that builds the native-looking context menu for +/// all platforms, see [AdaptiveTextSelectionToolbar]. +/// {@endtemplate} +/// +/// See also: +/// +/// * [AdaptiveTextSelectionToolbar], which does the same thing as this widget +/// but for all platforms, not just the Cupertino-styled platforms. +/// * [CupertinoAdaptiveTextSelectionToolbar.getAdaptiveButtons], which builds +/// the Cupertino button Widgets for the current platform given +/// [ContextMenuButtonItem]s. +class CupertinoAdaptiveTextSelectionToolbar extends StatelessWidget { + /// Create an instance of [CupertinoAdaptiveTextSelectionToolbar] with the + /// given [children]. + /// + /// See also: + /// + /// {@template flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.buttonItems} + /// * [CupertinoAdaptiveTextSelectionToolbar.buttonItems], which takes a list + /// of [ContextMenuButtonItem]s instead of [children] widgets. + /// {@endtemplate} + /// {@template flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.editable} + /// * [CupertinoAdaptiveTextSelectionToolbar.editable], which builds the + /// default Cupertino children for an editable field. + /// {@endtemplate} + /// {@template flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.editableText} + /// * [CupertinoAdaptiveTextSelectionToolbar.editableText], which builds the + /// default Cupertino children for an [EditableText]. + /// {@endtemplate} + /// {@template flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.selectable} + /// * [CupertinoAdaptiveTextSelectionToolbar.selectable], which builds the + /// Cupertino children for content that is selectable but not editable. + /// {@endtemplate} + const CupertinoAdaptiveTextSelectionToolbar({ + super.key, + required this.children, + required this.anchors, + }) : buttonItems = null; + + /// Create an instance of [CupertinoAdaptiveTextSelectionToolbar] whose + /// children will be built from the given [buttonItems]. + /// + /// See also: + /// + /// {@template flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.new} + /// * [CupertinoAdaptiveTextSelectionToolbar.new], which takes the children + /// directly as a list of widgets. + /// {@endtemplate} + /// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.editable} + /// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.editableText} + /// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.selectable} + const CupertinoAdaptiveTextSelectionToolbar.buttonItems({ + super.key, + required this.buttonItems, + required this.anchors, + }) : children = null; + + /// Create an instance of [CupertinoAdaptiveTextSelectionToolbar] with the + /// default children for an editable field. + /// + /// If a callback is null, then its corresponding button will not be built. + /// + /// See also: + /// + /// * [AdaptiveTextSelectionToolbar.editable], which is similar to this but + /// includes Material and Cupertino toolbars. + /// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.new} + /// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.editableText} + /// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.buttonItems} + /// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.selectable} + CupertinoAdaptiveTextSelectionToolbar.editable({ + super.key, + required ClipboardStatus clipboardStatus, + required VoidCallback? onCopy, + required VoidCallback? onCut, + required VoidCallback? onPaste, + required VoidCallback? onSelectAll, + required VoidCallback? onLookUp, + required VoidCallback? onSearchWeb, + required VoidCallback? onShare, + required VoidCallback? onLiveTextInput, + required this.anchors, + }) : children = null, + buttonItems = EditableText.getEditableButtonItems( + clipboardStatus: clipboardStatus, + onCopy: onCopy, + onCut: onCut, + onPaste: onPaste, + onSelectAll: onSelectAll, + onLookUp: onLookUp, + onSearchWeb: onSearchWeb, + onShare: onShare, + onLiveTextInput: onLiveTextInput, + ); + + /// Create an instance of [CupertinoAdaptiveTextSelectionToolbar] with the + /// default children for an [EditableText]. + /// + /// See also: + /// + /// * [AdaptiveTextSelectionToolbar.editableText], which is similar to this + /// but includes Material and Cupertino toolbars. + /// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.new} + /// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.editable} + /// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.buttonItems} + /// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.selectable} + CupertinoAdaptiveTextSelectionToolbar.editableText({ + super.key, + required EditableTextState editableTextState, + }) : children = null, + buttonItems = editableTextState.contextMenuButtonItems, + anchors = editableTextState.contextMenuAnchors; + + /// Create an instance of [CupertinoAdaptiveTextSelectionToolbar] with the + /// default children for selectable, but not editable, content. + /// + /// See also: + /// + /// * [AdaptiveTextSelectionToolbar.selectable], which is similar to this but + /// includes Material and Cupertino toolbars. + /// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.new} + /// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.buttonItems} + /// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.editable} + /// {@macro flutter.cupertino.CupertinoAdaptiveTextSelectionToolbar.editableText} + CupertinoAdaptiveTextSelectionToolbar.selectable({ + super.key, + required VoidCallback onCopy, + required VoidCallback onSelectAll, + required SelectionGeometry selectionGeometry, + required this.anchors, + }) : children = null, + buttonItems = SelectableRegion.getSelectableButtonItems( + selectionGeometry: selectionGeometry, + onCopy: onCopy, + onSelectAll: onSelectAll, + onShare: + null, // See https://github.com/flutter/flutter/issues/141775. + ); + + /// {@macro flutter.material.AdaptiveTextSelectionToolbar.anchors} + final TextSelectionToolbarAnchors anchors; + + /// The children of the toolbar, typically buttons. + final List? children; + + /// The [ContextMenuButtonItem]s that will be turned into the correct button + /// widgets for the current platform. + final List? buttonItems; + + /// Returns a List of Widgets generated by turning [buttonItems] into the + /// default context menu buttons for Cupertino on the current platform. + /// + /// This is useful when building a text selection toolbar with the default + /// button appearance for the given platform, but where the toolbar and/or the + /// button actions and labels may be custom. + /// + /// Does not build Material buttons. On non-Apple platforms, Cupertino buttons + /// will still be used, because the Cupertino library does not access the + /// Material library. To get the native-looking buttons on every platform, + /// use [AdaptiveTextSelectionToolbar.getAdaptiveButtons] in the Material + /// library. + /// + /// See also: + /// + /// * [AdaptiveTextSelectionToolbar.getAdaptiveButtons], which is the Material + /// equivalent of this class and builds only the Material buttons. It + /// includes a live example of using `getAdaptiveButtons`. + static Iterable getAdaptiveButtons( + BuildContext context, + List buttonItems, + ) { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.iOS: + return buttonItems.map((ContextMenuButtonItem buttonItem) { + return CupertinoTextSelectionToolbarButton.buttonItem( + buttonItem: buttonItem); + }); + case TargetPlatform.linux: + case TargetPlatform.windows: + case TargetPlatform.macOS: + return buttonItems.map((ContextMenuButtonItem buttonItem) { + return CupertinoDesktopTextSelectionToolbarButton.buttonItem( + buttonItem: buttonItem); + }); + } + } + + @override + Widget build(BuildContext context) { + // If there aren't any buttons to build, build an empty toolbar. + if ((children?.isEmpty ?? false) || (buttonItems?.isEmpty ?? false)) { + return const SizedBox.shrink(); + } + + final List resultChildren = + children ?? getAdaptiveButtons(context, buttonItems!).toList(); + + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.iOS: + case TargetPlatform.fuchsia: + return CupertinoTextSelectionToolbar( + anchorAbove: anchors.primaryAnchor, + anchorBelow: anchors.secondaryAnchor ?? anchors.primaryAnchor, + children: resultChildren, + ); + case TargetPlatform.linux: + case TargetPlatform.windows: + case TargetPlatform.macOS: + return CupertinoDesktopTextSelectionToolbar( + anchor: anchors.primaryAnchor, + children: resultChildren, + ); + } + } +} diff --git a/lib/common/widgets/text_field/cupertino/cupertino_spell_check_suggestions_toolbar.dart b/lib/common/widgets/text_field/cupertino/cupertino_spell_check_suggestions_toolbar.dart new file mode 100644 index 00000000..8cce9772 --- /dev/null +++ b/lib/common/widgets/text_field/cupertino/cupertino_spell_check_suggestions_toolbar.dart @@ -0,0 +1,162 @@ +// 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. + +/// @docImport 'package:flutter/material.dart'; +library; + +import 'package:PiliPlus/common/widgets/text_field/editable_text.dart'; +import 'package:flutter/cupertino.dart' hide EditableText, EditableTextState; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/services.dart' + show SelectionChangedCause, SuggestionSpan; + +/// iOS only shows 3 spell check suggestions in the toolbar. +const int _kMaxSuggestions = 3; + +/// The default spell check suggestions toolbar for iOS. +/// +/// Tries to position itself below the [anchors], but if it doesn't fit, then it +/// readjusts to fit above bottom view insets. +/// +/// See also: +/// * [SpellCheckSuggestionsToolbar], which is similar but for both the +/// Material and Cupertino libraries. +class CupertinoSpellCheckSuggestionsToolbar extends StatelessWidget { + /// Constructs a [CupertinoSpellCheckSuggestionsToolbar]. + /// + /// [buttonItems] must not contain more than three items. + const CupertinoSpellCheckSuggestionsToolbar({ + super.key, + required this.anchors, + required this.buttonItems, + }) : assert(buttonItems.length <= _kMaxSuggestions); + + /// Constructs a [CupertinoSpellCheckSuggestionsToolbar] with the default + /// children for an [EditableText]. + /// + /// See also: + /// * [SpellCheckSuggestionsToolbar.editableText], which is similar but + /// builds an Android-style toolbar. + CupertinoSpellCheckSuggestionsToolbar.editableText({ + super.key, + required EditableTextState editableTextState, + }) : buttonItems = + buildButtonItems(editableTextState) ?? [], + anchors = editableTextState.contextMenuAnchors; + + /// The location on which to anchor the menu. + final TextSelectionToolbarAnchors anchors; + + /// The [ContextMenuButtonItem]s that will be turned into the correct button + /// widgets and displayed in the spell check suggestions toolbar. + /// + /// Must not contain more than three items. + /// + /// See also: + /// + /// * [AdaptiveTextSelectionToolbar.buttonItems], the list of + /// [ContextMenuButtonItem]s that are used to build the buttons of the + /// text selection toolbar. + /// * [SpellCheckSuggestionsToolbar.buttonItems], the list of + /// [ContextMenuButtonItem]s used to build the Material style spell check + /// suggestions toolbar. + final List buttonItems; + + /// Builds the button items for the toolbar based on the available + /// spell check suggestions. + static List? buildButtonItems( + EditableTextState editableTextState) { + // Determine if composing region is misspelled. + final SuggestionSpan? spanAtCursorIndex = + editableTextState.findSuggestionSpanAtCursorIndex( + editableTextState.currentTextEditingValue.selection.baseOffset, + ); + + if (spanAtCursorIndex == null) { + return null; + } + if (spanAtCursorIndex.suggestions.isEmpty) { + assert(debugCheckHasCupertinoLocalizations(editableTextState.context)); + final CupertinoLocalizations localizations = CupertinoLocalizations.of( + editableTextState.context, + ); + return [ + ContextMenuButtonItem( + onPressed: null, + label: localizations.noSpellCheckReplacementsLabel), + ]; + } + + final List buttonItems = []; + + // Build suggestion buttons. + for (final String suggestion + in spanAtCursorIndex.suggestions.take(_kMaxSuggestions)) { + buttonItems.add( + ContextMenuButtonItem( + onPressed: () { + if (!editableTextState.mounted) { + return; + } + _replaceText( + editableTextState, suggestion, spanAtCursorIndex.range); + }, + label: suggestion, + ), + ); + } + return buttonItems; + } + + static void _replaceText( + EditableTextState editableTextState, + String text, + TextRange replacementRange, + ) { + // Replacement cannot be performed if the text is read only or obscured. + assert(!editableTextState.widget.readOnly && + !editableTextState.widget.obscureText); + + final TextEditingValue newValue = editableTextState.textEditingValue + .replaced(replacementRange, text) + .copyWith( + selection: TextSelection.collapsed( + offset: replacementRange.start + text.length)); + editableTextState.userUpdateTextEditingValue( + newValue, SelectionChangedCause.toolbar); + + // Schedule a call to bringIntoView() after renderEditable updates. + SchedulerBinding.instance.addPostFrameCallback((Duration duration) { + if (editableTextState.mounted) { + editableTextState + .bringIntoView(editableTextState.textEditingValue.selection.extent); + } + }, debugLabel: 'SpellCheckSuggestions.bringIntoView'); + editableTextState.hideToolbar(); + } + + /// Builds the toolbar buttons based on the [buttonItems]. + List _buildToolbarButtons(BuildContext context) { + return buttonItems.map((ContextMenuButtonItem buttonItem) { + return CupertinoTextSelectionToolbarButton.buttonItem( + buttonItem: buttonItem); + }).toList(); + } + + @override + Widget build(BuildContext context) { + if (buttonItems.isEmpty) { + return const SizedBox.shrink(); + } + + final List children = _buildToolbarButtons(context); + return CupertinoTextSelectionToolbar( + anchorAbove: anchors.primaryAnchor, + anchorBelow: anchors.secondaryAnchor == null + ? anchors.primaryAnchor + : anchors.secondaryAnchor!, + children: children, + ); + } +} diff --git a/lib/common/widgets/text_field/cupertino/cupertino_text_field.dart b/lib/common/widgets/text_field/cupertino/cupertino_text_field.dart new file mode 100644 index 00000000..4780715d --- /dev/null +++ b/lib/common/widgets/text_field/cupertino/cupertino_text_field.dart @@ -0,0 +1,1759 @@ +// 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. + +/// @docImport 'package:flutter/material.dart'; +library; + +import 'dart:ui' as ui show BoxHeightStyle, BoxWidthStyle; + +import 'package:PiliPlus/common/widgets/text_field/cupertino/cupertino_adaptive_text_selection_toolbar.dart'; +import 'package:PiliPlus/common/widgets/text_field/cupertino/cupertino_spell_check_suggestions_toolbar.dart'; +import 'package:PiliPlus/common/widgets/text_field/editable_text.dart'; +import 'package:PiliPlus/common/widgets/text_field/spell_check.dart'; +import 'package:PiliPlus/common/widgets/text_field/system_context_menu.dart'; +import 'package:PiliPlus/common/widgets/text_field/text_selection.dart'; +import 'package:flutter/cupertino.dart' + hide + SpellCheckConfiguration, + EditableTextContextMenuBuilder, + EditableText, + EditableTextState, + SystemContextMenu, + CupertinoSpellCheckSuggestionsToolbar, + CupertinoAdaptiveTextSelectionToolbar, + TextSelectionGestureDetectorBuilderDelegate, + TextSelectionGestureDetectorBuilder; +import 'package:flutter/foundation.dart' show defaultTargetPlatform; +import 'package:flutter/gestures.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; + +export 'package:flutter/services.dart' + show + SmartDashesType, + SmartQuotesType, + TextCapitalization, + TextInputAction, + TextInputType; + +const TextStyle _kDefaultPlaceholderStyle = TextStyle( + fontWeight: FontWeight.w400, + color: CupertinoColors.placeholderText, +); + +// Value inspected from Xcode 11 & iOS 13.0 Simulator. +const BorderSide _kDefaultRoundedBorderSide = BorderSide( + color: CupertinoDynamicColor.withBrightness( + color: Color(0x33000000), + darkColor: Color(0x33FFFFFF), + ), + width: 0.0, +); +const Border _kDefaultRoundedBorder = Border( + top: _kDefaultRoundedBorderSide, + bottom: _kDefaultRoundedBorderSide, + left: _kDefaultRoundedBorderSide, + right: _kDefaultRoundedBorderSide, +); + +const BoxDecoration _kDefaultRoundedBorderDecoration = BoxDecoration( + color: CupertinoDynamicColor.withBrightness( + color: CupertinoColors.white, + darkColor: CupertinoColors.black, + ), + border: _kDefaultRoundedBorder, + borderRadius: BorderRadius.all(Radius.circular(5.0)), +); + +const Color _kDisabledBackground = CupertinoDynamicColor.withBrightness( + color: Color(0xFFFAFAFA), + darkColor: Color(0xFF050505), +); + +// Value inspected from Xcode 12 & iOS 14.0 Simulator. +// Note it may not be consistent with https://developer.apple.com/design/resources/. +const CupertinoDynamicColor _kClearButtonColor = + CupertinoDynamicColor.withBrightness( + color: Color(0x33000000), + darkColor: Color(0x33FFFFFF), +); + +// An eyeballed value that moves the cursor slightly left of where it is +// rendered for text on Android so it's positioning more accurately matches the +// native iOS text cursor positioning. +// +// This value is in device pixels, not logical pixels as is typically used +// throughout the codebase. +const int _iOSHorizontalCursorOffsetPixels = -2; + +/// Visibility of text field overlays based on the state of the current text entry. +/// +/// Used to toggle the visibility behavior of the optional decorating widgets +/// surrounding the [EditableText] such as the clear text button. +enum OverlayVisibilityMode { + /// Overlay will never appear regardless of the text entry state. + never, + + /// Overlay will only appear when the current text entry is not empty. + /// + /// This includes prefilled text that the user did not type in manually. But + /// does not include text in placeholders. + editing, + + /// Overlay will only appear when the current text entry is empty. + /// + /// This also includes not having prefilled text that the user did not type + /// in manually. Texts in placeholders are ignored. + notEditing, + + /// Always show the overlay regardless of the text entry state. + always, +} + +class _CupertinoTextFieldSelectionGestureDetectorBuilder + extends TextSelectionGestureDetectorBuilder { + _CupertinoTextFieldSelectionGestureDetectorBuilder( + {required _CupertinoTextFieldState state}) + : _state = state, + super(delegate: state); + + final _CupertinoTextFieldState _state; + + @override + void onSingleTapUp(TapDragUpDetails details) { + // Because TextSelectionGestureDetector listens to taps that happen on + // widgets in front of it, tapping the clear button will also trigger + // this handler. If the clear button widget recognizes the up event, + // then do not handle it. + if (_state._clearGlobalKey.currentContext != null) { + final RenderBox renderBox = _state._clearGlobalKey.currentContext! + .findRenderObject()! as RenderBox; + final Offset localOffset = + renderBox.globalToLocal(details.globalPosition); + if (renderBox.hitTest(BoxHitTestResult(), position: localOffset)) { + return; + } + } + super.onSingleTapUp(details); + _state.widget.onTap?.call(); + } + + @override + void onDragSelectionEnd(TapDragEndDetails details) { + _state._requestKeyboard(); + super.onDragSelectionEnd(details); + } +} + +/// An iOS-style text field. +/// +/// A text field lets the user enter text, either with a hardware keyboard or with +/// an onscreen keyboard. +/// +/// This widget corresponds to both a `UITextField` and an editable `UITextView` +/// on iOS. +/// +/// The text field calls the [onChanged] callback whenever the user changes the +/// text in the field. If the user indicates that they are done typing in the +/// field (e.g., by pressing a button on the soft keyboard), the text field +/// calls the [onSubmitted] callback. +/// +/// {@macro flutter.widgets.EditableText.onChanged} +/// +/// {@tool dartpad} +/// This example shows how to set the initial value of the [CupertinoTextField] using +/// a [controller] that already contains some text. +/// +/// ** See code in examples/api/lib/cupertino/text_field/cupertino_text_field.0.dart ** +/// {@end-tool} +/// +/// The [controller] can also control the selection and composing region (and to +/// observe changes to the text, selection, and composing region). +/// +/// The text field has an overridable [decoration] that, by default, draws a +/// rounded rectangle border around the text field. If you set the [decoration] +/// property to null, the decoration will be removed entirely. +/// +/// {@macro flutter.material.textfield.wantKeepAlive} +/// +/// Remember to call [TextEditingController.dispose] when it is no longer +/// needed. This will ensure we discard any resources used by the object. +/// +/// {@macro flutter.widgets.editableText.showCaretOnScreen} +/// +/// ## Scrolling Considerations +/// +/// If this [CupertinoTextField] is not a descendant of [Scaffold] and is being +/// used within a [Scrollable] or nested [Scrollable]s, consider placing a +/// [ScrollNotificationObserver] above the root [Scrollable] that contains this +/// [CupertinoTextField] to ensure proper scroll coordination for +/// [CupertinoTextField] and its components like [TextSelectionOverlay]. +/// +/// See also: +/// +/// * +/// * [TextField], an alternative text field widget that follows the Material +/// Design UI conventions. +/// * [EditableText], which is the raw text editing control at the heart of a +/// [TextField]. +/// * Learn how to use a [TextEditingController] in one of our [cookbook recipes](https://docs.flutter.dev/cookbook/forms/text-field-changes#2-use-a-texteditingcontroller). +/// * +class CupertinoTextField extends StatefulWidget { + /// Creates an iOS-style text field. + /// + /// To provide a prefilled text entry, pass in a [TextEditingController] with + /// an initial value to the [controller] parameter. + /// + /// To provide a hint placeholder text that appears when the text entry is + /// empty, pass a [String] to the [placeholder] parameter. + /// + /// The [maxLines] property can be set to null to remove the restriction on + /// the number of lines. In this mode, the intrinsic height of the widget will + /// grow as the number of lines of text grows. By default, it is `1`, meaning + /// this is a single-line text field and will scroll horizontally when + /// it overflows. [maxLines] must not be zero. + /// + /// The text cursor is not shown if [showCursor] is false or if [showCursor] + /// is null (the default) and [readOnly] is true. + /// + /// If specified, the [maxLength] property must be greater than zero. + /// + /// The [selectionHeightStyle] and [selectionWidthStyle] properties allow + /// changing the shape of the selection highlighting. These properties default + /// to [ui.BoxHeightStyle.tight] and [ui.BoxWidthStyle.tight], respectively. + /// + /// The [autocorrect], [autofocus], [clearButtonMode], [dragStartBehavior], + /// [expands], [obscureText], [prefixMode], [readOnly], [scrollPadding], + /// [suffixMode], [textAlign], [selectionHeightStyle], [selectionWidthStyle], + /// [enableSuggestions], and [enableIMEPersonalizedLearning] properties must + /// not be null. + /// + /// {@macro flutter.widgets.editableText.accessibility} + /// + /// See also: + /// + /// * [minLines], which is the minimum number of lines to occupy when the + /// content spans fewer lines. + /// * [expands], to allow the widget to size itself to its parent's height. + /// * [maxLength], which discusses the precise meaning of "number of + /// characters" and how it may differ from the intuitive meaning. + const CupertinoTextField({ + super.key, + this.groupId = EditableText, + this.controller, + this.focusNode, + this.undoController, + this.decoration = _kDefaultRoundedBorderDecoration, + this.padding = const EdgeInsets.all(7.0), + this.placeholder, + this.placeholderStyle = const TextStyle( + fontWeight: FontWeight.w400, + color: CupertinoColors.placeholderText, + ), + this.prefix, + this.prefixMode = OverlayVisibilityMode.always, + this.suffix, + this.suffixMode = OverlayVisibilityMode.always, + this.crossAxisAlignment = CrossAxisAlignment.center, + this.clearButtonMode = OverlayVisibilityMode.never, + this.clearButtonSemanticLabel, + TextInputType? keyboardType, + this.textInputAction, + this.textCapitalization = TextCapitalization.none, + this.style, + this.strutStyle, + this.textAlign = TextAlign.start, + this.textAlignVertical, + this.textDirection, + this.readOnly = false, + @Deprecated( + 'Use `contextMenuBuilder` instead. ' + 'This feature was deprecated after v3.3.0-0.5.pre.', + ) + this.toolbarOptions, + this.showCursor, + this.autofocus = false, + this.obscuringCharacter = '•', + this.obscureText = false, + this.autocorrect = true, + SmartDashesType? smartDashesType, + SmartQuotesType? smartQuotesType, + this.enableSuggestions = true, + this.maxLines = 1, + this.minLines, + this.expands = false, + this.maxLength, + this.maxLengthEnforcement, + this.onChanged, + this.onEditingComplete, + this.onSubmitted, + this.onTapOutside, + this.onTapUpOutside, + this.inputFormatters, + this.enabled = true, + this.cursorWidth = 2.0, + this.cursorHeight, + this.cursorRadius = const Radius.circular(2.0), + this.cursorOpacityAnimates = true, + this.cursorColor, + this.selectionHeightStyle = ui.BoxHeightStyle.tight, + this.selectionWidthStyle = ui.BoxWidthStyle.tight, + this.keyboardAppearance, + this.scrollPadding = const EdgeInsets.all(20.0), + this.dragStartBehavior = DragStartBehavior.start, + bool? enableInteractiveSelection, + this.selectionControls, + this.onTap, + this.scrollController, + this.scrollPhysics, + this.autofillHints = const [], + this.contentInsertionConfiguration, + this.clipBehavior = Clip.hardEdge, + this.restorationId, + @Deprecated( + 'Use `stylusHandwritingEnabled` instead. ' + 'This feature was deprecated after v3.27.0-0.2.pre.', + ) + this.scribbleEnabled = true, + this.stylusHandwritingEnabled = + EditableText.defaultStylusHandwritingEnabled, + this.enableIMEPersonalizedLearning = true, + this.contextMenuBuilder = _defaultContextMenuBuilder, + this.spellCheckConfiguration, + this.magnifierConfiguration, + }) : assert(obscuringCharacter.length == 1), + smartDashesType = smartDashesType ?? + (obscureText ? SmartDashesType.disabled : SmartDashesType.enabled), + smartQuotesType = smartQuotesType ?? + (obscureText ? SmartQuotesType.disabled : SmartQuotesType.enabled), + assert(maxLines == null || maxLines > 0), + assert(minLines == null || minLines > 0), + assert( + (maxLines == null) || (minLines == null) || (maxLines >= minLines), + "minLines can't be greater than maxLines", + ), + assert( + !expands || (maxLines == null && minLines == null), + 'minLines and maxLines must be null when expands is true.', + ), + assert(!obscureText || maxLines == 1, + 'Obscured fields cannot be multiline.'), + assert(maxLength == null || maxLength > 0), + // Assert the following instead of setting it directly to avoid surprising the user by silently changing the value they set. + assert( + !identical(textInputAction, TextInputAction.newline) || + maxLines == 1 || + !identical(keyboardType, TextInputType.text), + 'Use keyboardType TextInputType.multiline when using TextInputAction.newline on a multiline TextField.', + ), + keyboardType = keyboardType ?? + (maxLines == 1 ? TextInputType.text : TextInputType.multiline), + enableInteractiveSelection = + enableInteractiveSelection ?? (!readOnly || !obscureText); + + /// Creates a borderless iOS-style text field. + /// + /// To provide a prefilled text entry, pass in a [TextEditingController] with + /// an initial value to the [controller] parameter. + /// + /// To provide a hint placeholder text that appears when the text entry is + /// empty, pass a [String] to the [placeholder] parameter. + /// + /// The [maxLines] property can be set to null to remove the restriction on + /// the number of lines. In this mode, the intrinsic height of the widget will + /// grow as the number of lines of text grows. By default, it is `1`, meaning + /// this is a single-line text field and will scroll horizontally when + /// it overflows. [maxLines] must not be zero. + /// + /// The text cursor is not shown if [showCursor] is false or if [showCursor] + /// is null (the default) and [readOnly] is true. + /// + /// If specified, the [maxLength] property must be greater than zero. + /// + /// The [selectionHeightStyle] and [selectionWidthStyle] properties allow + /// changing the shape of the selection highlighting. These properties default + /// to [ui.BoxHeightStyle.tight] and [ui.BoxWidthStyle.tight] respectively. + /// + /// See also: + /// + /// * [minLines], which is the minimum number of lines to occupy when the + /// content spans fewer lines. + /// * [expands], to allow the widget to size itself to its parent's height. + /// * [maxLength], which discusses the precise meaning of "number of + /// characters" and how it may differ from the intuitive meaning. + const CupertinoTextField.borderless({ + super.key, + this.groupId = EditableText, + this.controller, + this.focusNode, + this.undoController, + this.decoration, + this.padding = const EdgeInsets.all(7.0), + this.placeholder, + this.placeholderStyle = _kDefaultPlaceholderStyle, + this.prefix, + this.prefixMode = OverlayVisibilityMode.always, + this.suffix, + this.suffixMode = OverlayVisibilityMode.always, + this.crossAxisAlignment = CrossAxisAlignment.center, + this.clearButtonMode = OverlayVisibilityMode.never, + this.clearButtonSemanticLabel, + TextInputType? keyboardType, + this.textInputAction, + this.textCapitalization = TextCapitalization.none, + this.style, + this.strutStyle, + this.textAlign = TextAlign.start, + this.textAlignVertical, + this.textDirection, + this.readOnly = false, + @Deprecated( + 'Use `contextMenuBuilder` instead. ' + 'This feature was deprecated after v3.3.0-0.5.pre.', + ) + this.toolbarOptions, + this.showCursor, + this.autofocus = false, + this.obscuringCharacter = '•', + this.obscureText = false, + this.autocorrect = true, + SmartDashesType? smartDashesType, + SmartQuotesType? smartQuotesType, + this.enableSuggestions = true, + this.maxLines = 1, + this.minLines, + this.expands = false, + this.maxLength, + this.maxLengthEnforcement, + this.onChanged, + this.onEditingComplete, + this.onSubmitted, + this.onTapOutside, + this.onTapUpOutside, + this.inputFormatters, + this.enabled = true, + this.cursorWidth = 2.0, + this.cursorHeight, + this.cursorRadius = const Radius.circular(2.0), + this.cursorOpacityAnimates = true, + this.cursorColor, + this.selectionHeightStyle = ui.BoxHeightStyle.tight, + this.selectionWidthStyle = ui.BoxWidthStyle.tight, + this.keyboardAppearance, + this.scrollPadding = const EdgeInsets.all(20.0), + this.dragStartBehavior = DragStartBehavior.start, + bool? enableInteractiveSelection, + this.selectionControls, + this.onTap, + this.scrollController, + this.scrollPhysics, + this.autofillHints = const [], + this.contentInsertionConfiguration, + this.clipBehavior = Clip.hardEdge, + this.restorationId, + @Deprecated( + 'Use `stylusHandwritingEnabled` instead. ' + 'This feature was deprecated after v3.27.0-0.2.pre.', + ) + this.scribbleEnabled = true, + this.stylusHandwritingEnabled = true, + this.enableIMEPersonalizedLearning = true, + this.contextMenuBuilder = _defaultContextMenuBuilder, + this.spellCheckConfiguration, + this.magnifierConfiguration, + }) : assert(obscuringCharacter.length == 1), + smartDashesType = smartDashesType ?? + (obscureText ? SmartDashesType.disabled : SmartDashesType.enabled), + smartQuotesType = smartQuotesType ?? + (obscureText ? SmartQuotesType.disabled : SmartQuotesType.enabled), + assert(maxLines == null || maxLines > 0), + assert(minLines == null || minLines > 0), + assert( + (maxLines == null) || (minLines == null) || (maxLines >= minLines), + "minLines can't be greater than maxLines", + ), + assert( + !expands || (maxLines == null && minLines == null), + 'minLines and maxLines must be null when expands is true.', + ), + assert(!obscureText || maxLines == 1, + 'Obscured fields cannot be multiline.'), + assert(maxLength == null || maxLength > 0), + // Assert the following instead of setting it directly to avoid surprising the user by silently changing the value they set. + assert( + !identical(textInputAction, TextInputAction.newline) || + maxLines == 1 || + !identical(keyboardType, TextInputType.text), + 'Use keyboardType TextInputType.multiline when using TextInputAction.newline on a multiline TextField.', + ), + keyboardType = keyboardType ?? + (maxLines == 1 ? TextInputType.text : TextInputType.multiline), + enableInteractiveSelection = + enableInteractiveSelection ?? (!readOnly || !obscureText); + + /// {@macro flutter.widgets.editableText.groupId} + final Object groupId; + + /// Controls the text being edited. + /// + /// If null, this widget will create its own [TextEditingController]. + final TextEditingController? controller; + + /// {@macro flutter.widgets.Focus.focusNode} + final FocusNode? focusNode; + + /// Controls the [BoxDecoration] of the box behind the text input. + /// + /// Defaults to having a rounded rectangle grey border and can be null to have + /// no box decoration. + final BoxDecoration? decoration; + + /// Padding around the text entry area between the [prefix] and [suffix] + /// or the clear button when [clearButtonMode] is not never. + /// + /// Defaults to a padding of 6 pixels on all sides and can be null. + final EdgeInsetsGeometry padding; + + /// A lighter colored placeholder hint that appears on the first line of the + /// text field when the text entry is empty. + /// + /// Defaults to having no placeholder text. + /// + /// The text style of the placeholder text matches that of the text field's + /// main text entry except a lighter font weight and a grey font color. + final String? placeholder; + + /// The style to use for the placeholder text. + /// + /// The [placeholderStyle] is merged with the [style] [TextStyle] when applied + /// to the [placeholder] text. To avoid merging with [style], specify + /// [TextStyle.inherit] as false. + /// + /// Defaults to the [style] property with w300 font weight and grey color. + /// + /// If specifically set to null, placeholder's style will be the same as [style]. + final TextStyle? placeholderStyle; + + /// An optional [Widget] to display before the text. + final Widget? prefix; + + /// Controls the visibility of the [prefix] widget based on the state of + /// text entry when the [prefix] argument is not null. + /// + /// Defaults to [OverlayVisibilityMode.always]. + /// + /// Has no effect when [prefix] is null. + final OverlayVisibilityMode prefixMode; + + /// An optional [Widget] to display after the text. + final Widget? suffix; + + /// Controls the visibility of the [suffix] widget based on the state of + /// text entry when the [suffix] argument is not null. + /// + /// Defaults to [OverlayVisibilityMode.always]. + /// + /// Has no effect when [suffix] is null. + final OverlayVisibilityMode suffixMode; + + /// Controls the vertical alignment of the [prefix] and the [suffix] widget in relation to content. + /// + /// Defaults to [CrossAxisAlignment.center]. + /// + /// Has no effect when both the [prefix] and [suffix] are null. + final CrossAxisAlignment crossAxisAlignment; + + /// Show an iOS-style clear button to clear the current text entry. + /// + /// Can be made to appear depending on various text states of the + /// [TextEditingController]. + /// + /// Will only appear if no [suffix] widget is appearing. + /// + /// Defaults to [OverlayVisibilityMode.never]. + final OverlayVisibilityMode clearButtonMode; + + /// The semantic label for the clear button used by screen readers. + /// + /// This will be used by screen reading software to identify the clear button + /// widget. Defaults to "Clear". + final String? clearButtonSemanticLabel; + + /// {@macro flutter.widgets.editableText.keyboardType} + final TextInputType keyboardType; + + /// The type of action button to use for the keyboard. + /// + /// Defaults to [TextInputAction.newline] if [keyboardType] is + /// [TextInputType.multiline] and [TextInputAction.done] otherwise. + final TextInputAction? textInputAction; + + /// {@macro flutter.widgets.editableText.textCapitalization} + final TextCapitalization textCapitalization; + + /// The style to use for the text being edited. + /// + /// Also serves as a base for the [placeholder] text's style. + /// + /// Defaults to the standard iOS font style from [CupertinoTheme] if null. + final TextStyle? style; + + /// {@macro flutter.widgets.editableText.strutStyle} + final StrutStyle? strutStyle; + + /// {@macro flutter.widgets.editableText.textAlign} + final TextAlign textAlign; + + /// Configuration of toolbar options. + /// + /// If not set, select all and paste will default to be enabled. Copy and cut + /// will be disabled if [obscureText] is true. If [readOnly] is true, + /// paste and cut will be disabled regardless. + @Deprecated( + 'Use `contextMenuBuilder` instead. ' + 'This feature was deprecated after v3.3.0-0.5.pre.', + ) + final ToolbarOptions? toolbarOptions; + + /// {@macro flutter.material.InputDecorator.textAlignVertical} + final TextAlignVertical? textAlignVertical; + + /// {@macro flutter.widgets.editableText.textDirection} + final TextDirection? textDirection; + + /// {@macro flutter.widgets.editableText.readOnly} + final bool readOnly; + + /// {@macro flutter.widgets.editableText.showCursor} + final bool? showCursor; + + /// {@macro flutter.widgets.editableText.autofocus} + final bool autofocus; + + /// {@macro flutter.widgets.editableText.obscuringCharacter} + final String obscuringCharacter; + + /// {@macro flutter.widgets.editableText.obscureText} + final bool obscureText; + + /// {@macro flutter.widgets.editableText.autocorrect} + final bool autocorrect; + + /// {@macro flutter.services.TextInputConfiguration.smartDashesType} + final SmartDashesType smartDashesType; + + /// {@macro flutter.services.TextInputConfiguration.smartQuotesType} + final SmartQuotesType smartQuotesType; + + /// {@macro flutter.services.TextInputConfiguration.enableSuggestions} + final bool enableSuggestions; + + /// {@macro flutter.widgets.editableText.maxLines} + /// * [expands], which determines whether the field should fill the height of + /// its parent. + final int? maxLines; + + /// {@macro flutter.widgets.editableText.minLines} + /// * [expands], which determines whether the field should fill the height of + /// its parent. + final int? minLines; + + /// {@macro flutter.widgets.editableText.expands} + final bool expands; + + /// The maximum number of characters (Unicode grapheme clusters) to allow in + /// the text field. + /// + /// After [maxLength] characters have been input, additional input + /// is ignored, unless [maxLengthEnforcement] is set to + /// [MaxLengthEnforcement.none]. + /// + /// The TextField enforces the length with a + /// [LengthLimitingTextInputFormatter], which is evaluated after the supplied + /// [inputFormatters], if any. + /// + /// This value must be either null or greater than zero. If set to null + /// (the default), there is no limit to the number of characters allowed. + /// + /// Whitespace characters (e.g. newline, space, tab) are included in the + /// character count. + /// + /// {@macro flutter.services.lengthLimitingTextInputFormatter.maxLength} + final int? maxLength; + + /// Determines how the [maxLength] limit should be enforced. + /// + /// If [MaxLengthEnforcement.none] is set, additional input beyond [maxLength] + /// will not be enforced by the limit. + /// + /// {@macro flutter.services.textFormatter.effectiveMaxLengthEnforcement} + /// + /// {@macro flutter.services.textFormatter.maxLengthEnforcement} + final MaxLengthEnforcement? maxLengthEnforcement; + + /// {@macro flutter.widgets.editableText.onChanged} + final ValueChanged? onChanged; + + /// {@macro flutter.widgets.editableText.onEditingComplete} + final VoidCallback? onEditingComplete; + + /// {@macro flutter.widgets.editableText.onSubmitted} + /// + /// See also: + /// + /// * [TextInputAction.next] and [TextInputAction.previous], which + /// automatically shift the focus to the next/previous focusable item when + /// the user is done editing. + final ValueChanged? onSubmitted; + + /// {@macro flutter.widgets.editableText.onTapOutside} + final TapRegionCallback? onTapOutside; + + /// {@macro flutter.widgets.editableText.onTapUpOutside} + final TapRegionCallback? onTapUpOutside; + + /// {@macro flutter.widgets.editableText.inputFormatters} + final List? inputFormatters; + + /// Disables the text field when false. + /// + /// Text fields in disabled states have a light grey background and don't + /// respond to touch events including the [prefix], [suffix] and the clear + /// button. + /// + /// Defaults to true. + final bool enabled; + + /// {@macro flutter.widgets.editableText.cursorWidth} + final double cursorWidth; + + /// {@macro flutter.widgets.editableText.cursorHeight} + final double? cursorHeight; + + /// {@macro flutter.widgets.editableText.cursorRadius} + final Radius cursorRadius; + + /// {@macro flutter.widgets.editableText.cursorOpacityAnimates} + final bool cursorOpacityAnimates; + + /// The color to use when painting the cursor. + /// + /// Defaults to the [DefaultSelectionStyle.cursorColor]. If that color is + /// null, it uses the [CupertinoThemeData.primaryColor] of the ambient theme, + /// which itself defaults to [CupertinoColors.activeBlue] in the light theme + /// and [CupertinoColors.activeOrange] in the dark theme. + final Color? cursorColor; + + /// Controls how tall the selection highlight boxes are computed to be. + /// + /// See [ui.BoxHeightStyle] for details on available styles. + final ui.BoxHeightStyle selectionHeightStyle; + + /// Controls how wide the selection highlight boxes are computed to be. + /// + /// See [ui.BoxWidthStyle] for details on available styles. + final ui.BoxWidthStyle selectionWidthStyle; + + /// The appearance of the keyboard. + /// + /// This setting is only honored on iOS devices. + /// + /// If null, defaults to [Brightness.light]. + final Brightness? keyboardAppearance; + + /// {@macro flutter.widgets.editableText.scrollPadding} + final EdgeInsets scrollPadding; + + /// {@macro flutter.widgets.editableText.enableInteractiveSelection} + final bool enableInteractiveSelection; + + /// {@macro flutter.widgets.editableText.selectionControls} + final TextSelectionControls? selectionControls; + + /// {@macro flutter.widgets.scrollable.dragStartBehavior} + final DragStartBehavior dragStartBehavior; + + /// {@macro flutter.widgets.editableText.scrollController} + final ScrollController? scrollController; + + /// {@macro flutter.widgets.editableText.scrollPhysics} + final ScrollPhysics? scrollPhysics; + + /// {@macro flutter.widgets.editableText.selectionEnabled} + bool get selectionEnabled => enableInteractiveSelection; + + /// {@macro flutter.material.textfield.onTap} + final GestureTapCallback? onTap; + + /// {@macro flutter.widgets.editableText.autofillHints} + /// {@macro flutter.services.AutofillConfiguration.autofillHints} + final Iterable? autofillHints; + + /// {@macro flutter.material.Material.clipBehavior} + /// + /// Defaults to [Clip.hardEdge]. + final Clip clipBehavior; + + /// {@macro flutter.material.textfield.restorationId} + final String? restorationId; + + /// {@macro flutter.widgets.editableText.scribbleEnabled} + @Deprecated( + 'Use `stylusHandwritingEnabled` instead. ' + 'This feature was deprecated after v3.27.0-0.2.pre.', + ) + final bool scribbleEnabled; + + /// {@macro flutter.widgets.editableText.stylusHandwritingEnabled} + final bool stylusHandwritingEnabled; + + /// {@macro flutter.services.TextInputConfiguration.enableIMEPersonalizedLearning} + final bool enableIMEPersonalizedLearning; + + /// {@macro flutter.widgets.editableText.contentInsertionConfiguration} + final ContentInsertionConfiguration? contentInsertionConfiguration; + + /// {@macro flutter.widgets.EditableText.contextMenuBuilder} + /// + /// If not provided, will build a default menu based on the platform. + /// + /// See also: + /// + /// * [CupertinoAdaptiveTextSelectionToolbar], which is built by default. + final EditableTextContextMenuBuilder? contextMenuBuilder; + + static Widget _defaultContextMenuBuilder( + BuildContext context, + EditableTextState editableTextState, + ) { + if (defaultTargetPlatform == TargetPlatform.iOS && + SystemContextMenu.isSupported(context)) { + return SystemContextMenu.editableText( + editableTextState: editableTextState); + } + return CupertinoAdaptiveTextSelectionToolbar.editableText( + editableTextState: editableTextState); + } + + /// Configuration for the text field magnifier. + /// + /// By default (when this property is set to null), a [CupertinoTextMagnifier] + /// is used on mobile platforms, and nothing on desktop platforms. To suppress + /// the magnifier on all platforms, consider passing + /// [TextMagnifierConfiguration.disabled] explicitly. + /// + /// {@macro flutter.widgets.magnifier.intro} + /// + /// {@tool dartpad} + /// This sample demonstrates how to customize the magnifier that this text field uses. + /// + /// ** See code in examples/api/lib/widgets/text_magnifier/text_magnifier.0.dart ** + /// {@end-tool} + final TextMagnifierConfiguration? magnifierConfiguration; + + /// {@macro flutter.widgets.EditableText.spellCheckConfiguration} + /// + /// If [SpellCheckConfiguration.misspelledTextStyle] is not specified in this + /// configuration, then [cupertinoMisspelledTextStyle] is used by default. + final SpellCheckConfiguration? spellCheckConfiguration; + + /// The [TextStyle] used to indicate misspelled words in the Cupertino style. + /// + /// See also: + /// * [SpellCheckConfiguration.misspelledTextStyle], the style configured to + /// mark misspelled words with. + /// * [TextField.materialMisspelledTextStyle], the style configured + /// to mark misspelled words with in the Material style. + static const TextStyle cupertinoMisspelledTextStyle = TextStyle( + decoration: TextDecoration.underline, + decorationColor: CupertinoColors.systemRed, + decorationStyle: TextDecorationStyle.dotted, + ); + + /// The color of the selection highlight when the spell check menu is visible. + /// + /// Eyeballed from a screenshot taken on an iPhone 11 running iOS 16.2. + @visibleForTesting + static const Color kMisspelledSelectionColor = Color(0x62ff9699); + + /// Default builder for the spell check suggestions toolbar in the Cupertino + /// style. + /// + /// See also: + /// * [spellCheckConfiguration], where this is typically specified for + /// [CupertinoTextField]. + /// * [SpellCheckConfiguration.spellCheckSuggestionsToolbarBuilder], the + /// parameter for which this is the default value for [CupertinoTextField]. + /// * [TextField.defaultSpellCheckSuggestionsToolbarBuilder], which is like + /// this but specifies the default for [CupertinoTextField]. + @visibleForTesting + static Widget defaultSpellCheckSuggestionsToolbarBuilder( + BuildContext context, + EditableTextState editableTextState, + ) { + return CupertinoSpellCheckSuggestionsToolbar.editableText( + editableTextState: editableTextState); + } + + /// {@macro flutter.widgets.undoHistory.controller} + final UndoHistoryController? undoController; + + @override + State createState() => _CupertinoTextFieldState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add( + DiagnosticsProperty('controller', controller, + defaultValue: null), + ); + properties.add(DiagnosticsProperty('focusNode', focusNode, + defaultValue: null)); + properties.add( + DiagnosticsProperty( + 'undoController', + undoController, + defaultValue: null, + ), + ); + properties + .add(DiagnosticsProperty('decoration', decoration)); + properties.add(DiagnosticsProperty('padding', padding)); + properties.add(StringProperty('placeholder', placeholder)); + properties.add( + DiagnosticsProperty('placeholderStyle', placeholderStyle)); + properties.add( + DiagnosticsProperty( + 'prefix', prefix == null ? null : prefixMode), + ); + properties.add( + DiagnosticsProperty( + 'suffix', suffix == null ? null : suffixMode), + ); + properties.add(DiagnosticsProperty( + 'clearButtonMode', clearButtonMode)); + properties.add( + DiagnosticsProperty( + 'clearButtonSemanticLabel', clearButtonSemanticLabel), + ); + properties.add( + DiagnosticsProperty( + 'keyboardType', + keyboardType, + defaultValue: TextInputType.text, + ), + ); + properties.add( + DiagnosticsProperty('style', style, defaultValue: null)); + properties.add( + DiagnosticsProperty('autofocus', autofocus, defaultValue: false)); + properties.add( + DiagnosticsProperty('obscuringCharacter', obscuringCharacter, + defaultValue: '•'), + ); + properties.add(DiagnosticsProperty('obscureText', obscureText, + defaultValue: false)); + properties.add(DiagnosticsProperty('autocorrect', autocorrect, + defaultValue: true)); + properties.add( + EnumProperty( + 'smartDashesType', + smartDashesType, + defaultValue: + obscureText ? SmartDashesType.disabled : SmartDashesType.enabled, + ), + ); + properties.add( + EnumProperty( + 'smartQuotesType', + smartQuotesType, + defaultValue: + obscureText ? SmartQuotesType.disabled : SmartQuotesType.enabled, + ), + ); + properties.add( + DiagnosticsProperty('enableSuggestions', enableSuggestions, + defaultValue: true), + ); + properties.add(IntProperty('maxLines', maxLines, defaultValue: 1)); + properties.add(IntProperty('minLines', minLines, defaultValue: null)); + properties.add( + DiagnosticsProperty('expands', expands, defaultValue: false)); + properties.add(IntProperty('maxLength', maxLength, defaultValue: null)); + properties.add( + EnumProperty( + 'maxLengthEnforcement', + maxLengthEnforcement, + defaultValue: null, + ), + ); + properties + .add(DoubleProperty('cursorWidth', cursorWidth, defaultValue: 2.0)); + properties + .add(DoubleProperty('cursorHeight', cursorHeight, defaultValue: null)); + properties.add(DiagnosticsProperty('cursorRadius', cursorRadius, + defaultValue: null)); + properties.add( + DiagnosticsProperty('cursorOpacityAnimates', cursorOpacityAnimates, + defaultValue: true), + ); + properties.add(createCupertinoColorProperty('cursorColor', cursorColor, + defaultValue: null)); + properties.add( + FlagProperty( + 'selectionEnabled', + value: selectionEnabled, + defaultValue: true, + ifFalse: 'selection disabled', + ), + ); + properties.add( + DiagnosticsProperty( + 'selectionControls', + selectionControls, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty( + 'scrollController', + scrollController, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty('scrollPhysics', scrollPhysics, + defaultValue: null), + ); + properties.add(EnumProperty('textAlign', textAlign, + defaultValue: TextAlign.start)); + properties.add( + DiagnosticsProperty( + 'textAlignVertical', + textAlignVertical, + defaultValue: null, + ), + ); + properties.add(EnumProperty('textDirection', textDirection, + defaultValue: null)); + properties.add( + DiagnosticsProperty('clipBehavior', clipBehavior, + defaultValue: Clip.hardEdge), + ); + properties.add( + DiagnosticsProperty('scribbleEnabled', scribbleEnabled, + defaultValue: true), + ); + properties.add( + DiagnosticsProperty( + 'stylusHandwritingEnabled', + stylusHandwritingEnabled, + defaultValue: EditableText.defaultStylusHandwritingEnabled, + ), + ); + properties.add( + DiagnosticsProperty( + 'enableIMEPersonalizedLearning', + enableIMEPersonalizedLearning, + defaultValue: true, + ), + ); + properties.add( + DiagnosticsProperty( + 'spellCheckConfiguration', + spellCheckConfiguration, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty>( + 'contentCommitMimeTypes', + contentInsertionConfiguration?.allowedMimeTypes ?? const [], + defaultValue: contentInsertionConfiguration == null + ? const [] + : kDefaultContentInsertionMimeTypes, + ), + ); + } + + static final TextMagnifierConfiguration _iosMagnifierConfiguration = + TextMagnifierConfiguration( + magnifierBuilder: ( + BuildContext context, + MagnifierController controller, + ValueNotifier magnifierInfo, + ) { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.iOS: + return CupertinoTextMagnifier( + controller: controller, magnifierInfo: magnifierInfo); + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + return null; + } + }, + ); + + /// Returns a new [SpellCheckConfiguration] where the given configuration has + /// had any missing values replaced with their defaults for the iOS platform. + static SpellCheckConfiguration inferIOSSpellCheckConfiguration( + SpellCheckConfiguration? configuration, + ) { + if (configuration == null || + configuration == const SpellCheckConfiguration.disabled()) { + return const SpellCheckConfiguration.disabled(); + } + + return configuration.copyWith( + misspelledTextStyle: configuration.misspelledTextStyle ?? + CupertinoTextField.cupertinoMisspelledTextStyle, + misspelledSelectionColor: configuration.misspelledSelectionColor ?? + CupertinoTextField.kMisspelledSelectionColor, + spellCheckSuggestionsToolbarBuilder: + configuration.spellCheckSuggestionsToolbarBuilder ?? + CupertinoTextField.defaultSpellCheckSuggestionsToolbarBuilder, + ); + } +} + +class _CupertinoTextFieldState extends State + with RestorationMixin, AutomaticKeepAliveClientMixin + implements TextSelectionGestureDetectorBuilderDelegate, AutofillClient { + final GlobalKey _clearGlobalKey = GlobalKey(); + + RestorableTextEditingController? _controller; + TextEditingController get _effectiveController => + widget.controller ?? _controller!.value; + + FocusNode? _focusNode; + FocusNode get _effectiveFocusNode => + widget.focusNode ?? (_focusNode ??= FocusNode()); + + MaxLengthEnforcement get _effectiveMaxLengthEnforcement => + widget.maxLengthEnforcement ?? + LengthLimitingTextInputFormatter.getDefaultMaxLengthEnforcement(); + + bool _showSelectionHandles = false; + + late _CupertinoTextFieldSelectionGestureDetectorBuilder + _selectionGestureDetectorBuilder; + + // API for TextSelectionGestureDetectorBuilderDelegate. + @override + bool get forcePressEnabled => true; + + @override + final GlobalKey editableTextKey = + GlobalKey(); + + @override + bool get selectionEnabled => widget.selectionEnabled; + // End of API for TextSelectionGestureDetectorBuilderDelegate. + + @override + void initState() { + super.initState(); + _selectionGestureDetectorBuilder = + _CupertinoTextFieldSelectionGestureDetectorBuilder( + state: this, + ); + if (widget.controller == null) { + _createLocalController(); + } + _effectiveFocusNode.canRequestFocus = widget.enabled; + _effectiveFocusNode.addListener(_handleFocusChanged); + } + + @override + void didUpdateWidget(CupertinoTextField oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.controller == null && oldWidget.controller != null) { + _createLocalController(oldWidget.controller!.value); + } else if (widget.controller != null && oldWidget.controller == null) { + unregisterFromRestoration(_controller!); + _controller!.dispose(); + _controller = null; + } + + if (widget.focusNode != oldWidget.focusNode) { + (oldWidget.focusNode ?? _focusNode)?.removeListener(_handleFocusChanged); + (widget.focusNode ?? _focusNode)?.addListener(_handleFocusChanged); + } + _effectiveFocusNode.canRequestFocus = widget.enabled; + } + + @override + void restoreState(RestorationBucket? oldBucket, bool initialRestore) { + if (_controller != null) { + _registerController(); + } + } + + void _registerController() { + assert(_controller != null); + registerForRestoration(_controller!, 'controller'); + _controller!.value.addListener(updateKeepAlive); + } + + void _createLocalController([TextEditingValue? value]) { + assert(_controller == null); + _controller = value == null + ? RestorableTextEditingController() + : RestorableTextEditingController.fromValue(value); + if (!restorePending) { + _registerController(); + } + } + + @override + String? get restorationId => widget.restorationId; + + @override + void dispose() { + _effectiveFocusNode.removeListener(_handleFocusChanged); + _focusNode?.dispose(); + _controller?.dispose(); + super.dispose(); + } + + EditableTextState get _editableText => editableTextKey.currentState!; + + void _requestKeyboard() { + _editableText.requestKeyboard(); + } + + void _handleFocusChanged() { + setState(() { + // Rebuild the widget on focus change to show/hide the text selection + // highlight. + }); + } + + bool _shouldShowSelectionHandles(SelectionChangedCause? cause) { + // When the text field is activated by something that doesn't trigger the + // selection overlay, we shouldn't show the handles either. + if (!_selectionGestureDetectorBuilder.shouldShowSelectionToolbar) { + return false; + } + + // On iOS, we don't show handles when the selection is collapsed. + if (_effectiveController.selection.isCollapsed) { + return false; + } + + if (cause == SelectionChangedCause.keyboard) { + return false; + } + + if (cause == SelectionChangedCause.stylusHandwriting) { + return true; + } + + if (_effectiveController.text.isNotEmpty) { + return true; + } + + return false; + } + + void _handleSelectionChanged( + TextSelection selection, SelectionChangedCause? cause) { + final bool willShowSelectionHandles = _shouldShowSelectionHandles(cause); + if (willShowSelectionHandles != _showSelectionHandles) { + setState(() { + _showSelectionHandles = willShowSelectionHandles; + }); + } + + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + case TargetPlatform.linux: + case TargetPlatform.windows: + case TargetPlatform.fuchsia: + case TargetPlatform.android: + if (cause == SelectionChangedCause.longPress) { + _editableText.bringIntoView(selection.extent); + } + } + + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + case TargetPlatform.fuchsia: + case TargetPlatform.android: + break; + case TargetPlatform.macOS: + case TargetPlatform.linux: + case TargetPlatform.windows: + if (cause == SelectionChangedCause.drag) { + _editableText.hideToolbar(); + } + } + } + + @override + bool get wantKeepAlive => _controller?.value.text.isNotEmpty ?? false; + + static bool _shouldShowAttachment({ + required OverlayVisibilityMode attachment, + required bool hasText, + }) { + return switch (attachment) { + OverlayVisibilityMode.never => false, + OverlayVisibilityMode.always => true, + OverlayVisibilityMode.editing => hasText, + OverlayVisibilityMode.notEditing => !hasText, + }; + } + + // True if any surrounding decoration widgets will be shown. + bool get _hasDecoration { + return widget.placeholder != null || + widget.clearButtonMode != OverlayVisibilityMode.never || + widget.prefix != null || + widget.suffix != null; + } + + // Provide default behavior if widget.textAlignVertical is not set. + // CupertinoTextField has top alignment by default, unless it has decoration + // like a prefix or suffix, in which case it's aligned to the center. + TextAlignVertical get _textAlignVertical { + if (widget.textAlignVertical != null) { + return widget.textAlignVertical!; + } + return _hasDecoration ? TextAlignVertical.center : TextAlignVertical.top; + } + + void _onClearButtonTapped() { + final bool hadText = _effectiveController.text.isNotEmpty; + _effectiveController.clear(); + if (hadText) { + // Tapping the clear button is also considered a "user initiated" change + // (instead of a programmatical one), so call `onChanged` if the text + // changed as a result. + widget.onChanged?.call(_effectiveController.text); + } + } + + Widget _buildClearButton() { + final String clearLabel = widget.clearButtonSemanticLabel ?? + CupertinoLocalizations.of(context).clearButtonLabel; + + return Semantics( + button: true, + label: clearLabel, + child: GestureDetector( + key: _clearGlobalKey, + onTap: widget.enabled ? _onClearButtonTapped : null, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6.0), + child: Icon( + CupertinoIcons.clear_thick_circled, + size: 18.0, + color: CupertinoDynamicColor.resolve(_kClearButtonColor, context), + ), + ), + ), + ); + } + + Widget _addTextDependentAttachments( + Widget editableText, + TextStyle textStyle, + TextStyle placeholderStyle, + ) { + // If there are no surrounding widgets, just return the core editable text + // part. + if (!_hasDecoration) { + return editableText; + } + + // Otherwise, listen to the current state of the text entry. + return ValueListenableBuilder( + valueListenable: _effectiveController, + child: editableText, + builder: (BuildContext context, TextEditingValue text, Widget? child) { + final bool hasText = text.text.isNotEmpty; + final String? placeholderText = widget.placeholder; + final Widget? placeholder = placeholderText == null + ? null + // Make the placeholder invisible when hasText is true. + : Visibility( + maintainAnimation: true, + maintainSize: true, + maintainState: true, + visible: !hasText, + child: SizedBox( + width: double.infinity, + child: Padding( + padding: widget.padding, + child: Text( + placeholderText, + // This is to make sure the text field is always tall enough + // to accommodate the first line of the placeholder, so the + // text does not shrink vertically as you type (however in + // rare circumstances, the height may still change when + // there's no placeholder text). + maxLines: hasText ? 1 : widget.maxLines, + overflow: placeholderStyle.overflow, + style: placeholderStyle, + textAlign: widget.textAlign, + ), + ), + ), + ); + + final Widget? prefixWidget = _shouldShowAttachment( + attachment: widget.prefixMode, hasText: hasText) + ? widget.prefix + : null; + + // Show user specified suffix if applicable and fall back to clear button. + final bool showUserSuffix = _shouldShowAttachment( + attachment: widget.suffixMode, + hasText: hasText, + ); + final bool showClearButton = _shouldShowAttachment( + attachment: widget.clearButtonMode, + hasText: hasText, + ); + final Widget? suffixWidget = + switch ((showUserSuffix, showClearButton)) { + (false, false) => null, + (true, false) => widget.suffix, + (true, true) => widget.suffix ?? _buildClearButton(), + (false, true) => _buildClearButton(), + }; + return Row( + crossAxisAlignment: widget.crossAxisAlignment, + children: [ + // Insert a prefix at the front if the prefix visibility mode matches + // the current text state. + if (prefixWidget != null) prefixWidget, + // In the middle part, stack the placeholder on top of the main EditableText + // if needed. + Expanded( + child: Stack( + // Ideally this should be baseline aligned. However that comes at + // the cost of the ability to compute the intrinsic dimensions of + // this widget. + // See also https://github.com/flutter/flutter/issues/13715. + alignment: AlignmentDirectional.center, + textDirection: widget.textDirection, + children: [ + if (placeholder != null) placeholder, + editableText + ], + ), + ), + if (suffixWidget != null) suffixWidget, + ], + ); + }, + ); + } + + // AutofillClient implementation start. + @override + String get autofillId => _editableText.autofillId; + + @override + void autofill(TextEditingValue newEditingValue) => + _editableText.autofill(newEditingValue); + + @override + TextInputConfiguration get textInputConfiguration { + final List? autofillHints = + widget.autofillHints?.toList(growable: false); + final AutofillConfiguration autofillConfiguration = autofillHints != null + ? AutofillConfiguration( + uniqueIdentifier: autofillId, + autofillHints: autofillHints, + currentEditingValue: _effectiveController.value, + hintText: widget.placeholder, + ) + : AutofillConfiguration.disabled; + + return _editableText.textInputConfiguration.copyWith( + autofillConfiguration: autofillConfiguration, + ); + } + // AutofillClient implementation end. + + @override + Widget build(BuildContext context) { + super.build(context); // See AutomaticKeepAliveClientMixin. + assert(debugCheckHasDirectionality(context)); + final TextEditingController controller = _effectiveController; + + TextSelectionControls? textSelectionControls = widget.selectionControls; + VoidCallback? handleDidGainAccessibilityFocus; + VoidCallback? handleDidLoseAccessibilityFocus; + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + case TargetPlatform.android: + case TargetPlatform.fuchsia: + textSelectionControls ??= cupertinoTextSelectionHandleControls; + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + textSelectionControls ??= cupertinoDesktopTextSelectionHandleControls; + handleDidGainAccessibilityFocus = () { + // Automatically activate the TextField when it receives accessibility focus. + if (!_effectiveFocusNode.hasFocus && + _effectiveFocusNode.canRequestFocus) { + _effectiveFocusNode.requestFocus(); + } + }; + handleDidLoseAccessibilityFocus = () { + _effectiveFocusNode.unfocus(); + }; + } + + final bool enabled = widget.enabled; + final Offset cursorOffset = Offset( + _iOSHorizontalCursorOffsetPixels / MediaQuery.devicePixelRatioOf(context), + 0, + ); + final List formatters = [ + ...?widget.inputFormatters, + if (widget.maxLength != null) + LengthLimitingTextInputFormatter( + widget.maxLength, + maxLengthEnforcement: _effectiveMaxLengthEnforcement, + ), + ]; + final CupertinoThemeData themeData = CupertinoTheme.of(context); + + final TextStyle? resolvedStyle = widget.style?.copyWith( + color: CupertinoDynamicColor.maybeResolve(widget.style?.color, context), + backgroundColor: CupertinoDynamicColor.maybeResolve( + widget.style?.backgroundColor, context), + ); + + final TextStyle textStyle = + themeData.textTheme.textStyle.merge(resolvedStyle); + + final TextStyle? resolvedPlaceholderStyle = + widget.placeholderStyle?.copyWith( + color: CupertinoDynamicColor.maybeResolve( + widget.placeholderStyle?.color, context), + backgroundColor: CupertinoDynamicColor.maybeResolve( + widget.placeholderStyle?.backgroundColor, + context, + ), + ); + + final TextStyle placeholderStyle = + textStyle.merge(resolvedPlaceholderStyle); + + final Brightness keyboardAppearance = + widget.keyboardAppearance ?? CupertinoTheme.brightnessOf(context); + final Color cursorColor = CupertinoDynamicColor.maybeResolve( + widget.cursorColor ?? DefaultSelectionStyle.of(context).cursorColor, + context, + ) ?? + themeData.primaryColor; + + final Color disabledColor = + CupertinoDynamicColor.resolve(_kDisabledBackground, context); + + final Color? decorationColor = CupertinoDynamicColor.maybeResolve( + widget.decoration?.color, + context, + ); + + final BoxBorder? border = widget.decoration?.border; + Border? resolvedBorder = border as Border?; + if (border is Border) { + BorderSide resolveBorderSide(BorderSide side) { + return side == BorderSide.none + ? side + : side.copyWith( + color: CupertinoDynamicColor.resolve(side.color, context)); + } + + resolvedBorder = border.runtimeType != Border + ? border + : Border( + top: resolveBorderSide(border.top), + left: resolveBorderSide(border.left), + bottom: resolveBorderSide(border.bottom), + right: resolveBorderSide(border.right), + ); + } + + // Use the default disabled color only if the box decoration was not set. + final BoxDecoration? effectiveDecoration = widget.decoration?.copyWith( + border: resolvedBorder, + color: enabled + ? decorationColor + : (widget.decoration == _kDefaultRoundedBorderDecoration + ? disabledColor + : widget.decoration?.color), + ); + + final Color selectionColor = CupertinoDynamicColor.maybeResolve( + DefaultSelectionStyle.of(context).selectionColor, + context, + ) ?? + CupertinoTheme.of(context).primaryColor.withOpacity(0.2); + + // Set configuration as disabled if not otherwise specified. If specified, + // ensure that configuration uses Cupertino text style for misspelled words + // unless a custom style is specified. + final SpellCheckConfiguration spellCheckConfiguration = + CupertinoTextField.inferIOSSpellCheckConfiguration( + widget.spellCheckConfiguration); + + final Widget paddedEditable = Padding( + padding: widget.padding, + child: RepaintBoundary( + child: UnmanagedRestorationScope( + bucket: bucket, + child: EditableText( + key: editableTextKey, + controller: controller, + undoController: widget.undoController, + readOnly: widget.readOnly || !enabled, + toolbarOptions: widget.toolbarOptions, + showCursor: widget.showCursor, + showSelectionHandles: _showSelectionHandles, + focusNode: _effectiveFocusNode, + keyboardType: widget.keyboardType, + textInputAction: widget.textInputAction, + textCapitalization: widget.textCapitalization, + style: textStyle, + strutStyle: widget.strutStyle, + textAlign: widget.textAlign, + textDirection: widget.textDirection, + autofocus: widget.autofocus, + obscuringCharacter: widget.obscuringCharacter, + obscureText: widget.obscureText, + autocorrect: widget.autocorrect, + smartDashesType: widget.smartDashesType, + smartQuotesType: widget.smartQuotesType, + enableSuggestions: widget.enableSuggestions, + maxLines: widget.maxLines, + minLines: widget.minLines, + expands: widget.expands, + magnifierConfiguration: widget.magnifierConfiguration ?? + CupertinoTextField._iosMagnifierConfiguration, + // Only show the selection highlight when the text field is focused. + selectionColor: + _effectiveFocusNode.hasFocus ? selectionColor : null, + selectionControls: + widget.selectionEnabled ? textSelectionControls : null, + groupId: widget.groupId, + onChanged: widget.onChanged, + onSelectionChanged: _handleSelectionChanged, + onEditingComplete: widget.onEditingComplete, + onSubmitted: widget.onSubmitted, + onTapOutside: widget.onTapOutside, + inputFormatters: formatters, + rendererIgnoresPointer: true, + cursorWidth: widget.cursorWidth, + cursorHeight: widget.cursorHeight, + cursorRadius: widget.cursorRadius, + cursorColor: cursorColor, + cursorOpacityAnimates: widget.cursorOpacityAnimates, + cursorOffset: cursorOffset, + paintCursorAboveText: true, + autocorrectionTextRectColor: selectionColor, + backgroundCursorColor: CupertinoDynamicColor.resolve( + CupertinoColors.inactiveGray, + context, + ), + selectionHeightStyle: widget.selectionHeightStyle, + selectionWidthStyle: widget.selectionWidthStyle, + scrollPadding: widget.scrollPadding, + keyboardAppearance: keyboardAppearance, + dragStartBehavior: widget.dragStartBehavior, + scrollController: widget.scrollController, + scrollPhysics: widget.scrollPhysics, + enableInteractiveSelection: widget.enableInteractiveSelection, + autofillClient: this, + clipBehavior: widget.clipBehavior, + restorationId: 'editable', + scribbleEnabled: widget.scribbleEnabled, + stylusHandwritingEnabled: widget.stylusHandwritingEnabled, + enableIMEPersonalizedLearning: widget.enableIMEPersonalizedLearning, + contentInsertionConfiguration: widget.contentInsertionConfiguration, + contextMenuBuilder: widget.contextMenuBuilder, + spellCheckConfiguration: spellCheckConfiguration, + ), + ), + ), + ); + + return Semantics( + enabled: enabled, + onTap: !enabled || widget.readOnly + ? null + : () { + if (!controller.selection.isValid) { + controller.selection = + TextSelection.collapsed(offset: controller.text.length); + } + _requestKeyboard(); + }, + onDidGainAccessibilityFocus: handleDidGainAccessibilityFocus, + onDidLoseAccessibilityFocus: handleDidLoseAccessibilityFocus, + onFocus: enabled + ? () { + assert( + _effectiveFocusNode.canRequestFocus, + 'Received SemanticsAction.focus from the engine. However, the FocusNode ' + 'of this text field cannot gain focus. This likely indicates a bug. ' + 'If this text field cannot be focused (e.g. because it is not ' + 'enabled), then its corresponding semantics node must be configured ' + 'such that the assistive technology cannot request focus on it.', + ); + + if (_effectiveFocusNode.canRequestFocus && + !_effectiveFocusNode.hasFocus) { + _effectiveFocusNode.requestFocus(); + } else if (!widget.readOnly) { + // If the platform requested focus, that means that previously the + // platform believed that the text field did not have focus (even + // though Flutter's widget system believed otherwise). This likely + // means that the on-screen keyboard is hidden, or more generally, + // there is no current editing session in this field. To correct + // that, keyboard must be requested. + // + // A concrete scenario where this can happen is when the user + // dismisses the keyboard on the web. The editing session is + // closed by the engine, but the text field widget stays focused + // in the framework. + _requestKeyboard(); + } + } + : null, + child: TextFieldTapRegion( + child: IgnorePointer( + ignoring: !enabled, + child: Container( + decoration: effectiveDecoration, + color: + !enabled && effectiveDecoration == null ? disabledColor : null, + child: _selectionGestureDetectorBuilder.buildGestureDetector( + behavior: HitTestBehavior.translucent, + child: Align( + alignment: Alignment(-1.0, _textAlignVertical.y), + widthFactor: 1.0, + heightFactor: 1.0, + child: _addTextDependentAttachments( + paddedEditable, textStyle, placeholderStyle), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/common/widgets/text_field/editable_text.dart b/lib/common/widgets/text_field/editable_text.dart new file mode 100644 index 00000000..cfdf8eb6 --- /dev/null +++ b/lib/common/widgets/text_field/editable_text.dart @@ -0,0 +1,6609 @@ +// 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. + +// ignore_for_file: uri_does_not_exist_in_doc_import + +/// @docImport 'package:flutter/cupertino.dart'; +/// @docImport 'package:flutter/material.dart'; +/// +/// @docImport 'app.dart'; +/// @docImport 'context_menu_controller.dart'; +/// @docImport 'form.dart'; +/// @docImport 'restoration.dart'; +/// @docImport 'restoration_properties.dart'; +/// @docImport 'selectable_region.dart'; +/// @docImport 'text_selection_toolbar_layout_delegate.dart'; +library; + +import 'dart:async'; +import 'dart:math' as math; +import 'dart:ui' as ui hide TextStyle; +import 'dart:ui'; + +import 'package:PiliPlus/common/widgets/text_field/spell_check.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart' + hide SpellCheckConfiguration, buildTextSpanWithSpellCheckSuggestions; +import 'package:flutter/rendering.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/services.dart'; + +export 'package:flutter/services.dart' + show + KeyboardInsertedContent, + SelectionChangedCause, + SmartDashesType, + SmartQuotesType, + TextEditingValue, + TextInputType, + TextSelection; + +// Examples can assume: +// late BuildContext context; +// late WidgetTester tester; + +/// Signature for the callback that reports when the user changes the selection +/// (including the cursor location). +typedef SelectionChangedCallback = void Function( + TextSelection selection, SelectionChangedCause? cause); + +/// Signature for a widget builder that builds a context menu for the given +/// [EditableTextState]. +/// +/// See also: +/// +/// * [SelectableRegionContextMenuBuilder], which performs the same role for +/// [SelectableRegion]. +typedef EditableTextContextMenuBuilder = Widget Function( + BuildContext context, EditableTextState editableTextState); + +// Signature for a function that determines the target location of the given +// [TextPosition] after applying the given [TextBoundary]. +typedef _ApplyTextBoundary = TextPosition Function( + TextPosition, bool, TextBoundary); + +// The time it takes for the cursor to fade from fully opaque to fully +// transparent and vice versa. A full cursor blink, from transparent to opaque +// to transparent, is twice this duration. +const Duration _kCursorBlinkHalfPeriod = Duration(milliseconds: 500); + +// Number of cursor ticks during which the most recently entered character +// is shown in an obscured text field. +const int _kObscureShowLatestCharCursorTicks = 3; + +class _CompositionCallback extends SingleChildRenderObjectWidget { + const _CompositionCallback( + {required this.compositeCallback, required this.enabled, super.child}); + final CompositionCallback compositeCallback; + final bool enabled; + + @override + RenderObject createRenderObject(BuildContext context) { + return _RenderCompositionCallback(compositeCallback, enabled); + } + + @override + void updateRenderObject( + BuildContext context, _RenderCompositionCallback renderObject) { + super.updateRenderObject(context, renderObject); + // _EditableTextState always uses the same callback. + assert(renderObject.compositeCallback == compositeCallback); + renderObject.enabled = enabled; + } +} + +class _RenderCompositionCallback extends RenderProxyBox { + _RenderCompositionCallback(this.compositeCallback, this._enabled); + + final CompositionCallback compositeCallback; + VoidCallback? _cancelCallback; + + bool get enabled => _enabled; + bool _enabled = false; + set enabled(bool newValue) { + _enabled = newValue; + if (!newValue) { + _cancelCallback?.call(); + _cancelCallback = null; + } else if (_cancelCallback == null) { + markNeedsPaint(); + } + } + + @override + void paint(PaintingContext context, ui.Offset offset) { + if (enabled) { + _cancelCallback ??= context.addCompositionCallback(compositeCallback); + } + super.paint(context, offset); + } +} + +/// A controller for an editable text field. +/// +/// Whenever the user modifies a text field with an associated +/// [TextEditingController], the text field updates [value] and the controller +/// notifies its listeners. Listeners can then read the [text] and [selection] +/// properties to learn what the user has typed or how the selection has been +/// updated. +/// +/// Similarly, if you modify the [text] or [selection] properties, the text +/// field will be notified and will update itself appropriately. +/// +/// A [TextEditingController] can also be used to provide an initial value for a +/// text field. If you build a text field with a controller that already has +/// [text], the text field will use that text as its initial value. +/// +/// The [value] (as well as [text] and [selection]) of this controller can be +/// updated from within a listener added to this controller. Be aware of +/// infinite loops since the listener will also be notified of the changes made +/// from within itself. Modifying the composing region from within a listener +/// can also have a bad interaction with some input methods. Gboard, for +/// example, will try to restore the composing region of the text if it was +/// modified programmatically, creating an infinite loop of communications +/// between the framework and the input method. Consider using +/// [TextInputFormatter]s instead for as-you-type text modification. +/// +/// If both the [text] and [selection] properties need to be changed, set the +/// controller's [value] instead. Setting [text] will clear the selection +/// and composing range. +/// +/// Remember to [dispose] of the [TextEditingController] when it is no longer +/// needed. This will ensure we discard any resources used by the object. +/// +/// {@tool dartpad} +/// This example creates a [TextField] with a [TextEditingController] whose +/// change listener forces the entered text to be lower case and keeps the +/// cursor at the end of the input. +/// +/// ** See code in examples/api/lib/widgets/editable_text/text_editing_controller.0.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [TextField], which is a Material Design text field that can be controlled +/// with a [TextEditingController]. +/// * [EditableText], which is a raw region of editable text that can be +/// controlled with a [TextEditingController]. +/// * Learn how to use a [TextEditingController] in one of our [cookbook recipes](https://docs.flutter.dev/cookbook/forms/text-field-changes#2-use-a-texteditingcontroller). + +// A time-value pair that represents a key frame in an animation. +class _KeyFrame { + const _KeyFrame(this.time, this.value); + // Values extracted from iOS 15.4 UIKit. + static const List<_KeyFrame> iOSBlinkingCaretKeyFrames = <_KeyFrame>[ + _KeyFrame(0, 1), // 0 + _KeyFrame(0.5, 1), // 1 + _KeyFrame(0.5375, 0.75), // 2 + _KeyFrame(0.575, 0.5), // 3 + _KeyFrame(0.6125, 0.25), // 4 + _KeyFrame(0.65, 0), // 5 + _KeyFrame(0.85, 0), // 6 + _KeyFrame(0.8875, 0.25), // 7 + _KeyFrame(0.925, 0.5), // 8 + _KeyFrame(0.9625, 0.75), // 9 + _KeyFrame(1, 1), // 10 + ]; + + // The timing, in seconds, of the specified animation `value`. + final double time; + final double value; +} + +class _DiscreteKeyFrameSimulation extends Simulation { + _DiscreteKeyFrameSimulation.iOSBlinkingCaret() + : this._(_KeyFrame.iOSBlinkingCaretKeyFrames, 1); + _DiscreteKeyFrameSimulation._(this._keyFrames, this.maxDuration) + : assert(_keyFrames.isNotEmpty), + assert(_keyFrames.last.time <= maxDuration), + assert(() { + for (int i = 0; i < _keyFrames.length - 1; i += 1) { + if (_keyFrames[i].time > _keyFrames[i + 1].time) { + return false; + } + } + return true; + }(), 'The key frame sequence must be sorted by time.'); + + final double maxDuration; + + final List<_KeyFrame> _keyFrames; + + @override + double dx(double time) => 0; + + @override + bool isDone(double time) => time >= maxDuration; + + // The index of the KeyFrame corresponds to the most recent input `time`. + int _lastKeyFrameIndex = 0; + + @override + double x(double time) { + final int length = _keyFrames.length; + + // Perform a linear search in the sorted key frame list, starting from the + // last key frame found, since the input `time` usually monotonically + // increases by a small amount. + int searchIndex; + final int endIndex; + if (_keyFrames[_lastKeyFrameIndex].time > time) { + // The simulation may have restarted. Search within the index range + // [0, _lastKeyFrameIndex). + searchIndex = 0; + endIndex = _lastKeyFrameIndex; + } else { + searchIndex = _lastKeyFrameIndex; + endIndex = length; + } + + // Find the target key frame. Don't have to check (endIndex - 1): if + // (endIndex - 2) doesn't work we'll have to pick (endIndex - 1) anyways. + while (searchIndex < endIndex - 1) { + assert(_keyFrames[searchIndex].time <= time); + final _KeyFrame next = _keyFrames[searchIndex + 1]; + if (time < next.time) { + break; + } + searchIndex += 1; + } + + _lastKeyFrameIndex = searchIndex; + return _keyFrames[_lastKeyFrameIndex].value; + } +} + +/// A basic text input field. +/// +/// This widget interacts with the [TextInput] service to let the user edit the +/// text it contains. It also provides scrolling, selection, and cursor +/// movement. +/// +/// The [EditableText] widget is a low-level widget that is intended as a +/// building block for custom widget sets. For a complete user experience, +/// consider using a [TextField] or [CupertinoTextField]. +/// +/// ## Handling User Input +/// +/// Currently the user may change the text this widget contains via keyboard or +/// the text selection menu. When the user inserted or deleted text, you will be +/// notified of the change and get a chance to modify the new text value: +/// +/// * The [inputFormatters] will be first applied to the user input. +/// +/// * The [controller]'s [TextEditingController.value] will be updated with the +/// formatted result, and the [controller]'s listeners will be notified. +/// +/// * The [onChanged] callback, if specified, will be called last. +/// +/// ## Input Actions +/// +/// A [TextInputAction] can be provided to customize the appearance of the +/// action button on the soft keyboard for Android and iOS. The default action +/// is [TextInputAction.done]. +/// +/// Many [TextInputAction]s are common between Android and iOS. However, if a +/// [textInputAction] is provided that is not supported by the current +/// platform in debug mode, an error will be thrown when the corresponding +/// EditableText receives focus. For example, providing iOS's "emergencyCall" +/// action when running on an Android device will result in an error when in +/// debug mode. In release mode, incompatible [TextInputAction]s are replaced +/// either with "unspecified" on Android, or "default" on iOS. Appropriate +/// [textInputAction]s can be chosen by checking the current platform and then +/// selecting the appropriate action. +/// +/// {@template flutter.widgets.EditableText.lifeCycle} +/// ## Lifecycle +/// +/// Upon completion of editing, like pressing the "done" button on the keyboard, +/// two actions take place: +/// +/// 1st: Editing is finalized. The default behavior of this step includes +/// an invocation of [onChanged]. That default behavior can be overridden. +/// See [onEditingComplete] for details. +/// +/// 2nd: [onSubmitted] is invoked with the user's input value. +/// +/// [onSubmitted] can be used to manually move focus to another input widget +/// when a user finishes with the currently focused input widget. +/// +/// When the widget has focus, it will prevent itself from disposing via +/// [AutomaticKeepAliveClientMixin.wantKeepAlive] in order to avoid losing the +/// selection. Removing the focus will allow it to be disposed. +/// {@endtemplate} +/// +/// Rather than using this widget directly, consider using [TextField], which +/// is a full-featured, material-design text input field with placeholder text, +/// labels, and [Form] integration. +/// +/// ## Text Editing [Intent]s and Their Default [Action]s +/// +/// This widget provides default [Action]s for handling common text editing +/// [Intent]s such as deleting, copying and pasting in the text field. These +/// [Action]s can be directly invoked using [Actions.invoke] or the +/// [Actions.maybeInvoke] method. The default text editing keyboard [Shortcuts], +/// typically declared in [DefaultTextEditingShortcuts], also use these +/// [Intent]s and [Action]s to perform the text editing operations they are +/// bound to. +/// +/// The default handling of a specific [Intent] can be overridden by placing an +/// [Actions] widget above this widget. See the [Action] class and the +/// [Action.overridable] constructor for more information on how a pre-defined +/// overridable [Action] can be overridden. +/// +/// ### Intents for Deleting Text and Their Default Behavior +/// +/// | **Intent Class** | **Default Behavior when there's selected text** | **Default Behavior when there is a [caret](https://en.wikipedia.org/wiki/Caret_navigation) (The selection is [TextSelection.collapsed])** | +/// | :------------------------------- | :--------------------------------------------------- | :----------------------------------------------------------------------- | +/// | [DeleteCharacterIntent] | Deletes the selected text | Deletes the user-perceived character before or after the caret location. | +/// | [DeleteToNextWordBoundaryIntent] | Deletes the selected text and the word before/after the selection's [TextSelection.extent] position | Deletes from the caret location to the previous or the next word boundary | +/// | [DeleteToLineBreakIntent] | Deletes the selected text, and deletes to the start/end of the line from the selection's [TextSelection.extent] position | Deletes from the caret location to the logical start or end of the current line | +/// +/// ### Intents for Moving the [Caret](https://en.wikipedia.org/wiki/Caret_navigation) +/// +/// | **Intent Class** | **Default Behavior when there's selected text** | **Default Behavior when there is a caret ([TextSelection.collapsed])** | +/// | :----------------------------------------------------------------------------------- | :--------------------------------------------------------------- | :---------------------------------------------------------------------- | +/// | [ExtendSelectionByCharacterIntent](`collapseSelection: true`) | Collapses the selection to the logical start/end of the selection | Moves the caret past the user-perceived character before or after the current caret location. | +/// | [ExtendSelectionToNextWordBoundaryIntent](`collapseSelection: true`) | Collapses the selection to the word boundary before/after the selection's [TextSelection.extent] position | Moves the caret to the previous/next word boundary. | +/// | [ExtendSelectionToNextWordBoundaryOrCaretLocationIntent](`collapseSelection: true`) | Collapses the selection to the word boundary before/after the selection's [TextSelection.extent] position, or [TextSelection.base], whichever is closest in the given direction | Moves the caret to the previous/next word boundary. | +/// | [ExtendSelectionToLineBreakIntent](`collapseSelection: true`) | Collapses the selection to the start/end of the line at the selection's [TextSelection.extent] position | Moves the caret to the start/end of the current line .| +/// | [ExtendSelectionVerticallyToAdjacentLineIntent](`collapseSelection: true`) | Collapses the selection to the position closest to the selection's [TextSelection.extent], on the previous/next adjacent line | Moves the caret to the closest position on the previous/next adjacent line. | +/// | [ExtendSelectionVerticallyToAdjacentPageIntent](`collapseSelection: true`) | Collapses the selection to the position closest to the selection's [TextSelection.extent], on the previous/next adjacent page | Moves the caret to the closest position on the previous/next adjacent page. | +/// | [ExtendSelectionToDocumentBoundaryIntent](`collapseSelection: true`) | Collapses the selection to the start/end of the document | Moves the caret to the start/end of the document. | +/// +/// #### Intents for Extending the Selection +/// +/// | **Intent Class** | **Default Behavior when there's selected text** | **Default Behavior when there is a caret ([TextSelection.collapsed])** | +/// | :----------------------------------------------------------------------------------- | :--------------------------------------------------------------- | :---------------------------------------------------------------------- | +/// | [ExtendSelectionByCharacterIntent](`collapseSelection: false`) | Moves the selection's [TextSelection.extent] past the user-perceived character before/after it | +/// | [ExtendSelectionToNextWordBoundaryIntent](`collapseSelection: false`) | Moves the selection's [TextSelection.extent] to the previous/next word boundary | +/// | [ExtendSelectionToNextWordBoundaryOrCaretLocationIntent](`collapseSelection: false`) | Moves the selection's [TextSelection.extent] to the previous/next word boundary, or [TextSelection.base] whichever is closest in the given direction | Moves the selection's [TextSelection.extent] to the previous/next word boundary. | +/// | [ExtendSelectionToLineBreakIntent](`collapseSelection: false`) | Moves the selection's [TextSelection.extent] to the start/end of the line | +/// | [ExtendSelectionVerticallyToAdjacentLineIntent](`collapseSelection: false`) | Moves the selection's [TextSelection.extent] to the closest position on the previous/next adjacent line | +/// | [ExtendSelectionVerticallyToAdjacentPageIntent](`collapseSelection: false`) | Moves the selection's [TextSelection.extent] to the closest position on the previous/next adjacent page | +/// | [ExtendSelectionToDocumentBoundaryIntent](`collapseSelection: false`) | Moves the selection's [TextSelection.extent] to the start/end of the document | +/// | [SelectAllTextIntent] | Selects the entire document | +/// +/// ### Other Intents +/// +/// | **Intent Class** | **Default Behavior** | +/// | :-------------------------------------- | :--------------------------------------------------- | +/// | [DoNothingAndStopPropagationTextIntent] | Does nothing in the input field, and prevents the key event from further propagating in the widget tree. | +/// | [ReplaceTextIntent] | Replaces the current [TextEditingValue] in the input field's [TextEditingController], and triggers all related user callbacks and [TextInputFormatter]s. | +/// | [UpdateSelectionIntent] | Updates the current selection in the input field's [TextEditingController], and triggers the [onSelectionChanged] callback. | +/// | [CopySelectionTextIntent] | Copies or cuts the selected text into the clipboard | +/// | [PasteTextIntent] | Inserts the current text in the clipboard after the caret location, or replaces the selected text if the selection is not collapsed. | +/// +/// ## Text Editing [Shortcuts] +/// +/// It's also possible to directly remap keyboard shortcuts to new [Intent]s by +/// inserting a [Shortcuts] widget above this in the widget tree. When using +/// [WidgetsApp], the large set of default text editing keyboard shortcuts are +/// declared near the top of the widget tree in [DefaultTextEditingShortcuts], +/// and any [Shortcuts] widget between it and this [EditableText] will override +/// those defaults. +/// +/// {@template flutter.widgets.editableText.shortcutsAndTextInput} +/// ### Interactions Between [Shortcuts] and Text Input +/// +/// Shortcuts prevent text input fields from receiving their keystrokes as text +/// input. For example, placing a [Shortcuts] widget in the widget tree above +/// a text input field and creating a shortcut for [LogicalKeyboardKey.keyA] +/// will prevent the field from receiving that key as text input. In other +/// words, typing key "A" into the field will trigger the shortcut and will not +/// insert a letter "a" into the field. +/// +/// This happens because of the way that key strokes are handled in Flutter. +/// When a keystroke is received in Flutter's engine, it first gives the +/// framework the opportunity to handle it as a raw key event through +/// [SystemChannels.keyEvent]. This is what [Shortcuts] listens to indirectly +/// through its [FocusNode]. If it is not handled, then it will proceed to try +/// handling it as text input through [SystemChannels.textInput], which is what +/// [EditableTextState] listens to through [TextInputClient]. +/// +/// This behavior, where a shortcut prevents text input into some field, can be +/// overridden by using another [Shortcuts] widget lower in the widget tree and +/// mapping the desired key stroke(s) to [DoNothingAndStopPropagationIntent]. +/// The key event will be reported as unhandled by the framework and will then +/// be sent as text input as usual. +/// {@endtemplate} +/// +/// ## Gesture Events Handling +/// +/// When [rendererIgnoresPointer] is false (the default), this widget provides +/// rudimentary, platform-agnostic gesture handling for user actions such as +/// tapping, long-pressing, and scrolling. +/// +/// To provide more complete gesture handling, including double-click to select +/// a word, drag selection, and platform-specific handling of gestures such as +/// long presses, consider setting [rendererIgnoresPointer] to true and using +/// [TextSelectionGestureDetectorBuilder]. +/// +/// {@template flutter.widgets.editableText.showCaretOnScreen} +/// ## Keep the caret visible when focused +/// +/// When focused, this widget will make attempts to keep the text area and its +/// caret (even when [showCursor] is `false`) visible, on these occasions: +/// +/// * When the user focuses this text field and it is not [readOnly]. +/// * When the user changes the selection of the text field, or changes the +/// text when the text field is not [readOnly]. +/// * When the virtual keyboard pops up. +/// {@endtemplate} +/// +/// ## Scrolling Considerations +/// +/// If this [EditableText] is not a descendant of [Scaffold] and is being used +/// within a [Scrollable] or nested [Scrollable]s, consider placing a +/// [ScrollNotificationObserver] above the root [Scrollable] that contains this +/// [EditableText] to ensure proper scroll coordination for [EditableText] and +/// its components like [TextSelectionOverlay]. +/// +/// {@template flutter.widgets.editableText.accessibility} +/// ## Troubleshooting Common Accessibility Issues +/// +/// ### Customizing User Input Accessibility Announcements +/// +/// To customize user input accessibility announcements triggered by text +/// changes, use [SemanticsService.announce] to make the desired +/// accessibility announcement. +/// +/// On iOS, the on-screen keyboard may announce the most recent input +/// incorrectly when a [TextInputFormatter] inserts a thousands separator to +/// a currency value text field. The following example demonstrates how to +/// suppress the default accessibility announcements by always announcing +/// the content of the text field as a US currency value (the `\$` inserts +/// a dollar sign, the `$newText` interpolates the `newText` variable): +/// +/// ```dart +/// onChanged: (String newText) { +/// if (newText.isNotEmpty) { +/// SemanticsService.announce('\$$newText', Directionality.of(context)); +/// } +/// } +/// ``` +/// +/// {@endtemplate} +/// +/// See also: +/// +/// * [TextField], which is a full-featured, material-design text input field +/// with placeholder text, labels, and [Form] integration. +class EditableText extends StatefulWidget { + /// Creates a basic text input control. + /// + /// The [maxLines] property can be set to null to remove the restriction on + /// the number of lines. By default, it is one, meaning this is a single-line + /// text field. [maxLines] must be null or greater than zero. + /// + /// If [keyboardType] is not set or is null, its value will be inferred from + /// [autofillHints], if [autofillHints] is not empty. Otherwise it defaults to + /// [TextInputType.text] if [maxLines] is exactly one, and + /// [TextInputType.multiline] if [maxLines] is null or greater than one. + /// + /// The text cursor is not shown if [showCursor] is false or if [showCursor] + /// is null (the default) and [readOnly] is true. + EditableText({ + super.key, + required this.controller, + required this.focusNode, + this.readOnly = false, + this.obscuringCharacter = '•', + this.obscureText = false, + this.autocorrect = true, + SmartDashesType? smartDashesType, + SmartQuotesType? smartQuotesType, + this.enableSuggestions = true, + required this.style, + StrutStyle? strutStyle, + required this.cursorColor, + required this.backgroundCursorColor, + this.textAlign = TextAlign.start, + this.textDirection, + this.locale, + @Deprecated( + 'Use textScaler instead. ' + 'Use of textScaleFactor was deprecated in preparation for the upcoming nonlinear text scaling support. ' + 'This feature was deprecated after v3.12.0-2.0.pre.', + ) + this.textScaleFactor, + this.textScaler, + this.maxLines = 1, + this.minLines, + this.expands = false, + this.forceLine = true, + this.textHeightBehavior, + this.textWidthBasis = TextWidthBasis.parent, + this.autofocus = false, + bool? showCursor, + this.showSelectionHandles = false, + this.selectionColor, + this.selectionControls, + TextInputType? keyboardType, + this.textInputAction, + this.textCapitalization = TextCapitalization.none, + this.onChanged, + this.onEditingComplete, + this.onSubmitted, + this.onAppPrivateCommand, + this.onSelectionChanged, + this.onSelectionHandleTapped, + this.groupId = EditableText, + this.onTapOutside, + this.onTapUpOutside, + List? inputFormatters, + this.mouseCursor, + this.rendererIgnoresPointer = false, + this.cursorWidth = 2.0, + this.cursorHeight, + this.cursorRadius, + this.cursorOpacityAnimates = false, + this.cursorOffset, + this.paintCursorAboveText = false, + this.selectionHeightStyle = ui.BoxHeightStyle.tight, + this.selectionWidthStyle = ui.BoxWidthStyle.tight, + this.scrollPadding = const EdgeInsets.all(20.0), + this.keyboardAppearance = Brightness.light, + this.dragStartBehavior = DragStartBehavior.start, + bool? enableInteractiveSelection, + this.scrollController, + this.scrollPhysics, + this.autocorrectionTextRectColor, + @Deprecated( + 'Use `contextMenuBuilder` instead. ' + 'This feature was deprecated after v3.3.0-0.5.pre.', + ) + ToolbarOptions? toolbarOptions, + this.autofillHints = const [], + this.autofillClient, + this.clipBehavior = Clip.hardEdge, + this.restorationId, + this.scrollBehavior, + @Deprecated( + 'Use `stylusHandwritingEnabled` instead. ' + 'This feature was deprecated after v3.27.0-0.2.pre.', + ) + this.scribbleEnabled = true, + this.stylusHandwritingEnabled = defaultStylusHandwritingEnabled, + this.enableIMEPersonalizedLearning = true, + this.contentInsertionConfiguration, + this.contextMenuBuilder, + this.spellCheckConfiguration, + this.magnifierConfiguration = TextMagnifierConfiguration.disabled, + this.undoController, + this.onDelAtUser, + this.onMention, + }) : assert(obscuringCharacter.length == 1), + smartDashesType = smartDashesType ?? + (obscureText ? SmartDashesType.disabled : SmartDashesType.enabled), + smartQuotesType = smartQuotesType ?? + (obscureText ? SmartQuotesType.disabled : SmartQuotesType.enabled), + assert(minLines == null || minLines > 0), + assert( + (maxLines == null) || (minLines == null) || (maxLines >= minLines), + "minLines can't be greater than maxLines", + ), + assert( + !expands || (maxLines == null && minLines == null), + 'minLines and maxLines must be null when expands is true.', + ), + assert(!obscureText || maxLines == 1, + 'Obscured fields cannot be multiline.'), + enableInteractiveSelection = + enableInteractiveSelection ?? (!readOnly || !obscureText), + toolbarOptions = selectionControls is TextSelectionHandleControls && + toolbarOptions == null + ? ToolbarOptions.empty + : toolbarOptions ?? + (obscureText + ? (readOnly + // No point in even offering "Select All" in a read-only obscured + // field. + ? ToolbarOptions.empty + // Writable, but obscured. + : const ToolbarOptions(selectAll: true, paste: true)) + : (readOnly + // Read-only, not obscured. + ? const ToolbarOptions(selectAll: true, copy: true) + // Writable, not obscured. + : const ToolbarOptions( + copy: true, + cut: true, + selectAll: true, + paste: true, + ))), + assert( + spellCheckConfiguration == null || + spellCheckConfiguration == + const SpellCheckConfiguration.disabled() || + spellCheckConfiguration.misspelledTextStyle != null, + 'spellCheckConfiguration must specify a misspelledTextStyle if spell check behavior is desired', + ), + _strutStyle = strutStyle, + keyboardType = keyboardType ?? + _inferKeyboardType( + autofillHints: autofillHints, maxLines: maxLines), + inputFormatters = maxLines == 1 + ? [ + FilteringTextInputFormatter.singleLineFormatter, + ...inputFormatters ?? + const Iterable.empty(), + ] + : inputFormatters, + showCursor = showCursor ?? !readOnly; + + final VoidCallback? onMention; + + final ValueChanged? onDelAtUser; + + /// Controls the text being edited. + final TextEditingController controller; + + /// Controls whether this widget has keyboard focus. + final FocusNode focusNode; + + /// {@template flutter.widgets.editableText.obscuringCharacter} + /// Character used for obscuring text if [obscureText] is true. + /// + /// Must be only a single character. + /// + /// Defaults to the character U+2022 BULLET (•). + /// {@endtemplate} + final String obscuringCharacter; + + /// {@template flutter.widgets.editableText.obscureText} + /// Whether to hide the text being edited (e.g., for passwords). + /// + /// When this is set to true, all the characters in the text field are + /// replaced by [obscuringCharacter], and the text in the field cannot be + /// copied with copy or cut. If [readOnly] is also true, then the text cannot + /// be selected. + /// + /// Defaults to false. + /// {@endtemplate} + final bool obscureText; + + /// {@macro dart.ui.textHeightBehavior} + final TextHeightBehavior? textHeightBehavior; + + /// {@macro flutter.painting.textPainter.textWidthBasis} + final TextWidthBasis textWidthBasis; + + /// {@template flutter.widgets.editableText.readOnly} + /// Whether the text can be changed. + /// + /// When this is set to true, the text cannot be modified + /// by any shortcut or keyboard operation. The text is still selectable. + /// + /// Defaults to false. + /// {@endtemplate} + final bool readOnly; + + /// Whether the text will take the full width regardless of the text width. + /// + /// When this is set to false, the width will be based on text width, which + /// will also be affected by [textWidthBasis]. + /// + /// Defaults to true. + /// + /// See also: + /// + /// * [textWidthBasis], which controls the calculation of text width. + final bool forceLine; + + /// Configuration of toolbar options. + /// + /// By default, all options are enabled. If [readOnly] is true, paste and cut + /// will be disabled regardless. If [obscureText] is true, cut and copy will + /// be disabled regardless. If [readOnly] and [obscureText] are both true, + /// select all will also be disabled. + final ToolbarOptions toolbarOptions; + + /// Whether to show selection handles. + /// + /// When a selection is active, there will be two handles at each side of + /// boundary, or one handle if the selection is collapsed. The handles can be + /// dragged to adjust the selection. + /// + /// See also: + /// + /// * [showCursor], which controls the visibility of the cursor. + final bool showSelectionHandles; + + /// {@template flutter.widgets.editableText.showCursor} + /// Whether to show cursor. + /// + /// The cursor refers to the blinking caret when the [EditableText] is focused. + /// {@endtemplate} + /// + /// See also: + /// + /// * [showSelectionHandles], which controls the visibility of the selection handles. + final bool showCursor; + + /// {@template flutter.widgets.editableText.autocorrect} + /// Whether to enable autocorrection. + /// + /// Defaults to true. + /// {@endtemplate} + final bool autocorrect; + + /// {@macro flutter.services.TextInputConfiguration.smartDashesType} + final SmartDashesType smartDashesType; + + /// {@macro flutter.services.TextInputConfiguration.smartQuotesType} + final SmartQuotesType smartQuotesType; + + /// {@macro flutter.services.TextInputConfiguration.enableSuggestions} + final bool enableSuggestions; + + /// The text style to use for the editable text. + final TextStyle style; + + /// Controls the undo state of the current editable text. + /// + /// If null, this widget will create its own [UndoHistoryController]. + final UndoHistoryController? undoController; + + /// {@template flutter.widgets.editableText.strutStyle} + /// The strut style used for the vertical layout. + /// + /// [StrutStyle] is used to establish a predictable vertical layout. + /// Since fonts may vary depending on user input and due to font + /// fallback, [StrutStyle.forceStrutHeight] is enabled by default + /// to lock all lines to the height of the base [TextStyle], provided by + /// [style]. This ensures the typed text fits within the allotted space. + /// + /// If null, the strut used will inherit values from the [style] and will + /// have [StrutStyle.forceStrutHeight] set to true. When no [style] is + /// passed, the theme's [TextStyle] will be used to generate [strutStyle] + /// instead. + /// + /// To disable strut-based vertical alignment and allow dynamic vertical + /// layout based on the glyphs typed, use [StrutStyle.disabled]. + /// + /// Flutter's strut is based on [typesetting strut](https://en.wikipedia.org/wiki/Strut_(typesetting)) + /// and CSS's [line-height](https://www.w3.org/TR/CSS2/visudet.html#line-height). + /// {@endtemplate} + /// + /// Within editable text and text fields, [StrutStyle] will not use its standalone + /// default values, and will instead inherit omitted/null properties from the + /// [TextStyle] instead. See [StrutStyle.inheritFromTextStyle]. + StrutStyle get strutStyle { + if (_strutStyle == null) { + return StrutStyle.fromTextStyle(style, forceStrutHeight: true); + } + return _strutStyle!.inheritFromTextStyle(style); + } + + final StrutStyle? _strutStyle; + + /// {@template flutter.widgets.editableText.textAlign} + /// How the text should be aligned horizontally. + /// + /// Defaults to [TextAlign.start]. + /// {@endtemplate} + final TextAlign textAlign; + + /// {@template flutter.widgets.editableText.textDirection} + /// The directionality of the text. + /// + /// This decides how [textAlign] values like [TextAlign.start] and + /// [TextAlign.end] are interpreted. + /// + /// This is also used to disambiguate how to render bidirectional text. For + /// example, if the text is an English phrase followed by a Hebrew phrase, + /// in a [TextDirection.ltr] context the English phrase will be on the left + /// and the Hebrew phrase to its right, while in a [TextDirection.rtl] + /// context, the English phrase will be on the right and the Hebrew phrase on + /// its left. + /// + /// Defaults to the ambient [Directionality], if any. + /// {@endtemplate} + final TextDirection? textDirection; + + /// {@template flutter.widgets.editableText.textCapitalization} + /// Configures how the platform keyboard will select an uppercase or + /// lowercase keyboard. + /// + /// Only supports text keyboards, other keyboard types will ignore this + /// configuration. Capitalization is locale-aware. + /// + /// Defaults to [TextCapitalization.none]. + /// + /// See also: + /// + /// * [TextCapitalization], for a description of each capitalization behavior. + /// + /// {@endtemplate} + final TextCapitalization textCapitalization; + + /// Used to select a font when the same Unicode character can + /// be rendered differently, depending on the locale. + /// + /// It's rarely necessary to set this property. By default its value + /// is inherited from the enclosing app with `Localizations.localeOf(context)`. + /// + /// See [RenderEditable.locale] for more information. + final Locale? locale; + + /// {@template flutter.widgets.editableText.textScaleFactor} + /// Deprecated. Will be removed in a future version of Flutter. Use + /// [textScaler] instead. + /// + /// The number of font pixels for each logical pixel. + /// + /// For example, if the text scale factor is 1.5, text will be 50% larger than + /// the specified font size. + /// + /// Defaults to the [MediaQueryData.textScaleFactor] obtained from the ambient + /// [MediaQuery], or 1.0 if there is no [MediaQuery] in scope. + /// {@endtemplate} + @Deprecated( + 'Use textScaler instead. ' + 'Use of textScaleFactor was deprecated in preparation for the upcoming nonlinear text scaling support. ' + 'This feature was deprecated after v3.12.0-2.0.pre.', + ) + final double? textScaleFactor; + + /// {@macro flutter.painting.textPainter.textScaler} + final TextScaler? textScaler; + + /// The color to use when painting the cursor. + final Color cursorColor; + + /// The color to use when painting the autocorrection Rect. + /// + /// For [CupertinoTextField]s, the value is set to the ambient + /// [CupertinoThemeData.primaryColor] with 20% opacity. For [TextField]s, the + /// value is null on non-iOS platforms and the same color used in [CupertinoTextField] + /// on iOS. + /// + /// Currently the autocorrection Rect only appears on iOS. + /// + /// Defaults to null, which disables autocorrection Rect painting. + final Color? autocorrectionTextRectColor; + + /// The color to use when painting the background cursor aligned with the text + /// while rendering the floating cursor. + /// + /// Typically this would be set to [CupertinoColors.inactiveGray]. + /// + /// See also: + /// + /// * [FloatingCursorDragState], which explains the floating cursor feature + /// in detail. + final Color backgroundCursorColor; + + /// {@template flutter.widgets.editableText.maxLines} + /// The maximum number of lines to show at one time, wrapping if necessary. + /// + /// This affects the height of the field itself and does not limit the number + /// of lines that can be entered into the field. + /// + /// If this is 1 (the default), the text will not wrap, but will scroll + /// horizontally instead. + /// + /// If this is null, there is no limit to the number of lines, and the text + /// container will start with enough vertical space for one line and + /// automatically grow to accommodate additional lines as they are entered, up + /// to the height of its constraints. + /// + /// If this is not null, the value must be greater than zero, and it will lock + /// the input to the given number of lines and take up enough horizontal space + /// to accommodate that number of lines. Setting [minLines] as well allows the + /// input to grow and shrink between the indicated range. + /// + /// The full set of behaviors possible with [minLines] and [maxLines] are as + /// follows. These examples apply equally to [TextField], [TextFormField], + /// [CupertinoTextField], and [EditableText]. + /// + /// Input that occupies a single line and scrolls horizontally as needed. + /// ```dart + /// const TextField() + /// ``` + /// + /// Input whose height grows from one line up to as many lines as needed for + /// the text that was entered. If a height limit is imposed by its parent, it + /// will scroll vertically when its height reaches that limit. + /// ```dart + /// const TextField(maxLines: null) + /// ``` + /// + /// The input's height is large enough for the given number of lines. If + /// additional lines are entered the input scrolls vertically. + /// ```dart + /// const TextField(maxLines: 2) + /// ``` + /// + /// Input whose height grows with content between a min and max. An infinite + /// max is possible with `maxLines: null`. + /// ```dart + /// const TextField(minLines: 2, maxLines: 4) + /// ``` + /// + /// See also: + /// + /// * [minLines], which sets the minimum number of lines visible. + /// {@endtemplate} + /// * [expands], which determines whether the field should fill the height of + /// its parent. + final int? maxLines; + + /// {@template flutter.widgets.editableText.minLines} + /// The minimum number of lines to occupy when the content spans fewer lines. + /// + /// This affects the height of the field itself and does not limit the number + /// of lines that can be entered into the field. + /// + /// If this is null (default), text container starts with enough vertical space + /// for one line and grows to accommodate additional lines as they are entered. + /// + /// This can be used in combination with [maxLines] for a varying set of behaviors. + /// + /// If the value is set, it must be greater than zero. If the value is greater + /// than 1, [maxLines] should also be set to either null or greater than + /// this value. + /// + /// When [maxLines] is set as well, the height will grow between the indicated + /// range of lines. When [maxLines] is null, it will grow as high as needed, + /// starting from [minLines]. + /// + /// A few examples of behaviors possible with [minLines] and [maxLines] are as follows. + /// These apply equally to [TextField], [TextFormField], [CupertinoTextField], + /// and [EditableText]. + /// + /// Input that always occupies at least 2 lines and has an infinite max. + /// Expands vertically as needed. + /// ```dart + /// TextField(minLines: 2) + /// ``` + /// + /// Input whose height starts from 2 lines and grows up to 4 lines at which + /// point the height limit is reached. If additional lines are entered it will + /// scroll vertically. + /// ```dart + /// const TextField(minLines:2, maxLines: 4) + /// ``` + /// + /// Defaults to null. + /// + /// See also: + /// + /// * [maxLines], which sets the maximum number of lines visible, and has + /// several examples of how minLines and maxLines interact to produce + /// various behaviors. + /// {@endtemplate} + /// * [expands], which determines whether the field should fill the height of + /// its parent. + final int? minLines; + + /// {@template flutter.widgets.editableText.expands} + /// Whether this widget's height will be sized to fill its parent. + /// + /// If set to true and wrapped in a parent widget like [Expanded] or + /// [SizedBox], the input will expand to fill the parent. + /// + /// [maxLines] and [minLines] must both be null when this is set to true, + /// otherwise an error is thrown. + /// + /// Defaults to false. + /// + /// See the examples in [maxLines] for the complete picture of how [maxLines], + /// [minLines], and [expands] interact to produce various behaviors. + /// + /// Input that matches the height of its parent: + /// ```dart + /// const Expanded( + /// child: TextField(maxLines: null, expands: true), + /// ) + /// ``` + /// {@endtemplate} + final bool expands; + + /// {@template flutter.widgets.editableText.autofocus} + /// Whether this text field should focus itself if nothing else is already + /// focused. + /// + /// If true, the keyboard will open as soon as this text field obtains focus. + /// Otherwise, the keyboard is only shown after the user taps the text field. + /// + /// Defaults to false. + /// {@endtemplate} + // See https://github.com/flutter/flutter/issues/7035 for the rationale for this + // keyboard behavior. + final bool autofocus; + + /// The color to use when painting the selection. + /// + /// If this property is null, this widget gets the selection color from the + /// [DefaultSelectionStyle]. + /// + /// For [CupertinoTextField]s, the value is set to the ambient + /// [CupertinoThemeData.primaryColor] with 20% opacity. For [TextField]s, the + /// value is set to the ambient [TextSelectionThemeData.selectionColor]. + final Color? selectionColor; + + /// {@template flutter.widgets.editableText.selectionControls} + /// Optional delegate for building the text selection handles. + /// + /// Historically, this field also controlled the toolbar. This is now handled + /// by [contextMenuBuilder] instead. However, for backwards compatibility, when + /// [selectionControls] is set to an object that does not mix in + /// [TextSelectionHandleControls], [contextMenuBuilder] is ignored and the + /// [TextSelectionControls.buildToolbar] method is used instead. + /// {@endtemplate} + /// + /// See also: + /// + /// * [CupertinoTextField], which wraps an [EditableText] and which shows the + /// selection toolbar upon user events that are appropriate on the iOS + /// platform. + /// * [TextField], a Material Design themed wrapper of [EditableText], which + /// shows the selection toolbar upon appropriate user events based on the + /// user's platform set in [ThemeData.platform]. + final TextSelectionControls? selectionControls; + + /// {@template flutter.widgets.editableText.keyboardType} + /// The type of keyboard to use for editing the text. + /// + /// Defaults to [TextInputType.text] if [maxLines] is one and + /// [TextInputType.multiline] otherwise. + /// {@endtemplate} + final TextInputType keyboardType; + + /// The type of action button to use with the soft keyboard. + final TextInputAction? textInputAction; + + /// {@template flutter.widgets.editableText.onChanged} + /// Called when the user initiates a change to the TextField's + /// value: when they have inserted or deleted text. + /// + /// This callback doesn't run when the TextField's text is changed + /// programmatically, via the TextField's [controller]. Typically it + /// isn't necessary to be notified of such changes, since they're + /// initiated by the app itself. + /// + /// To be notified of all changes to the TextField's text, cursor, + /// and selection, one can add a listener to its [controller] with + /// [TextEditingController.addListener]. + /// + /// [onChanged] is called before [onSubmitted] when user indicates completion + /// of editing, such as when pressing the "done" button on the keyboard. That + /// default behavior can be overridden. See [onEditingComplete] for details. + /// + /// {@tool dartpad} + /// This example shows how onChanged could be used to check the TextField's + /// current value each time the user inserts or deletes a character. + /// + /// ** See code in examples/api/lib/widgets/editable_text/editable_text.on_changed.0.dart ** + /// {@end-tool} + /// {@endtemplate} + /// + /// ## Handling emojis and other complex characters + /// {@template flutter.widgets.EditableText.onChanged} + /// It's important to always use + /// [characters](https://pub.dev/packages/characters) when dealing with user + /// input text that may contain complex characters. This will ensure that + /// extended grapheme clusters and surrogate pairs are treated as single + /// characters, as they appear to the user. + /// + /// For example, when finding the length of some user input, use + /// `string.characters.length`. Do NOT use `string.length` or even + /// `string.runes.length`. For the complex character "👨‍👩‍👦", this + /// appears to the user as a single character, and `string.characters.length` + /// intuitively returns 1. On the other hand, `string.length` returns 8, and + /// `string.runes.length` returns 5! + /// {@endtemplate} + /// + /// See also: + /// + /// * [inputFormatters], which are called before [onChanged] + /// runs and can validate and change ("format") the input value. + /// * [onEditingComplete], [onSubmitted], [onSelectionChanged]: + /// which are more specialized input change notifications. + /// * [TextEditingController], which implements the [Listenable] interface + /// and notifies its listeners on [TextEditingValue] changes. + final ValueChanged? onChanged; + + /// {@template flutter.widgets.editableText.onEditingComplete} + /// Called when the user submits editable content (e.g., user presses the "done" + /// button on the keyboard). + /// + /// The default implementation of [onEditingComplete] executes 2 different + /// behaviors based on the situation: + /// + /// - When a completion action is pressed, such as "done", "go", "send", or + /// "search", the user's content is submitted to the [controller] and then + /// focus is given up. + /// + /// - When a non-completion action is pressed, such as "next" or "previous", + /// the user's content is submitted to the [controller], but focus is not + /// given up because developers may want to immediately move focus to + /// another input widget within [onSubmitted]. + /// + /// Providing [onEditingComplete] prevents the aforementioned default behavior. + /// {@endtemplate} + final VoidCallback? onEditingComplete; + + /// {@template flutter.widgets.editableText.onSubmitted} + /// Called when the user indicates that they are done editing the text in the + /// field. + /// + /// By default, [onSubmitted] is called after [onChanged] when the user + /// has finalized editing; or, if the default behavior has been overridden, + /// after [onEditingComplete]. See [onEditingComplete] for details. + /// + /// ## Testing + /// The following is the recommended way to trigger [onSubmitted] in a test: + /// + /// ```dart + /// await tester.testTextInput.receiveAction(TextInputAction.done); + /// ``` + /// + /// Sending a `LogicalKeyboardKey.enter` via `tester.sendKeyEvent` will not + /// trigger [onSubmitted]. This is because on a real device, the engine + /// translates the enter key to a done action, but `tester.sendKeyEvent` sends + /// the key to the framework only. + /// {@endtemplate} + final ValueChanged? onSubmitted; + + /// {@template flutter.widgets.editableText.onAppPrivateCommand} + /// This is used to receive a private command from the input method. + /// + /// Called when the result of [TextInputClient.performPrivateCommand] is + /// received. + /// + /// This can be used to provide domain-specific features that are only known + /// between certain input methods and their clients. + /// + /// See also: + /// * [performPrivateCommand](https://developer.android.com/reference/android/view/inputmethod/InputConnection#performPrivateCommand\(java.lang.String,%20android.os.Bundle\)), + /// which is the Android documentation for performPrivateCommand, used to + /// send a command from the input method. + /// * [sendAppPrivateCommand](https://developer.android.com/reference/android/view/inputmethod/InputMethodManager#sendAppPrivateCommand), + /// which is the Android documentation for sendAppPrivateCommand, used to + /// send a command to the input method. + /// {@endtemplate} + final AppPrivateCommandCallback? onAppPrivateCommand; + + /// {@template flutter.widgets.editableText.onSelectionChanged} + /// Called when the user changes the selection of text (including the cursor + /// location). + /// {@endtemplate} + final SelectionChangedCallback? onSelectionChanged; + + /// {@macro flutter.widgets.SelectionOverlay.onSelectionHandleTapped} + final VoidCallback? onSelectionHandleTapped; + + /// {@template flutter.widgets.editableText.groupId} + /// The group identifier for the [TextFieldTapRegion] of this text field. + /// + /// Text fields with the same group identifier share the same tap region. + /// Defaults to the type of [EditableText]. + /// + /// See also: + /// + /// * [TextFieldTapRegion], to give a [groupId] to a widget that is to be + /// included in a [EditableText]'s tap region that has [groupId] set. + /// {@endtemplate} + final Object groupId; + + /// {@template flutter.widgets.editableText.onTapOutside} + /// Called for each tap down that occurs outside of the [TextFieldTapRegion] + /// group when the text field is focused. + /// + /// If this is null, [EditableTextTapOutsideIntent] will be invoked. In the + /// default implementation, [FocusNode.unfocus] will be called on the + /// [focusNode] for this text field when a [PointerDownEvent] is received on + /// another part of the UI. However, it will not unfocus as a result of mobile + /// application touch events (which does not include mouse clicks), to conform + /// with the platform conventions. To change this behavior, a callback may be + /// set here or [EditableTextTapOutsideIntent] may be overridden. + /// + /// When adding additional controls to a text field (for example, a spinner, a + /// button that copies the selected text, or modifies formatting), it is + /// helpful if tapping on that control doesn't unfocus the text field. In + /// order for an external widget to be considered as part of the text field + /// for the purposes of tapping "outside" of the field, wrap the control in a + /// [TextFieldTapRegion]. + /// + /// The [PointerDownEvent] passed to the function is the event that caused the + /// notification. It is possible that the event may occur outside of the + /// immediate bounding box defined by the text field, although it will be + /// within the bounding box of a [TextFieldTapRegion] member. + /// {@endtemplate} + /// + /// {@tool dartpad} + /// This example shows how to use a `TextFieldTapRegion` to wrap a set of + /// "spinner" buttons that increment and decrement a value in the [TextField] + /// without causing the text field to lose keyboard focus. + /// + /// This example includes a generic `SpinnerField` class that you can copy + /// into your own project and customize. + /// + /// ** See code in examples/api/lib/widgets/tap_region/text_field_tap_region.0.dart ** + /// {@end-tool} + /// + /// See also: + /// + /// * [TapRegion] for how the region group is determined. + /// * [onTapUpOutside] which is called for each tap up. + /// * [EditableTextTapOutsideIntent] for the intent that is invoked if + /// this is null. + final TapRegionCallback? onTapOutside; + + /// {@template flutter.widgets.editableText.onTapUpOutside} + /// Called for each tap up that occurs outside of the [TextFieldTapRegion] + /// group when the text field is focused. + /// + /// If this is null, [EditableTextTapUpOutsideIntent] will be invoked. In the + /// default implementation, this is a no-op. To change this behavior, set a + /// callback here or override [EditableTextTapUpOutsideIntent]. + /// + /// The [PointerUpEvent] passed to the function is the event that caused the + /// notification. It is possible that the event may occur outside of the + /// immediate bounding box defined by the text field, although it will be + /// within the bounding box of a [TextFieldTapRegion] member. + /// {@endtemplate} + /// + /// See also: + /// + /// * [TapRegion] for how the region group is determined. + /// * [onTapOutside], which is called for each tap down. + /// * [EditableTextTapOutsideIntent], the intent that is invoked if + /// this is null. + final TapRegionUpCallback? onTapUpOutside; + + /// {@template flutter.widgets.editableText.inputFormatters} + /// Optional input validation and formatting overrides. + /// + /// Formatters are run in the provided order when the user changes the text + /// this widget contains. When this parameter changes, the new formatters will + /// not be applied until the next time the user inserts or deletes text. + /// Similar to the [onChanged] callback, formatters don't run when the text is + /// changed programmatically via [controller]. + /// + /// See also: + /// + /// * [TextEditingController], which implements the [Listenable] interface + /// and notifies its listeners on [TextEditingValue] changes. + /// {@endtemplate} + final List? inputFormatters; + + /// The cursor for a mouse pointer when it enters or is hovering over the + /// widget. + /// + /// If this property is null, [SystemMouseCursors.text] will be used. + /// + /// The [mouseCursor] is the only property of [EditableText] that controls the + /// appearance of the mouse pointer. All other properties related to "cursor" + /// stands for the text cursor, which is usually a blinking vertical line at + /// the editing position. + final MouseCursor? mouseCursor; + + /// Whether the caller will provide gesture handling (true), or if the + /// [EditableText] is expected to handle basic gestures (false). + /// + /// When this is false, the [EditableText] (or more specifically, the + /// [RenderEditable]) enables some rudimentary gestures (tap to position the + /// cursor, long-press to select all, and some scrolling behavior). + /// + /// These behaviors are sufficient for debugging purposes but are inadequate + /// for user-facing applications. To enable platform-specific behaviors, use a + /// [TextSelectionGestureDetectorBuilder] to wrap the [EditableText], and set + /// [rendererIgnoresPointer] to true. + /// + /// When [rendererIgnoresPointer] is true true, the [RenderEditable] created + /// by this widget will not handle pointer events. + /// + /// This property is false by default. + /// + /// See also: + /// + /// * [RenderEditable.ignorePointer], which implements this feature. + /// * [TextSelectionGestureDetectorBuilder], which implements platform-specific + /// gestures and behaviors. + final bool rendererIgnoresPointer; + + /// {@template flutter.widgets.editableText.cursorWidth} + /// How thick the cursor will be. + /// + /// Defaults to 2.0. + /// + /// The cursor will draw under the text. The cursor width will extend + /// to the right of the boundary between characters for left-to-right text + /// and to the left for right-to-left text. This corresponds to extending + /// downstream relative to the selected position. Negative values may be used + /// to reverse this behavior. + /// {@endtemplate} + final double cursorWidth; + + /// {@template flutter.widgets.editableText.cursorHeight} + /// How tall the cursor will be. + /// + /// If this property is null, [RenderEditable.preferredLineHeight] will be used. + /// {@endtemplate} + final double? cursorHeight; + + /// {@template flutter.widgets.editableText.cursorRadius} + /// How rounded the corners of the cursor should be. + /// + /// By default, the cursor has no radius. + /// {@endtemplate} + final Radius? cursorRadius; + + /// {@template flutter.widgets.editableText.cursorOpacityAnimates} + /// Whether the cursor will animate from fully transparent to fully opaque + /// during each cursor blink. + /// + /// By default, the cursor opacity will animate on iOS platforms and will not + /// animate on Android platforms. + /// {@endtemplate} + final bool cursorOpacityAnimates; + + /// {@macro flutter.rendering.RenderEditable.cursorOffset} + final Offset? cursorOffset; + + /// {@macro flutter.rendering.RenderEditable.paintCursorAboveText} + final bool paintCursorAboveText; + + /// Controls how tall the selection highlight boxes are computed to be. + /// + /// See [ui.BoxHeightStyle] for details on available styles. + final ui.BoxHeightStyle selectionHeightStyle; + + /// Controls how wide the selection highlight boxes are computed to be. + /// + /// See [ui.BoxWidthStyle] for details on available styles. + final ui.BoxWidthStyle selectionWidthStyle; + + /// The appearance of the keyboard. + /// + /// This setting is only honored on iOS devices. + /// + /// Defaults to [Brightness.light]. + final Brightness keyboardAppearance; + + /// {@template flutter.widgets.editableText.scrollPadding} + /// Configures the padding for the edges surrounding a [Scrollable] when the + /// text field scrolls into view. + /// + /// When this widget receives focus and is not completely visible (for example + /// scrolled partially off the screen or overlapped by the keyboard), then it + /// will attempt to make itself visible by scrolling a surrounding + /// [Scrollable], if one is present. This value controls how far from the + /// edges of a [Scrollable] the TextField will be positioned after the scroll. + /// + /// Defaults to EdgeInsets.all(20.0). + /// {@endtemplate} + final EdgeInsets scrollPadding; + + /// {@template flutter.widgets.editableText.enableInteractiveSelection} + /// Whether to enable user interface affordances for changing the + /// text selection. + /// + /// For example, setting this to true will enable features such as + /// long-pressing the TextField to select text and show the + /// cut/copy/paste menu, and tapping to move the text caret. + /// + /// When this is false, the text selection cannot be adjusted by + /// the user, text cannot be copied, and the user cannot paste into + /// the text field from the clipboard. + /// + /// Defaults to true. + /// {@endtemplate} + final bool enableInteractiveSelection; + + /// Setting this property to true makes the cursor stop blinking or fading + /// on and off once the cursor appears on focus. This property is useful for + /// testing purposes. + /// + /// It does not affect the necessity to focus the EditableText for the cursor + /// to appear in the first place. + /// + /// Defaults to false, resulting in a typical blinking cursor. + static bool debugDeterministicCursor = false; + + /// {@macro flutter.widgets.scrollable.dragStartBehavior} + final DragStartBehavior dragStartBehavior; + + /// {@template flutter.widgets.editableText.scrollController} + /// The [ScrollController] to use when vertically scrolling the input. + /// + /// If null, it will instantiate a new ScrollController. + /// + /// See [Scrollable.controller]. + /// {@endtemplate} + final ScrollController? scrollController; + + /// {@template flutter.widgets.editableText.scrollPhysics} + /// The [ScrollPhysics] to use when vertically scrolling the input. + /// + /// If not specified, it will behave according to the current platform. + /// + /// See [Scrollable.physics]. + /// {@endtemplate} + /// + /// If an explicit [ScrollBehavior] is provided to [scrollBehavior], the + /// [ScrollPhysics] provided by that behavior will take precedence after + /// [scrollPhysics]. + final ScrollPhysics? scrollPhysics; + + /// {@template flutter.widgets.editableText.scribbleEnabled} + /// Whether iOS 14 Scribble features are enabled for this widget. + /// + /// Only available on iPads. + /// + /// Defaults to true. + /// {@endtemplate} + @Deprecated( + 'Use `stylusHandwritingEnabled` instead. ' + 'This feature was deprecated after v3.27.0-0.2.pre.', + ) + final bool scribbleEnabled; + + /// {@template flutter.widgets.editableText.stylusHandwritingEnabled} + /// Whether this input supports stylus handwriting, where the user can write + /// directly on top of a field. + /// + /// Currently only the following devices are supported: + /// + /// * iPads running iOS 14 and above using an Apple Pencil. + /// * Android devices running API 34 and above and using an active stylus. + /// {@endtemplate} + /// + /// On Android, Scribe gestures are detected outside of [EditableText], + /// typically by [TextSelectionGestureDetectorBuilder]. This is handled + /// automatically in [TextField]. + /// + /// See also: + /// + /// * [ScribbleClient], which can be mixed into an arbitrary widget to + /// provide iOS Scribble functionality. + /// * [Scribe], which can be used to interact with Android Scribe directly. + final bool stylusHandwritingEnabled; + + /// {@template flutter.widgets.editableText.selectionEnabled} + /// Same as [enableInteractiveSelection]. + /// + /// This getter exists primarily for consistency with + /// [RenderEditable.selectionEnabled]. + /// {@endtemplate} + bool get selectionEnabled => enableInteractiveSelection; + + /// {@template flutter.widgets.editableText.autofillHints} + /// A list of strings that helps the autofill service identify the type of this + /// text input. + /// + /// When set to null, this text input will not send its autofill information + /// to the platform, preventing it from participating in autofills triggered + /// by a different [AutofillClient], even if they're in the same + /// [AutofillScope]. Additionally, on Android and web, setting this to null + /// will disable autofill for this text field. + /// + /// The minimum platform SDK version that supports Autofill is API level 26 + /// for Android, and iOS 10.0 for iOS. + /// + /// Defaults to an empty list. + /// + /// ### Setting up iOS autofill: + /// + /// To provide the best user experience and ensure your app fully supports + /// password autofill on iOS, follow these steps: + /// + /// * Set up your iOS app's + /// [associated domains](https://developer.apple.com/documentation/safariservices/supporting_associated_domains_in_your_app). + /// * Some autofill hints only work with specific [keyboardType]s. For example, + /// [AutofillHints.name] requires [TextInputType.name] and [AutofillHints.email] + /// works only with [TextInputType.emailAddress]. Make sure the input field has a + /// compatible [keyboardType]. Empirically, [TextInputType.name] works well + /// with many autofill hints that are predefined on iOS. + /// + /// ### Troubleshooting Autofill + /// + /// Autofill service providers rely heavily on [autofillHints]. Make sure the + /// entries in [autofillHints] are supported by the autofill service currently + /// in use (the name of the service can typically be found in your mobile + /// device's system settings). + /// + /// #### Autofill UI refuses to show up when I tap on the text field + /// + /// Check the device's system settings and make sure autofill is turned on, + /// and there are available credentials stored in the autofill service. + /// + /// * iOS password autofill: Go to Settings -> Password, turn on "Autofill + /// Passwords", and add new passwords for testing by pressing the top right + /// "+" button. Use an arbitrary "website" if you don't have associated + /// domains set up for your app. As long as there's at least one password + /// stored, you should be able to see a key-shaped icon in the quick type + /// bar on the software keyboard, when a password related field is focused. + /// + /// * iOS contact information autofill: iOS seems to pull contact info from + /// the Apple ID currently associated with the device. Go to Settings -> + /// Apple ID (usually the first entry, or "Sign in to your iPhone" if you + /// haven't set up one on the device), and fill out the relevant fields. If + /// you wish to test more contact info types, try adding them in Contacts -> + /// My Card. + /// + /// * Android autofill: Go to Settings -> System -> Languages & input -> + /// Autofill service. Enable the autofill service of your choice, and make + /// sure there are available credentials associated with your app. + /// + /// Specifying [InputDecoration.hintText] may also help autofill services + /// (like Samsung Pass) determine the expected content type of an input field, + /// although this is typically not required when autofillHints are present. + /// + /// #### I called `TextInput.finishAutofillContext` but the autofill save + /// prompt isn't showing + /// + /// * iOS: iOS may not show a prompt or any other visual indication when it + /// saves user password. Go to Settings -> Password and check if your new + /// password is saved. Neither saving password nor auto-generating strong + /// password works without properly setting up associated domains in your + /// app. To set up associated domains, follow the instructions in + /// . + /// + /// {@endtemplate} + /// {@macro flutter.services.AutofillConfiguration.autofillHints} + final Iterable? autofillHints; + + /// The [AutofillClient] that controls this input field's autofill behavior. + /// + /// When null, this widget's [EditableTextState] will be used as the + /// [AutofillClient]. This property may override [autofillHints]. + final AutofillClient? autofillClient; + + /// {@macro flutter.material.Material.clipBehavior} + /// + /// Defaults to [Clip.hardEdge]. + final Clip clipBehavior; + + /// Restoration ID to save and restore the scroll offset of the + /// [EditableText]. + /// + /// If a restoration id is provided, the [EditableText] will persist its + /// current scroll offset and restore it during state restoration. + /// + /// The scroll offset is persisted in a [RestorationBucket] claimed from + /// the surrounding [RestorationScope] using the provided restoration ID. + /// + /// Persisting and restoring the content of the [EditableText] is the + /// responsibility of the owner of the [controller], who may use a + /// [RestorableTextEditingController] for that purpose. + /// + /// See also: + /// + /// * [RestorationManager], which explains how state restoration works in + /// Flutter. + final String? restorationId; + + /// {@template flutter.widgets.editableText.scrollBehavior} + /// A [ScrollBehavior] that will be applied to this widget individually. + /// + /// Defaults to null, wherein the inherited [ScrollBehavior] is copied and + /// modified to alter the viewport decoration, like [Scrollbar]s. + /// + /// [ScrollBehavior]s also provide [ScrollPhysics]. If an explicit + /// [ScrollPhysics] is provided in [scrollPhysics], it will take precedence, + /// followed by [scrollBehavior], and then the inherited ancestor + /// [ScrollBehavior]. + /// {@endtemplate} + /// + /// The [ScrollBehavior] of the inherited [ScrollConfiguration] will be + /// modified by default to only apply a [Scrollbar] if [maxLines] is greater + /// than 1. + final ScrollBehavior? scrollBehavior; + + /// {@macro flutter.services.TextInputConfiguration.enableIMEPersonalizedLearning} + final bool enableIMEPersonalizedLearning; + + /// {@template flutter.widgets.editableText.contentInsertionConfiguration} + /// Configuration of handler for media content inserted via the system input + /// method. + /// + /// Defaults to null in which case media content insertion will be disabled, + /// and the system will display a message informing the user that the text field + /// does not support inserting media content. + /// + /// Set [ContentInsertionConfiguration.onContentInserted] to provide a handler. + /// Additionally, set [ContentInsertionConfiguration.allowedMimeTypes] + /// to limit the allowable mime types for inserted content. + /// + /// {@tool dartpad} + /// + /// This example shows how to access the data for inserted content in your + /// `TextField`. + /// + /// ** See code in examples/api/lib/widgets/editable_text/editable_text.on_content_inserted.0.dart ** + /// {@end-tool} + /// + /// If [contentInsertionConfiguration] is not provided, by default + /// an empty list of mime types will be sent to the Flutter Engine. + /// A handler function must be provided in order to customize the allowable + /// mime types for inserted content. + /// + /// If rich content is inserted without a handler, the system will display + /// a message informing the user that the current text input does not support + /// inserting rich content. + /// {@endtemplate} + final ContentInsertionConfiguration? contentInsertionConfiguration; + + /// {@template flutter.widgets.EditableText.contextMenuBuilder} + /// Builds the text selection toolbar when requested by the user. + /// + /// The context menu is built when [EditableTextState.showToolbar] is called, + /// typically by one of the callbacks installed by the widget created by + /// [TextSelectionGestureDetectorBuilder.buildGestureDetector]. The widget + /// returned by [contextMenuBuilder] is passed to a [ContextMenuController]. + /// + /// If no callback is provided, no context menu will be shown. + /// + /// The [EditableTextContextMenuBuilder] signature used by the + /// [contextMenuBuilder] callback has two parameters, the [BuildContext] of + /// the [EditableText] and the [EditableTextState] of the [EditableText]. + /// + /// The [EditableTextState] has two properties that are especially useful when + /// building the widgets for the context menu: + /// + /// * [EditableTextState.contextMenuAnchors] specifies the desired anchor + /// position for the context menu. + /// + /// * [EditableTextState.contextMenuButtonItems] represents the buttons that + /// should typically be built for this widget (e.g. cut, copy, paste). + /// + /// The [TextSelectionToolbarLayoutDelegate] class may be particularly useful + /// in honoring the preferred anchor positions. + /// + /// For backwards compatibility, when [EditableText.selectionControls] is set + /// to an object that does not mix in [TextSelectionHandleControls], + /// [contextMenuBuilder] is ignored and the + /// [TextSelectionControls.buildToolbar] method is used instead. + /// + /// {@tool dartpad} + /// This example shows how to customize the menu, in this case by keeping the + /// default buttons for the platform but modifying their appearance. + /// + /// ** See code in examples/api/lib/material/context_menu/editable_text_toolbar_builder.0.dart ** + /// {@end-tool} + /// + /// {@tool dartpad} + /// This example shows how to show a custom button only when an email address + /// is currently selected. + /// + /// ** See code in examples/api/lib/material/context_menu/editable_text_toolbar_builder.1.dart ** + /// {@end-tool} + /// + /// See also: + /// * [AdaptiveTextSelectionToolbar], which builds the default text selection + /// toolbar for the current platform, but allows customization of the + /// buttons. + /// * [AdaptiveTextSelectionToolbar.getAdaptiveButtons], which builds the + /// button Widgets for the current platform given + /// [ContextMenuButtonItem]s. + /// * [BrowserContextMenu], which allows the browser's context menu on web + /// to be disabled and Flutter-rendered context menus to appear. + /// {@endtemplate} + final EditableTextContextMenuBuilder? contextMenuBuilder; + + /// {@template flutter.widgets.EditableText.spellCheckConfiguration} + /// Configuration that details how spell check should be performed. + /// + /// Specifies the [SpellCheckService] used to spell check text input and the + /// [TextStyle] used to style text with misspelled words. + /// + /// If the [SpellCheckService] is left null, spell check is disabled by + /// default unless the [DefaultSpellCheckService] is supported, in which case + /// it is used. It is currently supported only on Android and iOS. + /// + /// If this configuration is left null, then spell check is disabled by default. + /// {@endtemplate} + final SpellCheckConfiguration? spellCheckConfiguration; + + /// The configuration for the magnifier to use with selections in this text + /// field. + /// + /// {@macro flutter.widgets.magnifier.intro} + final TextMagnifierConfiguration magnifierConfiguration; + + /// The default value for [stylusHandwritingEnabled]. + static const bool defaultStylusHandwritingEnabled = true; + + bool get _userSelectionEnabled => + enableInteractiveSelection && (!readOnly || !obscureText); + + /// Returns the [ContextMenuButtonItem]s representing the buttons in this + /// platform's default selection menu for an editable field. + /// + /// For example, [EditableText] uses this to generate the default buttons for + /// its context menu. + /// + /// See also: + /// + /// * [EditableTextState.contextMenuButtonItems], which gives the + /// [ContextMenuButtonItem]s for a specific EditableText. + /// * [SelectableRegion.getSelectableButtonItems], which performs a similar + /// role but for content that is selectable but not editable. + /// * [AdaptiveTextSelectionToolbar], which builds the toolbar itself, and can + /// take a list of [ContextMenuButtonItem]s with + /// [AdaptiveTextSelectionToolbar.buttonItems]. + /// * [AdaptiveTextSelectionToolbar.getAdaptiveButtons], which builds the button + /// Widgets for the current platform given [ContextMenuButtonItem]s. + static List getEditableButtonItems({ + required final ClipboardStatus? clipboardStatus, + required final VoidCallback? onCopy, + required final VoidCallback? onCut, + required final VoidCallback? onPaste, + required final VoidCallback? onSelectAll, + required final VoidCallback? onLookUp, + required final VoidCallback? onSearchWeb, + required final VoidCallback? onShare, + required final VoidCallback? onLiveTextInput, + }) { + final List resultButtonItem = + []; + + // Configure button items with clipboard. + if (onPaste == null || clipboardStatus != ClipboardStatus.unknown) { + // If the paste button is enabled, don't render anything until the state + // of the clipboard is known, since it's used to determine if paste is + // shown. + + // On Android, the share button is before the select all button. + final bool showShareBeforeSelectAll = + defaultTargetPlatform == TargetPlatform.android; + + resultButtonItem.addAll([ + if (onCut != null) + ContextMenuButtonItem( + onPressed: onCut, type: ContextMenuButtonType.cut), + if (onCopy != null) + ContextMenuButtonItem( + onPressed: onCopy, type: ContextMenuButtonType.copy), + if (onPaste != null) + ContextMenuButtonItem( + onPressed: onPaste, type: ContextMenuButtonType.paste), + if (onShare != null && showShareBeforeSelectAll) + ContextMenuButtonItem( + onPressed: onShare, type: ContextMenuButtonType.share), + if (onSelectAll != null) + ContextMenuButtonItem( + onPressed: onSelectAll, type: ContextMenuButtonType.selectAll), + if (onLookUp != null) + ContextMenuButtonItem( + onPressed: onLookUp, type: ContextMenuButtonType.lookUp), + if (onSearchWeb != null) + ContextMenuButtonItem( + onPressed: onSearchWeb, type: ContextMenuButtonType.searchWeb), + if (onShare != null && !showShareBeforeSelectAll) + ContextMenuButtonItem( + onPressed: onShare, type: ContextMenuButtonType.share), + ]); + } + + // Config button items with Live Text. + if (onLiveTextInput != null) { + resultButtonItem.add( + ContextMenuButtonItem( + onPressed: onLiveTextInput, + type: ContextMenuButtonType.liveTextInput, + ), + ); + } + + return resultButtonItem; + } + + // Infer the keyboard type of an `EditableText` if it's not specified. + static TextInputType _inferKeyboardType({ + required Iterable? autofillHints, + required int? maxLines, + }) { + if (autofillHints == null || autofillHints.isEmpty) { + return maxLines == 1 ? TextInputType.text : TextInputType.multiline; + } + + final String effectiveHint = autofillHints.first; + + // On iOS oftentimes specifying a text content type is not enough to qualify + // the input field for autofill. The keyboard type also needs to be compatible + // with the content type. To get autofill to work by default on EditableText, + // the keyboard type inference on iOS is done differently from other platforms. + // + // The entries with "autofill not working" comments are the iOS text content + // types that should work with the specified keyboard type but won't trigger + // (even within a native app). Tested on iOS 13.5. + if (!kIsWeb) { + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + const Map iOSKeyboardType = + { + AutofillHints.addressCity: TextInputType.name, + AutofillHints.addressCityAndState: + TextInputType.name, // Autofill not working. + AutofillHints.addressState: TextInputType.name, + AutofillHints.countryName: TextInputType.name, + AutofillHints.creditCardNumber: + TextInputType.number, // Couldn't test. + AutofillHints.email: TextInputType.emailAddress, + AutofillHints.familyName: TextInputType.name, + AutofillHints.fullStreetAddress: TextInputType.name, + AutofillHints.givenName: TextInputType.name, + AutofillHints.jobTitle: TextInputType.name, // Autofill not working. + AutofillHints.location: TextInputType.name, // Autofill not working. + AutofillHints.middleName: + TextInputType.name, // Autofill not working. + AutofillHints.name: TextInputType.name, + AutofillHints.namePrefix: + TextInputType.name, // Autofill not working. + AutofillHints.nameSuffix: + TextInputType.name, // Autofill not working. + AutofillHints.newPassword: TextInputType.text, + AutofillHints.newUsername: TextInputType.text, + AutofillHints.nickname: TextInputType.name, // Autofill not working. + AutofillHints.oneTimeCode: TextInputType.number, + AutofillHints.organizationName: + TextInputType.text, // Autofill not working. + AutofillHints.password: TextInputType.text, + AutofillHints.postalCode: TextInputType.name, + AutofillHints.streetAddressLine1: TextInputType.name, + AutofillHints.streetAddressLine2: + TextInputType.name, // Autofill not working. + AutofillHints.sublocality: + TextInputType.name, // Autofill not working. + AutofillHints.telephoneNumber: TextInputType.name, + AutofillHints.url: TextInputType.url, // Autofill not working. + AutofillHints.username: TextInputType.text, + }; + + final TextInputType? keyboardType = iOSKeyboardType[effectiveHint]; + if (keyboardType != null) { + return keyboardType; + } + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + break; + } + } + + if (maxLines != 1) { + return TextInputType.multiline; + } + + const Map inferKeyboardType = + { + AutofillHints.addressCity: TextInputType.streetAddress, + AutofillHints.addressCityAndState: TextInputType.streetAddress, + AutofillHints.addressState: TextInputType.streetAddress, + AutofillHints.birthday: TextInputType.datetime, + AutofillHints.birthdayDay: TextInputType.datetime, + AutofillHints.birthdayMonth: TextInputType.datetime, + AutofillHints.birthdayYear: TextInputType.datetime, + AutofillHints.countryCode: TextInputType.number, + AutofillHints.countryName: TextInputType.text, + AutofillHints.creditCardExpirationDate: TextInputType.datetime, + AutofillHints.creditCardExpirationDay: TextInputType.datetime, + AutofillHints.creditCardExpirationMonth: TextInputType.datetime, + AutofillHints.creditCardExpirationYear: TextInputType.datetime, + AutofillHints.creditCardFamilyName: TextInputType.name, + AutofillHints.creditCardGivenName: TextInputType.name, + AutofillHints.creditCardMiddleName: TextInputType.name, + AutofillHints.creditCardName: TextInputType.name, + AutofillHints.creditCardNumber: TextInputType.number, + AutofillHints.creditCardSecurityCode: TextInputType.number, + AutofillHints.creditCardType: TextInputType.text, + AutofillHints.email: TextInputType.emailAddress, + AutofillHints.familyName: TextInputType.name, + AutofillHints.fullStreetAddress: TextInputType.streetAddress, + AutofillHints.gender: TextInputType.text, + AutofillHints.givenName: TextInputType.name, + AutofillHints.impp: TextInputType.url, + AutofillHints.jobTitle: TextInputType.text, + AutofillHints.language: TextInputType.text, + AutofillHints.location: TextInputType.streetAddress, + AutofillHints.middleInitial: TextInputType.name, + AutofillHints.middleName: TextInputType.name, + AutofillHints.name: TextInputType.name, + AutofillHints.namePrefix: TextInputType.name, + AutofillHints.nameSuffix: TextInputType.name, + AutofillHints.newPassword: TextInputType.text, + AutofillHints.newUsername: TextInputType.text, + AutofillHints.nickname: TextInputType.text, + AutofillHints.oneTimeCode: TextInputType.text, + AutofillHints.organizationName: TextInputType.text, + AutofillHints.password: TextInputType.text, + AutofillHints.photo: TextInputType.text, + AutofillHints.postalAddress: TextInputType.streetAddress, + AutofillHints.postalAddressExtended: TextInputType.streetAddress, + AutofillHints.postalAddressExtendedPostalCode: TextInputType.number, + AutofillHints.postalCode: TextInputType.number, + AutofillHints.streetAddressLevel1: TextInputType.streetAddress, + AutofillHints.streetAddressLevel2: TextInputType.streetAddress, + AutofillHints.streetAddressLevel3: TextInputType.streetAddress, + AutofillHints.streetAddressLevel4: TextInputType.streetAddress, + AutofillHints.streetAddressLine1: TextInputType.streetAddress, + AutofillHints.streetAddressLine2: TextInputType.streetAddress, + AutofillHints.streetAddressLine3: TextInputType.streetAddress, + AutofillHints.sublocality: TextInputType.streetAddress, + AutofillHints.telephoneNumber: TextInputType.phone, + AutofillHints.telephoneNumberAreaCode: TextInputType.phone, + AutofillHints.telephoneNumberCountryCode: TextInputType.phone, + AutofillHints.telephoneNumberDevice: TextInputType.phone, + AutofillHints.telephoneNumberExtension: TextInputType.phone, + AutofillHints.telephoneNumberLocal: TextInputType.phone, + AutofillHints.telephoneNumberLocalPrefix: TextInputType.phone, + AutofillHints.telephoneNumberLocalSuffix: TextInputType.phone, + AutofillHints.telephoneNumberNational: TextInputType.phone, + AutofillHints.transactionAmount: + TextInputType.numberWithOptions(decimal: true), + AutofillHints.transactionCurrency: TextInputType.text, + AutofillHints.url: TextInputType.url, + AutofillHints.username: TextInputType.text, + }; + + return inferKeyboardType[effectiveHint] ?? TextInputType.text; + } + + @override + EditableTextState createState() => EditableTextState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add( + DiagnosticsProperty('controller', controller)); + properties.add(DiagnosticsProperty('focusNode', focusNode)); + properties.add(DiagnosticsProperty('obscureText', obscureText, + defaultValue: false)); + properties.add( + DiagnosticsProperty('readOnly', readOnly, defaultValue: false)); + properties.add(DiagnosticsProperty('autocorrect', autocorrect, + defaultValue: true)); + properties.add( + EnumProperty( + 'smartDashesType', + smartDashesType, + defaultValue: + obscureText ? SmartDashesType.disabled : SmartDashesType.enabled, + ), + ); + properties.add( + EnumProperty( + 'smartQuotesType', + smartQuotesType, + defaultValue: + obscureText ? SmartQuotesType.disabled : SmartQuotesType.enabled, + ), + ); + properties.add( + DiagnosticsProperty('enableSuggestions', enableSuggestions, + defaultValue: true), + ); + style.debugFillProperties(properties); + properties.add( + EnumProperty('textAlign', textAlign, defaultValue: null)); + properties.add(EnumProperty('textDirection', textDirection, + defaultValue: null)); + properties + .add(DiagnosticsProperty('locale', locale, defaultValue: null)); + properties.add(DiagnosticsProperty('textScaler', textScaler, + defaultValue: null)); + properties.add(IntProperty('maxLines', maxLines, defaultValue: 1)); + properties.add(IntProperty('minLines', minLines, defaultValue: null)); + properties.add( + DiagnosticsProperty('expands', expands, defaultValue: false)); + properties.add( + DiagnosticsProperty('autofocus', autofocus, defaultValue: false)); + properties.add( + DiagnosticsProperty('keyboardType', keyboardType, + defaultValue: null), + ); + properties.add( + DiagnosticsProperty( + 'scrollController', + scrollController, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty('scrollPhysics', scrollPhysics, + defaultValue: null), + ); + properties.add( + DiagnosticsProperty>('autofillHints', autofillHints, + defaultValue: null), + ); + properties.add( + DiagnosticsProperty( + 'textHeightBehavior', + textHeightBehavior, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty('scribbleEnabled', scribbleEnabled, + defaultValue: true), + ); + properties.add( + DiagnosticsProperty( + 'stylusHandwritingEnabled', + stylusHandwritingEnabled, + defaultValue: defaultStylusHandwritingEnabled, + ), + ); + properties.add( + DiagnosticsProperty( + 'enableIMEPersonalizedLearning', + enableIMEPersonalizedLearning, + defaultValue: true, + ), + ); + properties.add( + DiagnosticsProperty( + 'enableInteractiveSelection', + enableInteractiveSelection, + defaultValue: true, + ), + ); + properties.add( + DiagnosticsProperty( + 'undoController', + undoController, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty( + 'spellCheckConfiguration', + spellCheckConfiguration, + defaultValue: null, + ), + ); + properties.add( + DiagnosticsProperty>( + 'contentCommitMimeTypes', + contentInsertionConfiguration?.allowedMimeTypes ?? const [], + defaultValue: contentInsertionConfiguration == null + ? const [] + : kDefaultContentInsertionMimeTypes, + ), + ); + } +} + +/// State for an [EditableText]. +class EditableTextState extends State + with + AutomaticKeepAliveClientMixin, + WidgetsBindingObserver, + TickerProviderStateMixin, + TextSelectionDelegate, + TextInputClient, + DeltaTextInputClient + implements AutofillClient { + Timer? _cursorTimer; + AnimationController get _cursorBlinkOpacityController { + return _backingCursorBlinkOpacityController ??= + AnimationController(vsync: this)..addListener(_onCursorColorTick); + } + + AnimationController? _backingCursorBlinkOpacityController; + late final Simulation _iosBlinkCursorSimulation = + _DiscreteKeyFrameSimulation.iOSBlinkingCaret(); + + final ValueNotifier _cursorVisibilityNotifier = + ValueNotifier(true); + final GlobalKey _editableKey = GlobalKey(); + + /// Detects whether the clipboard can paste. + final ClipboardStatusNotifier clipboardStatus = kIsWeb + // Web browsers will show a permission dialog when Clipboard.hasStrings is + // called. In an EditableText, this will happen before the paste button is + // clicked, often before the context menu is even shown. To avoid this + // poor user experience, always show the paste button on web. + ? _WebClipboardStatusNotifier() + : ClipboardStatusNotifier(); + + /// Detects whether the Live Text input is enabled. + /// + /// See also: + /// * [LiveText], where the availability of Live Text input can be obtained. + final LiveTextInputStatusNotifier? _liveTextInputStatus = + kIsWeb ? null : LiveTextInputStatusNotifier(); + + TextInputConnection? _textInputConnection; + bool get _hasInputConnection => _textInputConnection?.attached ?? false; + + TextSelectionOverlay? _selectionOverlay; + ScrollNotificationObserverState? _scrollNotificationObserver; + ({ + TextEditingValue value, + Rect selectionBounds + })? _dataWhenToolbarShowScheduled; + bool _listeningToScrollNotificationObserver = false; + + bool get _webContextMenuEnabled => kIsWeb && BrowserContextMenu.enabled; + + final GlobalKey _scrollableKey = GlobalKey(); + ScrollController? _internalScrollController; + ScrollController get _scrollController => + widget.scrollController ?? + (_internalScrollController ??= ScrollController()); + + final LayerLink _toolbarLayerLink = LayerLink(); + final LayerLink _startHandleLayerLink = LayerLink(); + final LayerLink _endHandleLayerLink = LayerLink(); + + bool _didAutoFocus = false; + + AutofillGroupState? _currentAutofillScope; + @override + AutofillScope? get currentAutofillScope => _currentAutofillScope; + + AutofillClient get _effectiveAutofillClient => widget.autofillClient ?? this; + + late SpellCheckConfiguration _spellCheckConfiguration; + late TextStyle _style; + + /// Configuration that determines how spell check will be performed. + /// + /// If possible, this configuration will contain a default for the + /// [SpellCheckService] if it is not otherwise specified. + /// + /// See also: + /// * [DefaultSpellCheckService], the spell check service used by default. + @visibleForTesting + SpellCheckConfiguration get spellCheckConfiguration => + _spellCheckConfiguration; + + /// Whether or not spell check is enabled. + /// + /// Spell check is enabled when a [SpellCheckConfiguration] has been specified + /// for the widget. + bool get spellCheckEnabled => _spellCheckConfiguration.spellCheckEnabled; + + /// The most up-to-date spell check results for text input. + /// + /// 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; + + bool get _spellCheckResultsReceived => + spellCheckEnabled && + spellCheckResults != null && + spellCheckResults!.suggestionSpans.isNotEmpty; + + /// The text processing service used to retrieve the native text processing actions. + final ProcessTextService _processTextService = DefaultProcessTextService(); + + /// The list of native text processing actions provided by the engine. + final List _processTextActions = []; + + /// Whether to create an input connection with the platform for text editing + /// or not. + /// + /// Read-only input fields do not need a connection with the platform since + /// there's no need for text editing capabilities (e.g. virtual keyboard). + /// + /// On macOS, most of the selection and focus related shortcuts require a + /// connection with the platform because appropriate platform selectors are + /// sent from the engine and translated into intents. For read-only fields + /// those shortcuts should be available (for instance to allow tab traversal). + /// + /// On the web, we always need a connection because we want some browser + /// functionalities to continue to work on read-only input fields like: + /// - Relevant context menu. + /// - cmd/ctrl+c shortcut to copy. + /// - cmd/ctrl+a to select all. + /// - Changing the selection using a physical keyboard. + bool get _shouldCreateInputConnection => + kIsWeb || + defaultTargetPlatform == TargetPlatform.macOS || + !widget.readOnly; + + // The time it takes for the floating cursor to snap to the text aligned + // cursor position after the user has finished placing it. + static const Duration _floatingCursorResetTime = Duration(milliseconds: 125); + + AnimationController? _floatingCursorResetController; + + Orientation? _lastOrientation; + + bool get _stylusHandwritingEnabled { + // During the deprecation period, respect scribbleEnabled being explicitly + // set. + if (!widget.scribbleEnabled) { + return widget.scribbleEnabled; + } + return widget.stylusHandwritingEnabled; + } + + late final AppLifecycleListener _appLifecycleListener; + bool _justResumed = false; + + @override + bool get wantKeepAlive => widget.focusNode.hasFocus; + + Color get _cursorColor { + final double effectiveOpacity = math.min( + widget.cursorColor.alpha / 255.0, + _cursorBlinkOpacityController.value, + ); + return widget.cursorColor.withOpacity(effectiveOpacity); + } + + @override + bool get cutEnabled { + if (widget.selectionControls is! TextSelectionHandleControls) { + return widget.toolbarOptions.cut && + !widget.readOnly && + !widget.obscureText; + } + return !widget.readOnly && + !widget.obscureText && + !textEditingValue.selection.isCollapsed; + } + + @override + bool get copyEnabled { + if (widget.selectionControls is! TextSelectionHandleControls) { + return widget.toolbarOptions.copy && !widget.obscureText; + } + return !widget.obscureText && !textEditingValue.selection.isCollapsed; + } + + @override + bool get pasteEnabled { + if (widget.selectionControls is! TextSelectionHandleControls) { + return widget.toolbarOptions.paste && !widget.readOnly; + } + return !widget.readOnly && + (clipboardStatus.value == ClipboardStatus.pasteable); + } + + @override + bool get selectAllEnabled { + if (widget.selectionControls is! TextSelectionHandleControls) { + return widget.toolbarOptions.selectAll && + (!widget.readOnly || !widget.obscureText) && + widget.enableInteractiveSelection; + } + + if (!widget.enableInteractiveSelection || + (widget.readOnly && widget.obscureText)) { + return false; + } + + switch (defaultTargetPlatform) { + case TargetPlatform.macOS: + return false; + case TargetPlatform.iOS: + return textEditingValue.text.isNotEmpty && + textEditingValue.selection.isCollapsed; + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + return textEditingValue.text.isNotEmpty && + !(textEditingValue.selection.start == 0 && + textEditingValue.selection.end == textEditingValue.text.length); + } + } + + @override + bool get lookUpEnabled { + if (defaultTargetPlatform != TargetPlatform.iOS) { + return false; + } + return !widget.obscureText && + !textEditingValue.selection.isCollapsed && + textEditingValue.selection.textInside(textEditingValue.text).trim() != + ''; + } + + @override + bool get searchWebEnabled { + if (defaultTargetPlatform != TargetPlatform.iOS) { + return false; + } + + return !widget.obscureText && + !textEditingValue.selection.isCollapsed && + textEditingValue.selection.textInside(textEditingValue.text).trim() != + ''; + } + + @override + bool get shareEnabled { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.iOS: + return !widget.obscureText && + !textEditingValue.selection.isCollapsed && + textEditingValue.selection + .textInside(textEditingValue.text) + .trim() != + ''; + case TargetPlatform.macOS: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + return false; + } + } + + @override + bool get liveTextInputEnabled { + return _liveTextInputStatus?.value == LiveTextInputStatus.enabled && + !widget.obscureText && + !widget.readOnly && + textEditingValue.selection.isCollapsed; + } + + void _onChangedClipboardStatus() { + setState(() { + // Inform the widget that the value of clipboardStatus has changed. + }); + } + + void _onChangedLiveTextInputStatus() { + setState(() { + // Inform the widget that the value of liveTextInputStatus has changed. + }); + } + + TextEditingValue get _textEditingValueforTextLayoutMetrics { + final Widget? editableWidget = _editableKey.currentContext?.widget; + if (editableWidget is! _Editable) { + throw StateError('_Editable must be mounted.'); + } + return editableWidget.value; + } + + /// Copy current selection to [Clipboard]. + @override + void copySelection(SelectionChangedCause cause) { + final TextSelection selection = textEditingValue.selection; + if (selection.isCollapsed || widget.obscureText) { + return; + } + final String text = textEditingValue.text; + Clipboard.setData(ClipboardData(text: selection.textInside(text))); + if (cause == SelectionChangedCause.toolbar) { + bringIntoView(textEditingValue.selection.extent); + hideToolbar(false); + + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + case TargetPlatform.linux: + case TargetPlatform.windows: + break; + case TargetPlatform.android: + case TargetPlatform.fuchsia: + // Collapse the selection and hide the toolbar and handles. + userUpdateTextEditingValue( + TextEditingValue( + text: textEditingValue.text, + selection: TextSelection.collapsed( + offset: textEditingValue.selection.end), + ), + SelectionChangedCause.toolbar, + ); + } + } + clipboardStatus.update(); + } + + /// Cut current selection to [Clipboard]. + @override + void cutSelection(SelectionChangedCause cause) { + if (widget.readOnly || widget.obscureText) { + return; + } + final TextSelection selection = textEditingValue.selection; + final String text = textEditingValue.text; + if (selection.isCollapsed) { + return; + } + Clipboard.setData(ClipboardData(text: selection.textInside(text))); + _replaceText(ReplaceTextIntent(textEditingValue, '', selection, cause)); + if (cause == SelectionChangedCause.toolbar) { + // Schedule a call to bringIntoView() after renderEditable updates. + SchedulerBinding.instance.addPostFrameCallback((_) { + if (mounted) { + bringIntoView(textEditingValue.selection.extent); + } + }, debugLabel: 'EditableText.bringSelectionIntoView'); + hideToolbar(); + } + clipboardStatus.update(); + } + + bool get _allowPaste { + return !widget.readOnly && textEditingValue.selection.isValid; + } + + /// Paste text from [Clipboard]. + @override + Future pasteText(SelectionChangedCause cause) async { + if (!_allowPaste) { + return; + } + // Snapshot the input before using `await`. + // See https://github.com/flutter/flutter/issues/11427 + final ClipboardData? data = await Clipboard.getData(Clipboard.kTextPlain); + if (data == null) { + return; + } + _pasteText(cause, data.text!); + } + + void _pasteText(SelectionChangedCause cause, String text) { + if (!_allowPaste) { + return; + } + + // After the paste, the cursor should be collapsed and located after the + // pasted content. + final TextSelection selection = textEditingValue.selection; + final int lastSelectionIndex = + math.max(selection.baseOffset, selection.extentOffset); + final TextEditingValue collapsedTextEditingValue = + textEditingValue.copyWith( + selection: TextSelection.collapsed(offset: lastSelectionIndex), + ); + + userUpdateTextEditingValue( + collapsedTextEditingValue.replaced(selection, text), cause); + if (cause == SelectionChangedCause.toolbar) { + // Schedule a call to bringIntoView() after renderEditable updates. + SchedulerBinding.instance.addPostFrameCallback((_) { + if (mounted) { + bringIntoView(textEditingValue.selection.extent); + } + }, debugLabel: 'EditableText.bringSelectionIntoView'); + hideToolbar(); + } + } + + /// Select the entire text value. + @override + void selectAll(SelectionChangedCause cause) { + if (widget.readOnly && widget.obscureText) { + // If we can't modify it, and we can't copy it, there's no point in + // selecting it. + return; + } + userUpdateTextEditingValue( + textEditingValue.copyWith( + selection: TextSelection( + baseOffset: 0, extentOffset: textEditingValue.text.length), + ), + cause, + ); + + if (cause == SelectionChangedCause.toolbar) { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.iOS: + case TargetPlatform.fuchsia: + break; + case TargetPlatform.macOS: + case TargetPlatform.linux: + case TargetPlatform.windows: + hideToolbar(); + } + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + bringIntoView(textEditingValue.selection.extent); + case TargetPlatform.macOS: + case TargetPlatform.iOS: + break; + } + } + } + + /// Look up the current selection, + /// as in the "Look Up" edit menu button on iOS. + /// + /// Currently this is only implemented for iOS. + /// + /// Throws an error if the selection is empty or collapsed. + Future lookUpSelection(SelectionChangedCause cause) async { + assert(!widget.obscureText); + + final String text = + textEditingValue.selection.textInside(textEditingValue.text); + if (widget.obscureText || text.isEmpty) { + return; + } + await SystemChannels.platform.invokeMethod('LookUp.invoke', text); + } + + /// Launch a web search on the current selection, + /// as in the "Search Web" edit menu button on iOS. + /// + /// Currently this is only implemented for iOS. + /// + /// When 'obscureText' is true or the selection is empty, + /// this function will not do anything + Future searchWebForSelection(SelectionChangedCause cause) async { + assert(!widget.obscureText); + if (widget.obscureText) { + return; + } + + final String text = + textEditingValue.selection.textInside(textEditingValue.text); + if (text.isNotEmpty) { + await SystemChannels.platform.invokeMethod('SearchWeb.invoke', text); + } + } + + /// Launch the share interface for the current selection, + /// as in the "Share..." edit menu button on iOS. + /// + /// Currently this is only implemented for iOS and Android. + /// + /// When 'obscureText' is true or the selection is empty, + /// this function will not do anything + Future shareSelection(SelectionChangedCause cause) async { + assert(!widget.obscureText); + if (widget.obscureText) { + return; + } + + final String text = + textEditingValue.selection.textInside(textEditingValue.text); + if (text.isNotEmpty) { + await SystemChannels.platform.invokeMethod('Share.invoke', text); + } + } + + void _startLiveTextInput(SelectionChangedCause cause) { + if (!liveTextInputEnabled) { + return; + } + if (_hasInputConnection) { + LiveText.startLiveTextInput(); + } + if (cause == SelectionChangedCause.toolbar) { + 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 received or the cursor index is out + // of range that suggestionSpans covers. + return null; + } + + final List 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 + /// the [SpellCheckService] if left unspecified. + static SpellCheckConfiguration _inferSpellCheckConfiguration( + SpellCheckConfiguration? configuration, + ) { + final SpellCheckService? spellCheckService = + configuration?.spellCheckService; + final bool spellCheckAutomaticallyDisabled = configuration == null || + configuration == const SpellCheckConfiguration.disabled(); + final bool spellCheckServiceIsConfigured = spellCheckService != null || + WidgetsBinding + .instance.platformDispatcher.nativeSpellCheckServiceDefined; + if (spellCheckAutomaticallyDisabled || !spellCheckServiceIsConfigured) { + // Only enable spell check if a non-disabled configuration is provided + // and if that configuration does not specify a spell check service, + // a native spell checker must be supported. + assert(() { + if (!spellCheckAutomaticallyDisabled && + !spellCheckServiceIsConfigured) { + FlutterError.reportError( + FlutterErrorDetails( + exception: FlutterError( + 'Spell check was enabled with spellCheckConfiguration, but the ' + 'current platform does not have a supported spell check ' + 'service, and none was provided. Consider disabling spell ' + 'check for this platform or passing a SpellCheckConfiguration ' + 'with a specified spell check service.', + ), + library: 'widget library', + stack: StackTrace.current, + ), + ); + } + return true; + }()); + return const SpellCheckConfiguration.disabled(); + } + + return configuration.copyWith( + spellCheckService: spellCheckService ?? DefaultSpellCheckService(), + ); + } + + /// Returns the [ContextMenuButtonItem]s for the given [ToolbarOptions]. + @Deprecated( + 'Use `contextMenuBuilder` instead of `toolbarOptions`. ' + 'This feature was deprecated after v3.3.0-0.5.pre.', + ) + List? buttonItemsForToolbarOptions( + [TargetPlatform? targetPlatform]) { + final ToolbarOptions toolbarOptions = widget.toolbarOptions; + if (toolbarOptions == ToolbarOptions.empty) { + return null; + } + return [ + if (toolbarOptions.cut && cutEnabled) + ContextMenuButtonItem( + onPressed: () { + cutSelection(SelectionChangedCause.toolbar); + }, + type: ContextMenuButtonType.cut, + ), + if (toolbarOptions.copy && copyEnabled) + ContextMenuButtonItem( + onPressed: () { + copySelection(SelectionChangedCause.toolbar); + }, + type: ContextMenuButtonType.copy, + ), + if (toolbarOptions.paste && pasteEnabled) + ContextMenuButtonItem( + onPressed: () { + pasteText(SelectionChangedCause.toolbar); + }, + type: ContextMenuButtonType.paste, + ), + if (toolbarOptions.selectAll && selectAllEnabled) + ContextMenuButtonItem( + onPressed: () { + selectAll(SelectionChangedCause.toolbar); + }, + type: ContextMenuButtonType.selectAll, + ), + ]; + } + + /// Gets the line heights at the start and end of the selection for the given + /// [EditableTextState]. + /// + /// See also: + /// + /// * [TextSelectionToolbarAnchors.getSelectionRect], which depends on this + /// information. + ({double startGlyphHeight, double endGlyphHeight}) getGlyphHeights() { + final TextSelection selection = textEditingValue.selection; + + // Only calculate handle rects if the text in the previous frame + // is the same as the text in the current frame. This is done because + // widget.renderObject contains the renderEditable from the previous frame. + // If the text changed between the current and previous frames then + // widget.renderObject.getRectForComposingRange might fail. In cases where + // the current frame is different from the previous we fall back to + // renderObject.preferredLineHeight. + final InlineSpan span = renderEditable.text!; + final String prevText = span.toPlainText(); + final String currText = textEditingValue.text; + if (prevText != currText || !selection.isValid || selection.isCollapsed) { + return ( + startGlyphHeight: renderEditable.preferredLineHeight, + endGlyphHeight: renderEditable.preferredLineHeight, + ); + } + + final String selectedGraphemes = selection.textInside(currText); + final int firstSelectedGraphemeExtent = + selectedGraphemes.characters.first.length; + final Rect? startCharacterRect = renderEditable.getRectForComposingRange( + TextRange( + start: selection.start, + end: selection.start + firstSelectedGraphemeExtent), + ); + final int lastSelectedGraphemeExtent = + selectedGraphemes.characters.last.length; + final Rect? endCharacterRect = renderEditable.getRectForComposingRange( + TextRange( + start: selection.end - lastSelectedGraphemeExtent, + end: selection.end), + ); + return ( + startGlyphHeight: + startCharacterRect?.height ?? renderEditable.preferredLineHeight, + endGlyphHeight: + endCharacterRect?.height ?? renderEditable.preferredLineHeight, + ); + } + + /// {@template flutter.widgets.EditableText.getAnchors} + /// Returns the anchor points for the default context menu. + /// {@endtemplate} + /// + /// See also: + /// + /// * [contextMenuButtonItems], which provides the [ContextMenuButtonItem]s + /// for the default context menu buttons. + TextSelectionToolbarAnchors get contextMenuAnchors { + if (renderEditable.lastSecondaryTapDownPosition != null) { + return TextSelectionToolbarAnchors( + primaryAnchor: renderEditable.lastSecondaryTapDownPosition!, + ); + } + + final ( + startGlyphHeight: double startGlyphHeight, + endGlyphHeight: double endGlyphHeight + ) = getGlyphHeights(); + final TextSelection selection = textEditingValue.selection; + final List points = + renderEditable.getEndpointsForSelection(selection); + return TextSelectionToolbarAnchors.fromSelection( + renderBox: renderEditable, + startGlyphHeight: startGlyphHeight, + endGlyphHeight: endGlyphHeight, + selectionEndpoints: points, + ); + } + + /// Returns the [ContextMenuButtonItem]s representing the buttons in this + /// platform's default selection menu for [EditableText]. + /// + /// See also: + /// + /// * [EditableText.getEditableButtonItems], which performs a similar role, + /// but for any editable field, not just specifically EditableText. + /// * [SystemContextMenu.getDefaultItems], which performs a similar role, but + /// for the system-rendered context menu. + /// * [SelectableRegionState.contextMenuButtonItems], which performs a similar + /// role but for content that is selectable but not editable. + /// * [contextMenuAnchors], which provides the anchor points for the default + /// context menu. + /// * [AdaptiveTextSelectionToolbar], which builds the toolbar itself, and can + /// take a list of [ContextMenuButtonItem]s with + /// [AdaptiveTextSelectionToolbar.buttonItems]. + /// * [AdaptiveTextSelectionToolbar.getAdaptiveButtons], which builds the + /// button Widgets for the current platform given [ContextMenuButtonItem]s. + List get contextMenuButtonItems { + return buttonItemsForToolbarOptions() ?? + EditableText.getEditableButtonItems( + clipboardStatus: clipboardStatus.value, + onCopy: copyEnabled + ? () => copySelection(SelectionChangedCause.toolbar) + : null, + onCut: cutEnabled + ? () => cutSelection(SelectionChangedCause.toolbar) + : null, + onPaste: pasteEnabled + ? () => pasteText(SelectionChangedCause.toolbar) + : null, + onSelectAll: selectAllEnabled + ? () => selectAll(SelectionChangedCause.toolbar) + : null, + onLookUp: lookUpEnabled + ? () => lookUpSelection(SelectionChangedCause.toolbar) + : null, + onSearchWeb: searchWebEnabled + ? () => searchWebForSelection(SelectionChangedCause.toolbar) + : null, + onShare: shareEnabled + ? () => shareSelection(SelectionChangedCause.toolbar) + : null, + onLiveTextInput: liveTextInputEnabled + ? () => _startLiveTextInput(SelectionChangedCause.toolbar) + : null, + ) + ..addAll(_textProcessingActionButtonItems); + } + + List get _textProcessingActionButtonItems { + final List buttonItems = []; + final TextSelection selection = textEditingValue.selection; + if (widget.obscureText || !selection.isValid || selection.isCollapsed) { + return buttonItems; + } + + for (final ProcessTextAction action in _processTextActions) { + buttonItems.add( + ContextMenuButtonItem( + label: action.label, + onPressed: () async { + final String selectedText = + selection.textInside(textEditingValue.text); + if (selectedText.isNotEmpty) { + final String? processedText = + await _processTextService.processTextAction( + action.id, + selectedText, + widget.readOnly, + ); + // If an activity does not return a modified version, just hide the toolbar. + // Otherwise use the result to replace the selected text. + if (processedText != null && _allowPaste) { + _pasteText(SelectionChangedCause.toolbar, processedText); + } else { + hideToolbar(); + } + } + }, + ), + ); + } + return buttonItems; + } + + // State lifecycle: + + @protected + @override + void initState() { + super.initState(); + _liveTextInputStatus?.addListener(_onChangedLiveTextInputStatus); + clipboardStatus.addListener(_onChangedClipboardStatus); + widget.controller.addListener(_didChangeTextEditingValue); + widget.focusNode.addListener(_handleFocusChanged); + _cursorVisibilityNotifier.value = widget.showCursor; + _spellCheckConfiguration = + _inferSpellCheckConfiguration(widget.spellCheckConfiguration); + _appLifecycleListener = + AppLifecycleListener(onResume: () => _justResumed = true); + _initProcessTextActions(); + } + + /// Query the engine to initialize the list of text processing actions to show + /// in the text selection toolbar. + Future _initProcessTextActions() async { + _processTextActions.clear(); + _processTextActions.addAll(await _processTextService.queryTextActions()); + } + + // Whether `TickerMode.of(context)` is true and animations (like blinking the + // cursor) are supposed to run. + bool _tickersEnabled = true; + + @protected + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + _style = MediaQuery.boldTextOf(context) + ? widget.style.merge(const TextStyle(fontWeight: FontWeight.bold)) + : widget.style; + + final AutofillGroupState? newAutofillGroup = AutofillGroup.maybeOf(context); + if (currentAutofillScope != newAutofillGroup) { + _currentAutofillScope?.unregister(autofillId); + _currentAutofillScope = newAutofillGroup; + _currentAutofillScope?.register(_effectiveAutofillClient); + } + + if (!_didAutoFocus && widget.autofocus) { + _didAutoFocus = true; + SchedulerBinding.instance.addPostFrameCallback((_) { + if (mounted && renderEditable.hasSize) { + _flagInternalFocus(); + FocusScope.of(context).autofocus(widget.focusNode); + } + }, debugLabel: 'EditableText.autofocus'); + } + + // Restart or stop the blinking cursor when TickerMode changes. + final bool newTickerEnabled = TickerMode.of(context); + if (_tickersEnabled != newTickerEnabled) { + _tickersEnabled = newTickerEnabled; + if (_showBlinkingCursor) { + _startCursorBlink(); + } else if (!_tickersEnabled && _cursorTimer != null) { + _stopCursorBlink(); + } + } + + // Check for changes in viewId. + if (_hasInputConnection) { + final int newViewId = View.of(context).viewId; + if (newViewId != _viewId) { + _textInputConnection! + .updateConfig(_effectiveAutofillClient.textInputConfiguration); + } + } + + if (defaultTargetPlatform != TargetPlatform.iOS && + defaultTargetPlatform != TargetPlatform.android) { + return; + } + + // Hide the text selection toolbar on mobile when orientation changes. + final Orientation orientation = MediaQuery.orientationOf(context); + if (_lastOrientation == null) { + _lastOrientation = orientation; + return; + } + if (orientation != _lastOrientation) { + _lastOrientation = orientation; + if (defaultTargetPlatform == TargetPlatform.iOS) { + hideToolbar(false); + } + if (defaultTargetPlatform == TargetPlatform.android) { + hideToolbar(); + } + } + + if (_listeningToScrollNotificationObserver) { + // Only update subscription when we have previously subscribed to the + // scroll notification observer. We only subscribe to the scroll + // notification observer when the context menu is shown on platforms that + // support _platformSupportsFadeOnScroll. + _scrollNotificationObserver + ?.removeListener(_handleContextMenuOnParentScroll); + _scrollNotificationObserver = ScrollNotificationObserver.maybeOf(context); + _scrollNotificationObserver + ?.addListener(_handleContextMenuOnParentScroll); + } + } + + @protected + @override + void didUpdateWidget(EditableText oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.controller != oldWidget.controller) { + oldWidget.controller.removeListener(_didChangeTextEditingValue); + widget.controller.addListener(_didChangeTextEditingValue); + _updateRemoteEditingValueIfNeeded(); + } + + if (_selectionOverlay != null && + (widget.contextMenuBuilder != oldWidget.contextMenuBuilder || + widget.selectionControls != oldWidget.selectionControls || + widget.onSelectionHandleTapped != + oldWidget.onSelectionHandleTapped || + widget.dragStartBehavior != oldWidget.dragStartBehavior || + widget.magnifierConfiguration != + oldWidget.magnifierConfiguration)) { + final bool shouldShowToolbar = _selectionOverlay!.toolbarIsVisible; + final bool shouldShowHandles = _selectionOverlay!.handlesVisible; + _selectionOverlay!.dispose(); + _selectionOverlay = _createSelectionOverlay(); + if (shouldShowToolbar || shouldShowHandles) { + SchedulerBinding.instance.addPostFrameCallback((Duration _) { + if (shouldShowToolbar) { + _selectionOverlay!.showToolbar(); + } + if (shouldShowHandles) { + _selectionOverlay!.showHandles(); + } + }); + } + } else if (widget.controller.selection != oldWidget.controller.selection) { + _selectionOverlay?.update(_value); + } + _selectionOverlay?.handlesVisible = widget.showSelectionHandles; + + if (widget.autofillClient != oldWidget.autofillClient) { + _currentAutofillScope + ?.unregister(oldWidget.autofillClient?.autofillId ?? autofillId); + _currentAutofillScope?.register(_effectiveAutofillClient); + } + + if (widget.focusNode != oldWidget.focusNode) { + oldWidget.focusNode.removeListener(_handleFocusChanged); + widget.focusNode.addListener(_handleFocusChanged); + updateKeepAlive(); + } + + if (!_shouldCreateInputConnection) { + _closeInputConnectionIfNeeded(); + } else if (oldWidget.readOnly && _hasFocus) { + // _openInputConnection must be called after layout information is available. + // See https://github.com/flutter/flutter/issues/126312 + SchedulerBinding.instance.addPostFrameCallback((Duration _) { + _openInputConnection(); + }, debugLabel: 'EditableText.openInputConnection'); + } + + if (kIsWeb && _hasInputConnection) { + if (oldWidget.readOnly != widget.readOnly) { + _textInputConnection! + .updateConfig(_effectiveAutofillClient.textInputConfiguration); + } + } + + if (_hasInputConnection) { + if (oldWidget.obscureText != widget.obscureText || + oldWidget.keyboardType != widget.keyboardType) { + _textInputConnection! + .updateConfig(_effectiveAutofillClient.textInputConfiguration); + } + } + + if (widget.style != oldWidget.style) { + // The _textInputConnection will pick up the new style when it attaches in + // _openInputConnection. + _style = MediaQuery.boldTextOf(context) + ? widget.style.merge(const TextStyle(fontWeight: FontWeight.bold)) + : widget.style; + if (_hasInputConnection) { + _textInputConnection!.setStyle( + fontFamily: _style.fontFamily, + fontSize: _style.fontSize, + fontWeight: _style.fontWeight, + textDirection: _textDirection, + textAlign: widget.textAlign, + ); + } + } + + if (widget.showCursor != oldWidget.showCursor) { + _startOrStopCursorTimerIfNeeded(); + } + final bool canPaste = + widget.selectionControls is TextSelectionHandleControls + ? pasteEnabled + : widget.selectionControls?.canPaste(this) ?? false; + if (widget.selectionEnabled && pasteEnabled && canPaste) { + clipboardStatus.update(); + } + } + + void _disposeScrollNotificationObserver() { + _listeningToScrollNotificationObserver = false; + if (_scrollNotificationObserver != null) { + _scrollNotificationObserver! + .removeListener(_handleContextMenuOnParentScroll); + _scrollNotificationObserver = null; + } + } + + @protected + @override + void dispose() { + _internalScrollController?.dispose(); + _currentAutofillScope?.unregister(autofillId); + widget.controller.removeListener(_didChangeTextEditingValue); + _floatingCursorResetController?.dispose(); + _floatingCursorResetController = null; + _closeInputConnectionIfNeeded(); + assert(!_hasInputConnection); + _cursorTimer?.cancel(); + _cursorTimer = null; + _backingCursorBlinkOpacityController?.dispose(); + _backingCursorBlinkOpacityController = null; + _selectionOverlay?.dispose(); + _selectionOverlay = null; + widget.focusNode.removeListener(_handleFocusChanged); + WidgetsBinding.instance.removeObserver(this); + _liveTextInputStatus?.removeListener(_onChangedLiveTextInputStatus); + _liveTextInputStatus?.dispose(); + clipboardStatus.removeListener(_onChangedClipboardStatus); + clipboardStatus.dispose(); + _cursorVisibilityNotifier.dispose(); + _appLifecycleListener.dispose(); + FocusManager.instance.removeListener(_unflagInternalFocus); + _disposeScrollNotificationObserver(); + super.dispose(); + assert(_batchEditDepth <= 0, 'unfinished batch edits: $_batchEditDepth'); + } + + // TextInputClient implementation: + + /// The last known [TextEditingValue] of the platform text input plugin. + /// + /// This value is updated when the platform text input plugin sends a new + /// update via [updateEditingValue], or when [EditableText] calls + /// [TextInputConnection.setEditingState] to overwrite the platform text input + /// plugin's [TextEditingValue]. + /// + /// Used in [_updateRemoteEditingValueIfNeeded] to determine whether the + /// remote value is outdated and needs updating. + TextEditingValue? _lastKnownRemoteTextEditingValue; + + @override + TextEditingValue get currentTextEditingValue => _value; + + @override + void updateEditingValue(TextEditingValue value) { + // This method handles text editing state updates from the platform text + // input plugin. The [EditableText] may not have the focus or an open input + // connection, as autofill can update a disconnected [EditableText]. + + // Since we still have to support keyboard select, this is the best place + // to disable text updating. + if (!_shouldCreateInputConnection) { + return; + } + + if (_checkNeedsAdjustAffinity(value)) { + value = value.copyWith( + selection: + value.selection.copyWith(affinity: _value.selection.affinity), + ); + } + + if (widget.readOnly) { + // In the read-only case, we only care about selection changes, and reject + // everything else. + value = _value.copyWith(selection: value.selection); + } + _lastKnownRemoteTextEditingValue = value; + + if (value == _value) { + // This is possible, for example, when the numeric keyboard is input, + // the engine will notify twice for the same value. + // Track at https://github.com/flutter/flutter/issues/65811 + return; + } + + if (value.text == _value.text && value.composing == _value.composing) { + // `selection` is the only change. + SelectionChangedCause cause; + if (_textInputConnection?.scribbleInProgress ?? false) { + cause = SelectionChangedCause.stylusHandwriting; + } else if (_pointOffsetOrigin != null) { + // For floating cursor selection when force pressing the space bar. + cause = SelectionChangedCause.forcePress; + } else { + cause = SelectionChangedCause.keyboard; + } + _handleSelectionChanged(value.selection, cause); + } else { + 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 && + widget.obscureText && + WidgetsBinding.instance.platformDispatcher.brieflyShowPassword && + value.text.length == _value.text.length + 1; + + _obscureShowCharTicksPending = + revealObscuredInput ? _kObscureShowLatestCharCursorTicks : 0; + _obscureLatestCharIndex = + revealObscuredInput ? _value.selection.baseOffset : null; + _formatAndSetValue(value, SelectionChangedCause.keyboard); + } + + if (_showBlinkingCursor && _cursorTimer != null) { + // To keep the cursor from blinking while typing, restart the timer here. + _stopCursorBlink(resetCharTicks: false); + _startCursorBlink(); + } + + // Wherever the value is changed by the user, schedule a showCaretOnScreen + // to make sure the user can see the changes they just made. Programmatic + // changes to `textEditingValue` do not trigger the behavior even if the + // text field is focused. + _scheduleShowCaretOnScreen(withAnimation: true); + } + + bool _checkNeedsAdjustAffinity(TextEditingValue value) { + // Trust the engine affinity if the text changes or selection changes. + return value.text == _value.text && + value.selection.isCollapsed == _value.selection.isCollapsed && + value.selection.start == _value.selection.start && + value.selection.affinity != _value.selection.affinity; + } + + @override + void performAction(TextInputAction action) { + switch (action) { + case TextInputAction.newline: + // If this is a multiline EditableText, do nothing for a "newline" + // action; The newline is already inserted. Otherwise, finalize + // editing. + if (!_isMultiline) { + _finalizeEditing(action, shouldUnfocus: true); + } + case TextInputAction.done: + case TextInputAction.go: + case TextInputAction.next: + case TextInputAction.previous: + case TextInputAction.search: + case TextInputAction.send: + _finalizeEditing(action, shouldUnfocus: true); + case TextInputAction.continueAction: + case TextInputAction.emergencyCall: + case TextInputAction.join: + case TextInputAction.none: + case TextInputAction.route: + case TextInputAction.unspecified: + // Finalize editing, but don't give up focus because this keyboard + // action does not imply the user is done inputting information. + _finalizeEditing(action, shouldUnfocus: false); + } + } + + static final _atUserRegex = RegExp(r'@[\u4e00-\u9fa5a-zA-Z\d_-]+ $'); + + @override + void updateEditingValueWithDeltas(List textEditingDeltas) { + TextEditingValue value = _value; + + var last = textEditingDeltas.lastOrNull; + if (last case TextEditingDeltaInsertion e) { + if (e.textInserted == '@') { + widget.onMention?.call(); + } + } else if (last case TextEditingDeltaDeletion e) { + if (e.textDeleted == ' ') { + final selection = _value.selection; + if (selection.isCollapsed) { + final text = _value.text; + final offset = selection.baseOffset; + + RegExpMatch? match = + _atUserRegex.firstMatch(text.substring(0, offset)); + + if (match != null) { + _lastKnownRemoteTextEditingValue = value; + _value = TextEditingDeltaDeletion( + oldText: e.oldText, + deletedRange: TextRange(start: match.start, end: match.end), + selection: TextSelection.collapsed(offset: match.start), + composing: e.composing, + ).apply(value); + widget.onChanged?.call(_value.text); + return; + } + } + } + } + + for (final TextEditingDelta delta in textEditingDeltas) { + value = delta.apply(value); + } + + _lastKnownRemoteTextEditingValue = value; + + if (value == _value) { + // This is possible, for example, when the numeric keyboard is input, + // the engine will notify twice for the same value. + // Track at https://github.com/flutter/flutter/issues/65811 + return; + } + + _value = value; + + widget.onChanged?.call(value.text); + } + + @override + void performPrivateCommand(String action, Map data) { + widget.onAppPrivateCommand?.call(action, data); + } + + @override + void insertContent(KeyboardInsertedContent content) { + assert( + widget.contentInsertionConfiguration?.allowedMimeTypes + .contains(content.mimeType) ?? + false, + ); + widget.contentInsertionConfiguration?.onContentInserted.call(content); + } + + // The original position of the caret on FloatingCursorDragState.start. + Offset? _startCaretCenter; + + // The most recent text position as determined by the location of the floating + // cursor. + TextPosition? _lastTextPosition; + + // The offset of the floating cursor as determined from the start call. + Offset? _pointOffsetOrigin; + + // The most recent position of the floating cursor. + Offset? _lastBoundedOffset; + + // Because the center of the cursor is preferredLineHeight / 2 below the touch + // origin, but the touch origin is used to determine which line the cursor is + // on, we need this offset to correctly render and move the cursor. + Offset get _floatingCursorOffset => + Offset(0, renderEditable.preferredLineHeight / 2); + + @override + void updateFloatingCursor(RawFloatingCursorPoint point) { + _floatingCursorResetController ??= AnimationController(vsync: this) + ..addListener(_onFloatingCursorResetTick); + switch (point.state) { + case FloatingCursorDragState.Start: + if (_floatingCursorResetController!.isAnimating) { + _floatingCursorResetController!.stop(); + _onFloatingCursorResetTick(); + } + // Stop cursor blinking and making it visible. + _stopCursorBlink(resetCharTicks: false); + _cursorBlinkOpacityController.value = 1.0; + // We want to send in points that are centered around a (0,0) origin, so + // we cache the position. + _pointOffsetOrigin = point.offset; + + final Offset startCaretCenter; + final TextPosition currentTextPosition; + final bool shouldResetOrigin; + // Only non-null when starting a floating cursor via long press. + if (point.startLocation != null) { + shouldResetOrigin = false; + (startCaretCenter, currentTextPosition) = point.startLocation!; + } else { + shouldResetOrigin = true; + currentTextPosition = TextPosition( + offset: renderEditable.selection!.baseOffset, + affinity: renderEditable.selection!.affinity, + ); + startCaretCenter = + renderEditable.getLocalRectForCaret(currentTextPosition).center; + } + + _startCaretCenter = startCaretCenter; + _lastBoundedOffset = + renderEditable.calculateBoundedFloatingCursorOffset( + _startCaretCenter! - _floatingCursorOffset, + shouldResetOrigin: shouldResetOrigin, + ); + _lastTextPosition = currentTextPosition; + renderEditable.setFloatingCursor( + point.state, _lastBoundedOffset!, _lastTextPosition!); + case FloatingCursorDragState.Update: + final Offset centeredPoint = point.offset! - _pointOffsetOrigin!; + final Offset rawCursorOffset = + _startCaretCenter! + centeredPoint - _floatingCursorOffset; + + _lastBoundedOffset = renderEditable + .calculateBoundedFloatingCursorOffset(rawCursorOffset); + _lastTextPosition = renderEditable.getPositionForPoint( + renderEditable + .localToGlobal(_lastBoundedOffset! + _floatingCursorOffset), + ); + renderEditable.setFloatingCursor( + point.state, _lastBoundedOffset!, _lastTextPosition!); + case FloatingCursorDragState.End: + // Resume cursor blinking. + _startCursorBlink(); + // We skip animation if no update has happened. + if (_lastTextPosition != null && _lastBoundedOffset != null) { + _floatingCursorResetController!.value = 0.0; + _floatingCursorResetController!.animateTo( + 1.0, + duration: _floatingCursorResetTime, + curve: Curves.decelerate, + ); + } + } + } + + void _onFloatingCursorResetTick() { + final Offset finalPosition = + renderEditable.getLocalRectForCaret(_lastTextPosition!).centerLeft - + _floatingCursorOffset; + if (_floatingCursorResetController!.isCompleted) { + renderEditable.setFloatingCursor( + FloatingCursorDragState.End, + finalPosition, + _lastTextPosition!, + ); + // During a floating cursor's move gesture (1 finger), a cursor is + // animated only visually, without actually updating the selection. + // Only after move gesture is complete, this function will be called + // to actually update the selection to the new cursor location with + // zero selection length. + + // However, During a floating cursor's selection gesture (2 fingers), the + // selection is constantly updated by the engine throughout the gesture. + // Thus when the gesture is complete, we should not update the selection + // to the cursor location with zero selection length, because that would + // overwrite the selection made by floating cursor selection. + + // Here we use `isCollapsed` to distinguish between floating cursor's + // move gesture (1 finger) vs selection gesture (2 fingers), as + // the engine does not provide information other than notifying a + // new selection during with selection gesture (2 fingers). + if (renderEditable.selection!.isCollapsed) { + // The cause is technically the force cursor, but the cause is listed as tap as the desired functionality is the same. + _handleSelectionChanged( + TextSelection.fromPosition(_lastTextPosition!), + SelectionChangedCause.forcePress, + ); + } + _startCaretCenter = null; + _lastTextPosition = null; + _pointOffsetOrigin = null; + _lastBoundedOffset = null; + } else { + final double lerpValue = _floatingCursorResetController!.value; + final double lerpX = + ui.lerpDouble(_lastBoundedOffset!.dx, finalPosition.dx, lerpValue)!; + final double lerpY = + ui.lerpDouble(_lastBoundedOffset!.dy, finalPosition.dy, lerpValue)!; + + renderEditable.setFloatingCursor( + FloatingCursorDragState.Update, + Offset(lerpX, lerpY), + _lastTextPosition!, + resetLerpValue: lerpValue, + ); + } + } + + @pragma('vm:notify-debugger-on-exception') + void _finalizeEditing(TextInputAction action, {required bool shouldUnfocus}) { + // Take any actions necessary now that the user has completed editing. + if (widget.onEditingComplete != null) { + try { + widget.onEditingComplete!(); + } catch (exception, stack) { + FlutterError.reportError( + FlutterErrorDetails( + exception: exception, + stack: stack, + library: 'widgets', + context: + ErrorDescription('while calling onEditingComplete for $action'), + ), + ); + } + } else { + // Default behavior if the developer did not provide an + // onEditingComplete callback: Finalize editing and remove focus, or move + // it to the next/previous field, depending on the action. + widget.controller.clearComposing(); + if (shouldUnfocus) { + switch (action) { + case TextInputAction.none: + case TextInputAction.unspecified: + case TextInputAction.done: + case TextInputAction.go: + case TextInputAction.search: + case TextInputAction.send: + case TextInputAction.continueAction: + case TextInputAction.join: + case TextInputAction.route: + case TextInputAction.emergencyCall: + case TextInputAction.newline: + widget.focusNode.unfocus(); + case TextInputAction.next: + widget.focusNode.nextFocus(); + case TextInputAction.previous: + widget.focusNode.previousFocus(); + } + } + } + + final ValueChanged? onSubmitted = widget.onSubmitted; + if (onSubmitted == null) { + return; + } + + // Invoke optional callback with the user's submitted content. + try { + onSubmitted(_value.text); + } catch (exception, stack) { + FlutterError.reportError( + FlutterErrorDetails( + exception: exception, + stack: stack, + library: 'widgets', + context: ErrorDescription('while calling onSubmitted for $action'), + ), + ); + } + + // If `shouldUnfocus` is true, the text field should no longer be focused + // after the microtask queue is drained. But in case the developer cancelled + // the focus change in the `onSubmitted` callback by focusing this input + // field again, reset the soft keyboard. + // See https://github.com/flutter/flutter/issues/84240. + // + // `_restartConnectionIfNeeded` creates a new TextInputConnection to replace + // the current one. This on iOS switches to a new input view and on Android + // restarts the input method, and in both cases the soft keyboard will be + // reset. + if (shouldUnfocus) { + _scheduleRestartConnection(); + } + } + + int _batchEditDepth = 0; + + /// Begins a new batch edit, within which new updates made to the text editing + /// value will not be sent to the platform text input plugin. + /// + /// Batch edits nest. When the outermost batch edit finishes, [endBatchEdit] + /// will attempt to send [currentTextEditingValue] to the text input plugin if + /// it detected a change. + void beginBatchEdit() { + _batchEditDepth += 1; + } + + /// Ends the current batch edit started by the last call to [beginBatchEdit], + /// and send [currentTextEditingValue] to the text input plugin if needed. + /// + /// Throws an error in debug mode if this [EditableText] is not in a batch + /// edit. + void endBatchEdit() { + _batchEditDepth -= 1; + assert( + _batchEditDepth >= 0, + 'Unbalanced call to endBatchEdit: beginBatchEdit must be called first.', + ); + _updateRemoteEditingValueIfNeeded(); + } + + void _updateRemoteEditingValueIfNeeded() { + if (_batchEditDepth > 0 || !_hasInputConnection) { + return; + } + final TextEditingValue localValue = _value; + if (localValue == _lastKnownRemoteTextEditingValue) { + return; + } + _textInputConnection!.setEditingState(localValue); + _lastKnownRemoteTextEditingValue = localValue; + } + + TextEditingValue get _value => widget.controller.value; + set _value(TextEditingValue value) { + widget.controller.value = value; + } + + bool get _hasFocus => widget.focusNode.hasFocus; + bool get _isMultiline => widget.maxLines != 1; + + /// Flag to track whether this [EditableText] was in focus when [onTapOutside] + /// was called. + /// + /// This is used to determine whether [onTapUpOutside] should be called. + /// The reason [_hasFocus] can't be used directly is because [onTapOutside] + /// might unfocus this [EditableText] and block the [onTapUpOutside] call. + bool _hadFocusOnTapDown = false; + + // Finds the closest scroll offset to the current scroll offset that fully + // reveals the given caret rect. If the given rect's main axis extent is too + // large to be fully revealed in `renderEditable`, it will be centered along + // the main axis. + // + // If this is a multiline EditableText (which means the Editable can only + // scroll vertically), the given rect's height will first be extended to match + // `renderEditable.preferredLineHeight`, before the target scroll offset is + // calculated. + RevealedOffset _getOffsetToRevealCaret(Rect rect) { + if (!_scrollController.position.allowImplicitScrolling) { + return RevealedOffset(offset: _scrollController.offset, rect: rect); + } + + final Size editableSize = renderEditable.size; + final double additionalOffset; + final Offset unitOffset; + + if (!_isMultiline) { + additionalOffset = rect.width >= editableSize.width + // Center `rect` if it's oversized. + ? editableSize.width / 2 - rect.center.dx + // Valid additional offsets range from (rect.right - size.width) + // to (rect.left). Pick the closest one if out of range. + : clampDouble(0.0, rect.right - editableSize.width, rect.left); + unitOffset = const Offset(1, 0); + } else { + // The caret is vertically centered within the line. Expand the caret's + // height so that it spans the line because we're going to ensure that the + // entire expanded caret is scrolled into view. + final Rect expandedRect = Rect.fromCenter( + center: rect.center, + width: rect.width, + height: math.max(rect.height, renderEditable.preferredLineHeight), + ); + + additionalOffset = expandedRect.height >= editableSize.height + ? editableSize.height / 2 - expandedRect.center.dy + : clampDouble( + 0.0, expandedRect.bottom - editableSize.height, expandedRect.top); + unitOffset = const Offset(0, 1); + } + + // No overscrolling when encountering tall fonts/scripts that extend past + // the ascent. + final double targetOffset = clampDouble( + additionalOffset + _scrollController.offset, + _scrollController.position.minScrollExtent, + _scrollController.position.maxScrollExtent, + ); + + final double offsetDelta = _scrollController.offset - targetOffset; + return RevealedOffset( + rect: rect.shift(unitOffset * offsetDelta), offset: targetOffset); + } + + /// Whether to send the autofill information to the autofill service. True by + /// default. + bool get _needsAutofill => _effectiveAutofillClient + .textInputConfiguration.autofillConfiguration.enabled; + + // Must be called after layout. + // See https://github.com/flutter/flutter/issues/126312 + void _openInputConnection() { + if (!_shouldCreateInputConnection) { + return; + } + if (!_hasInputConnection) { + final TextEditingValue localValue = _value; + + // When _needsAutofill == true && currentAutofillScope == null, autofill + // is allowed but saving the user input from the text field is + // discouraged. + // + // In case the autofillScope changes from a non-null value to null, or + // _needsAutofill changes to false from true, the platform needs to be + // notified to exclude this field from the autofill context. So we need to + // provide the autofillId. + _textInputConnection = _needsAutofill && currentAutofillScope != null + ? currentAutofillScope! + .attach(this, _effectiveAutofillClient.textInputConfiguration) + : TextInput.attach( + this, _effectiveAutofillClient.textInputConfiguration); + _updateSizeAndTransform(); + _schedulePeriodicPostFrameCallbacks(); + _textInputConnection! + ..setStyle( + fontFamily: _style.fontFamily, + fontSize: _style.fontSize, + fontWeight: _style.fontWeight, + textDirection: _textDirection, + textAlign: widget.textAlign, + ) + ..setEditingState(localValue) + ..show(); + if (_needsAutofill) { + // Request autofill AFTER the size and the transform have been sent to + // the platform text input plugin. + _textInputConnection!.requestAutofill(); + } + _lastKnownRemoteTextEditingValue = localValue; + } else { + _textInputConnection!.show(); + } + } + + void _closeInputConnectionIfNeeded() { + if (_hasInputConnection) { + _textInputConnection!.close(); + _textInputConnection = null; + _lastKnownRemoteTextEditingValue = null; + _scribbleCacheKey = null; + removeTextPlaceholder(); + } + } + + void _openOrCloseInputConnectionIfNeeded() { + if (_hasFocus && widget.focusNode.consumeKeyboardToken()) { + _openInputConnection(); + } else if (!_hasFocus) { + _closeInputConnectionIfNeeded(); + widget.controller.clearComposing(); + } + } + + bool _restartConnectionScheduled = false; + void _scheduleRestartConnection() { + if (_restartConnectionScheduled) { + return; + } + _restartConnectionScheduled = true; + scheduleMicrotask(_restartConnectionIfNeeded); + } + + // Discards the current [TextInputConnection] and establishes a new one. + // + // This method is rarely needed. This is currently used to reset the input + // type when the "submit" text input action is triggered and the developer + // puts the focus back to this input field.. + void _restartConnectionIfNeeded() { + _restartConnectionScheduled = false; + if (!_hasInputConnection || !_shouldCreateInputConnection) { + return; + } + _textInputConnection!.close(); + _textInputConnection = null; + _lastKnownRemoteTextEditingValue = null; + + final AutofillScope? currentAutofillScope = + _needsAutofill ? this.currentAutofillScope : null; + final TextInputConnection newConnection = currentAutofillScope?.attach( + this, textInputConfiguration) ?? + TextInput.attach(this, _effectiveAutofillClient.textInputConfiguration); + _textInputConnection = newConnection; + + newConnection + ..show() + ..setStyle( + fontFamily: _style.fontFamily, + fontSize: _style.fontSize, + fontWeight: _style.fontWeight, + textDirection: _textDirection, + textAlign: widget.textAlign, + ) + ..setEditingState(_value); + _lastKnownRemoteTextEditingValue = _value; + } + + @override + void didChangeInputControl( + TextInputControl? oldControl, TextInputControl? newControl) { + if (_hasFocus && _hasInputConnection) { + oldControl?.hide(); + newControl?.show(); + } + } + + @override + void connectionClosed() { + if (_hasInputConnection) { + _textInputConnection!.connectionClosedReceived(); + _textInputConnection = null; + _lastKnownRemoteTextEditingValue = null; + widget.focusNode.unfocus(); + } + } + + // Indicates that a call to _handleFocusChanged originated within + // EditableText, allowing it to distinguish between internal and external + // focus changes. + bool _nextFocusChangeIsInternal = false; + + // Sets _nextFocusChangeIsInternal to true only until any subsequent focus + // change happens. + void _flagInternalFocus() { + _nextFocusChangeIsInternal = true; + FocusManager.instance.addListener(_unflagInternalFocus); + } + + void _unflagInternalFocus() { + _nextFocusChangeIsInternal = false; + FocusManager.instance.removeListener(_unflagInternalFocus); + } + + /// Express interest in interacting with the keyboard. + /// + /// If this control is already attached to the keyboard, this function will + /// request that the keyboard become visible. Otherwise, this function will + /// ask the focus system that it become focused. If successful in acquiring + /// focus, the control will then attach to the keyboard and request that the + /// keyboard become visible. + void requestKeyboard() { + if (_hasFocus) { + _openInputConnection(); + } else { + _flagInternalFocus(); + widget.focusNode + .requestFocus(); // This eventually calls _openInputConnection also, see _handleFocusChanged. + } + } + + void _updateOrDisposeSelectionOverlayIfNeeded() { + if (_selectionOverlay != null) { + if (_hasFocus) { + _selectionOverlay!.update(_value); + } else { + _selectionOverlay!.dispose(); + _selectionOverlay = null; + } + } + } + + final bool _platformSupportsFadeOnScroll = switch (defaultTargetPlatform) { + TargetPlatform.android || TargetPlatform.iOS => true, + TargetPlatform.fuchsia || + TargetPlatform.linux || + TargetPlatform.macOS || + TargetPlatform.windows => + false, + }; + + bool _isInternalScrollableNotification(BuildContext? notificationContext) { + final ScrollableState? scrollableState = + notificationContext?.findAncestorStateOfType(); + return _scrollableKey.currentContext == scrollableState?.context; + } + + bool _scrollableNotificationIsFromSameSubtree( + BuildContext? notificationContext) { + if (notificationContext == null) { + return false; + } + BuildContext? currentContext = context; + // The notification context of a ScrollNotification points to the RawGestureDetector + // of the Scrollable. We get the ScrollableState associated with this notification + // by looking up the tree. + final ScrollableState? notificationScrollableState = + notificationContext.findAncestorStateOfType(); + if (notificationScrollableState == null) { + return false; + } + while (currentContext != null) { + final ScrollableState? scrollableState = + currentContext.findAncestorStateOfType(); + if (scrollableState == notificationScrollableState) { + return true; + } + currentContext = scrollableState?.context; + } + return false; + } + + void _handleContextMenuOnParentScroll(ScrollNotification notification) { + // Do some preliminary checks to avoid expensive subtree traversal. + if (notification is! ScrollStartNotification && + notification is! ScrollEndNotification) { + return; + } + switch (notification) { + case ScrollStartNotification() when _dataWhenToolbarShowScheduled != null: + case ScrollEndNotification() when _dataWhenToolbarShowScheduled == null: + break; + case ScrollEndNotification() + when _dataWhenToolbarShowScheduled!.value != _value: + _dataWhenToolbarShowScheduled = null; + _disposeScrollNotificationObserver(); + case ScrollNotification(:final BuildContext? context) + when !_isInternalScrollableNotification(context) && + _scrollableNotificationIsFromSameSubtree(context): + _handleContextMenuOnScroll(notification); + } + } + + Rect _calculateDeviceRect() { + final Size screenSize = MediaQuery.sizeOf(context); + final ui.FlutterView view = View.of(context); + final double obscuredVertical = + (view.padding.top + view.padding.bottom + view.viewInsets.bottom) / + view.devicePixelRatio; + final double obscuredHorizontal = + (view.padding.left + view.padding.right) / view.devicePixelRatio; + final Size visibleScreenSize = Size( + screenSize.width - obscuredHorizontal, + screenSize.height - obscuredVertical, + ); + return Rect.fromLTWH( + view.padding.left / view.devicePixelRatio, + view.padding.top / view.devicePixelRatio, + visibleScreenSize.width, + visibleScreenSize.height, + ); + } + + bool _showToolbarOnScreenScheduled = false; + void _handleContextMenuOnScroll(ScrollNotification notification) { + if (_webContextMenuEnabled) { + return; + } + if (!_platformSupportsFadeOnScroll) { + _selectionOverlay?.updateForScroll(); + return; + } + // When the scroll begins and the toolbar is visible, hide it + // until scrolling ends. + // + // The selection and renderEditable need to be visible within the current + // viewport for the toolbar to show when scrolling ends. If they are not + // then the toolbar is shown when they are scrolled back into view, unless + // invalidated by a change in TextEditingValue. + if (notification is ScrollStartNotification) { + if (_dataWhenToolbarShowScheduled != null) { + return; + } + final bool toolbarIsVisible = _selectionOverlay != null && + _selectionOverlay!.toolbarIsVisible && + !_selectionOverlay!.spellCheckToolbarIsVisible; + if (!toolbarIsVisible) { + return; + } + final List selectionBoxes = + renderEditable.getBoxesForSelection(_value.selection); + final Rect selectionBounds = _value.selection.isCollapsed || + selectionBoxes.isEmpty + ? renderEditable.getLocalRectForCaret(_value.selection.extent) + : selectionBoxes + .map((TextBox box) => box.toRect()) + .reduce((Rect result, Rect rect) => result.expandToInclude(rect)); + _dataWhenToolbarShowScheduled = + (value: _value, selectionBounds: selectionBounds); + _selectionOverlay?.hideToolbar(); + } else if (notification is ScrollEndNotification) { + if (_dataWhenToolbarShowScheduled == null) { + return; + } + if (_dataWhenToolbarShowScheduled!.value != _value) { + // Value has changed so we should invalidate any toolbar scheduling. + _dataWhenToolbarShowScheduled = null; + _disposeScrollNotificationObserver(); + return; + } + + if (_showToolbarOnScreenScheduled) { + return; + } + _showToolbarOnScreenScheduled = true; + SchedulerBinding.instance.addPostFrameCallback((Duration _) { + _showToolbarOnScreenScheduled = false; + if (!mounted) { + return; + } + final Rect deviceRect = _calculateDeviceRect(); + final bool selectionVisibleInEditable = + renderEditable.selectionStartInViewport.value || + renderEditable.selectionEndInViewport.value; + final Rect selectionBounds = MatrixUtils.transformRect( + renderEditable.getTransformTo(null), + _dataWhenToolbarShowScheduled!.selectionBounds, + ); + final bool selectionOverlapsWithDeviceRect = + !selectionBounds.hasNaN && deviceRect.overlaps(selectionBounds); + + if (selectionVisibleInEditable && + selectionOverlapsWithDeviceRect && + _selectionInViewport( + _dataWhenToolbarShowScheduled!.selectionBounds)) { + showToolbar(); + _dataWhenToolbarShowScheduled = null; + } + }, debugLabel: 'EditableText.scheduleToolbar'); + } + } + + bool _selectionInViewport(Rect selectionBounds) { + RenderAbstractViewport? closestViewport = + RenderAbstractViewport.maybeOf(renderEditable); + while (closestViewport != null) { + final Rect selectionBoundsLocalToViewport = MatrixUtils.transformRect( + renderEditable.getTransformTo(closestViewport), + selectionBounds, + ); + if (selectionBoundsLocalToViewport.hasNaN || + closestViewport.paintBounds.hasNaN || + !closestViewport.paintBounds + .overlaps(selectionBoundsLocalToViewport)) { + return false; + } + closestViewport = RenderAbstractViewport.maybeOf(closestViewport.parent); + } + return true; + } + + TextSelectionOverlay _createSelectionOverlay() { + final EditableTextContextMenuBuilder? contextMenuBuilder = + widget.contextMenuBuilder; + final TextSelectionOverlay selectionOverlay = TextSelectionOverlay( + clipboardStatus: clipboardStatus, + context: context, + value: _value, + debugRequiredFor: widget, + toolbarLayerLink: _toolbarLayerLink, + startHandleLayerLink: _startHandleLayerLink, + endHandleLayerLink: _endHandleLayerLink, + renderObject: renderEditable, + selectionControls: widget.selectionControls, + selectionDelegate: this, + dragStartBehavior: widget.dragStartBehavior, + onSelectionHandleTapped: widget.onSelectionHandleTapped, + contextMenuBuilder: contextMenuBuilder == null || _webContextMenuEnabled + ? null + : (BuildContext context) { + return contextMenuBuilder(context, this); + }, + magnifierConfiguration: widget.magnifierConfiguration, + ); + + return selectionOverlay; + } + + @pragma('vm:notify-debugger-on-exception') + void _handleSelectionChanged( + TextSelection selection, SelectionChangedCause? cause) { + // We return early if the selection is not valid. This can happen when the + // text of [EditableText] is updated at the same time as the selection is + // changed by a gesture event. + final String text = widget.controller.value.text; + if (text.length < selection.end || text.length < selection.start) { + return; + } + + widget.controller.selection = selection; + + // This will show the keyboard for all selection changes on the + // EditableText except for those triggered by a keyboard input. + // Typically EditableText shouldn't take user keyboard input if + // it's not focused already. If the EditableText is being + // autofilled it shouldn't request focus. + switch (cause) { + case null: + case SelectionChangedCause.doubleTap: + case SelectionChangedCause.drag: + case SelectionChangedCause.forcePress: + case SelectionChangedCause.longPress: + case SelectionChangedCause.stylusHandwriting: + case SelectionChangedCause.tap: + case SelectionChangedCause.toolbar: + requestKeyboard(); + case SelectionChangedCause.keyboard: + } + if (widget.selectionControls == null && widget.contextMenuBuilder == null) { + _selectionOverlay?.dispose(); + _selectionOverlay = null; + } else { + if (_selectionOverlay == null) { + _selectionOverlay = _createSelectionOverlay(); + } else { + _selectionOverlay!.update(_value); + } + _selectionOverlay!.handlesVisible = widget.showSelectionHandles; + _selectionOverlay!.showHandles(); + } + // TODO(chunhtai): we should make sure selection actually changed before + // we call the onSelectionChanged. + // https://github.com/flutter/flutter/issues/76349. + try { + widget.onSelectionChanged?.call(selection, cause); + } catch (exception, stack) { + FlutterError.reportError( + FlutterErrorDetails( + exception: exception, + stack: stack, + library: 'widgets', + context: + ErrorDescription('while calling onSelectionChanged for $cause'), + ), + ); + } + + // To keep the cursor from blinking while it moves, restart the timer here. + if (_showBlinkingCursor && _cursorTimer != null) { + _stopCursorBlink(resetCharTicks: false); + _startCursorBlink(); + } + } + + // Animation configuration for scrolling the caret back on screen. + static const Duration _caretAnimationDuration = Duration(milliseconds: 100); + static const Curve _caretAnimationCurve = Curves.fastOutSlowIn; + + bool _showCaretOnScreenScheduled = false; + + void _scheduleShowCaretOnScreen({required bool withAnimation}) { + if (_showCaretOnScreenScheduled) { + return; + } + _showCaretOnScreenScheduled = true; + SchedulerBinding.instance.addPostFrameCallback((Duration _) { + _showCaretOnScreenScheduled = false; + // Since we are in a post frame callback, check currentContext in case + // RenderEditable has been disposed (in which case it will be null). + final RenderEditable? renderEditable = + _editableKey.currentContext?.findRenderObject() as RenderEditable?; + if (renderEditable == null || + !(renderEditable.selection?.isValid ?? false) || + !_scrollController.hasClients) { + return; + } + + final double lineHeight = renderEditable.preferredLineHeight; + + // Enlarge the target rect by scrollPadding to ensure that caret is not + // positioned directly at the edge after scrolling. + double bottomSpacing = widget.scrollPadding.bottom; + if (_selectionOverlay?.selectionControls != null) { + final double handleHeight = _selectionOverlay!.selectionControls! + .getHandleSize(lineHeight) + .height; + final double interactiveHandleHeight = + math.max(handleHeight, kMinInteractiveDimension); + final Offset anchor = + _selectionOverlay!.selectionControls!.getHandleAnchor( + TextSelectionHandleType.collapsed, + lineHeight, + ); + final double handleCenter = handleHeight / 2 - anchor.dy; + bottomSpacing = + math.max(handleCenter + interactiveHandleHeight / 2, bottomSpacing); + } + + final EdgeInsets caretPadding = + widget.scrollPadding.copyWith(bottom: bottomSpacing); + + final Rect caretRect = + renderEditable.getLocalRectForCaret(renderEditable.selection!.extent); + final RevealedOffset targetOffset = _getOffsetToRevealCaret(caretRect); + + final Rect rectToReveal; + final TextSelection selection = textEditingValue.selection; + if (selection.isCollapsed) { + rectToReveal = targetOffset.rect; + } else { + final List selectionBoxes = + renderEditable.getBoxesForSelection(selection); + // selectionBoxes may be empty if, for example, the selection does not + // encompass a full character, like if it only contained part of an + // extended grapheme cluster. + if (selectionBoxes.isEmpty) { + rectToReveal = targetOffset.rect; + } else { + rectToReveal = selection.baseOffset < selection.extentOffset + ? selectionBoxes.last.toRect() + : selectionBoxes.first.toRect(); + } + } + + if (withAnimation) { + _scrollController.animateTo( + targetOffset.offset, + duration: _caretAnimationDuration, + curve: _caretAnimationCurve, + ); + renderEditable.showOnScreen( + rect: caretPadding.inflateRect(rectToReveal), + duration: _caretAnimationDuration, + curve: _caretAnimationCurve, + ); + } else { + _scrollController.jumpTo(targetOffset.offset); + renderEditable.showOnScreen( + rect: caretPadding.inflateRect(rectToReveal)); + } + }, debugLabel: 'EditableText.showCaret'); + } + + late double _lastBottomViewInset; + + @override + void didChangeMetrics() { + if (!mounted) { + return; + } + final ui.FlutterView view = View.of(context); + if (_lastBottomViewInset != view.viewInsets.bottom) { + SchedulerBinding.instance.addPostFrameCallback((Duration _) { + _selectionOverlay?.updateForScroll(); + }, debugLabel: 'EditableText.updateForScroll'); + if (_lastBottomViewInset < view.viewInsets.bottom) { + // Because the metrics change signal from engine will come here every frame + // (on both iOS and Android). So we don't need to show caret with animation. + _scheduleShowCaretOnScreen(withAnimation: false); + } + } + _lastBottomViewInset = view.viewInsets.bottom; + } + + Future _performSpellCheck(final String text) async { + try { + final Locale? localeForSpellChecking = + widget.locale ?? Localizations.maybeLocaleOf(context); + + assert( + localeForSpellChecking != null, + 'Locale must be specified in widget or Localization widget must be in scope', + ); + + final List? suggestions = await _spellCheckConfiguration + .spellCheckService! + .fetchSpellCheckSuggestions(localeForSpellChecking!, text); + + if (suggestions == null) { + // The request to fetch spell check suggestions was canceled due to ongoing request. + return; + } + + spellCheckResults = SpellCheckResults(text, suggestions); + renderEditable.text = buildTextSpan(); + } catch (exception, stack) { + FlutterError.reportError( + FlutterErrorDetails( + exception: exception, + stack: stack, + library: 'widgets', + context: ErrorDescription('while performing spell check'), + ), + ); + } + } + + @pragma('vm:notify-debugger-on-exception') + void _formatAndSetValue( + TextEditingValue value, + SelectionChangedCause? cause, { + bool userInteraction = false, + }) { + final TextEditingValue oldValue = _value; + final bool textChanged = oldValue.text != value.text; + final bool textCommitted = + !oldValue.composing.isCollapsed && value.composing.isCollapsed; + final bool selectionChanged = oldValue.selection != value.selection; + + if (textChanged || textCommitted) { + // Only apply input formatters if the text has changed (including uncommitted + // text in the composing region), or when the user committed the composing + // text. + // Gboard is very persistent in restoring the composing region. Applying + // input formatters on composing-region-only changes (except clearing the + // current composing region) is very infinite-loop-prone: the formatters + // will keep trying to modify the composing region while Gboard will keep + // trying to restore the original composing region. + try { + value = widget.inputFormatters?.fold( + value, + (TextEditingValue newValue, TextInputFormatter formatter) => + formatter.formatEditUpdate(_value, newValue), + ) ?? + value; + + if (spellCheckEnabled && + value.text.isNotEmpty && + _value.text != value.text) { + _performSpellCheck(value.text); + } + } catch (exception, stack) { + FlutterError.reportError( + FlutterErrorDetails( + exception: exception, + stack: stack, + library: 'widgets', + context: ErrorDescription('while applying input formatters'), + ), + ); + } + } + + final TextSelection oldTextSelection = textEditingValue.selection; + + // Put all optional user callback invocations in a batch edit to prevent + // sending multiple `TextInput.updateEditingValue` messages. + beginBatchEdit(); + _value = value; + // Changes made by the keyboard can sometimes be "out of band" for listening + // components, so always send those events, even if we didn't think it + // changed. Also, the user long pressing should always send a selection change + // as well. + if (selectionChanged || + (userInteraction && + (cause == SelectionChangedCause.longPress || + cause == SelectionChangedCause.keyboard))) { + _handleSelectionChanged(_value.selection, cause); + _bringIntoViewBySelectionState(oldTextSelection, value.selection, cause); + } + final String currentText = _value.text; + if (oldValue.text != currentText) { + try { + widget.onChanged?.call(currentText); + } catch (exception, stack) { + FlutterError.reportError( + FlutterErrorDetails( + exception: exception, + stack: stack, + library: 'widgets', + context: ErrorDescription('while calling onChanged'), + ), + ); + } + } + endBatchEdit(); + } + + void _bringIntoViewBySelectionState( + TextSelection oldSelection, + TextSelection newSelection, + SelectionChangedCause? cause, + ) { + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + if (cause == SelectionChangedCause.longPress || + cause == SelectionChangedCause.drag) { + bringIntoView(newSelection.extent); + } + case TargetPlatform.linux: + case TargetPlatform.windows: + case TargetPlatform.fuchsia: + case TargetPlatform.android: + if (cause == SelectionChangedCause.drag) { + if (oldSelection.baseOffset != newSelection.baseOffset) { + bringIntoView(newSelection.base); + } else if (oldSelection.extentOffset != newSelection.extentOffset) { + bringIntoView(newSelection.extent); + } + } + } + } + + void _onCursorColorTick() { + final double effectiveOpacity = math.min( + widget.cursorColor.alpha / 255.0, + _cursorBlinkOpacityController.value, + ); + renderEditable.cursorColor = + widget.cursorColor.withOpacity(effectiveOpacity); + _cursorVisibilityNotifier.value = widget.showCursor && + (EditableText.debugDeterministicCursor || + _cursorBlinkOpacityController.value > 0); + } + + bool get _showBlinkingCursor => + _hasFocus && + _value.selection.isCollapsed && + widget.showCursor && + _tickersEnabled && + !renderEditable.floatingCursorOn; + + /// Whether the blinking cursor is actually visible at this precise moment + /// (it's hidden half the time, since it blinks). + @visibleForTesting + bool get cursorCurrentlyVisible => _cursorBlinkOpacityController.value > 0; + + /// The cursor blink interval (the amount of time the cursor is in the "on" + /// state or the "off" state). A complete cursor blink period is twice this + /// value (half on, half off). + @visibleForTesting + Duration get cursorBlinkInterval => _kCursorBlinkHalfPeriod; + + /// The current status of the text selection handles. + @visibleForTesting + TextSelectionOverlay? get selectionOverlay => _selectionOverlay; + + int _obscureShowCharTicksPending = 0; + int? _obscureLatestCharIndex; + + void _startCursorBlink() { + assert( + !(_cursorTimer?.isActive ?? false) || + !(_backingCursorBlinkOpacityController?.isAnimating ?? false), + ); + if (!widget.showCursor) { + return; + } + if (!_tickersEnabled) { + return; + } + _cursorTimer?.cancel(); + _cursorBlinkOpacityController.value = 1.0; + if (EditableText.debugDeterministicCursor) { + return; + } + if (widget.cursorOpacityAnimates) { + _cursorBlinkOpacityController + .animateWith(_iosBlinkCursorSimulation) + .whenComplete(_onCursorTick); + } else { + _cursorTimer = Timer.periodic(_kCursorBlinkHalfPeriod, (Timer timer) { + _onCursorTick(); + }); + } + } + + void _onCursorTick() { + if (_obscureShowCharTicksPending > 0) { + _obscureShowCharTicksPending = + WidgetsBinding.instance.platformDispatcher.brieflyShowPassword + ? _obscureShowCharTicksPending - 1 + : 0; + if (_obscureShowCharTicksPending == 0) { + setState(() {}); + } + } + + if (widget.cursorOpacityAnimates) { + _cursorTimer?.cancel(); + // Schedule this as an async task to avoid blocking tester.pumpAndSettle + // indefinitely. + _cursorTimer = Timer( + Duration.zero, + () => _cursorBlinkOpacityController + .animateWith(_iosBlinkCursorSimulation) + .whenComplete(_onCursorTick), + ); + } else { + if (!(_cursorTimer?.isActive ?? false) && _tickersEnabled) { + _cursorTimer = Timer.periodic(_kCursorBlinkHalfPeriod, (Timer timer) { + _onCursorTick(); + }); + } + _cursorBlinkOpacityController.value = + _cursorBlinkOpacityController.value == 0 ? 1 : 0; + } + } + + void _stopCursorBlink({bool resetCharTicks = true}) { + // If the cursor is animating, stop the animation, and we always + // want the cursor to be visible when the floating cursor is enabled. + _cursorBlinkOpacityController.value = + renderEditable.floatingCursorOn ? 1.0 : 0.0; + _cursorTimer?.cancel(); + _cursorTimer = null; + if (resetCharTicks) { + _obscureShowCharTicksPending = 0; + } + } + + void _startOrStopCursorTimerIfNeeded() { + if (!_showBlinkingCursor) { + _stopCursorBlink(); + } else if (_cursorTimer == null) { + _startCursorBlink(); + } + } + + void _didChangeTextEditingValue() { + if (_hasFocus && !_value.selection.isValid) { + // If this field is focused and the selection is invalid, place the cursor at + // the end. Does not rely on _handleFocusChanged because it makes selection + // handles visible on Android. + // Unregister as a listener to the text controller while making the change. + widget.controller.removeListener(_didChangeTextEditingValue); + widget.controller.selection = _adjustedSelectionWhenFocused()!; + widget.controller.addListener(_didChangeTextEditingValue); + } + _updateRemoteEditingValueIfNeeded(); + _startOrStopCursorTimerIfNeeded(); + _updateOrDisposeSelectionOverlayIfNeeded(); + // TODO(abarth): Teach RenderEditable about ValueNotifier + // to avoid this setState(). + setState(() { + /* We use widget.controller.value in build(). */ + }); + _verticalSelectionUpdateAction.stopCurrentVerticalRunIfSelectionChanges(); + } + + void _handleFocusChanged() { + _openOrCloseInputConnectionIfNeeded(); + _startOrStopCursorTimerIfNeeded(); + _updateOrDisposeSelectionOverlayIfNeeded(); + if (_hasFocus) { + // Listen for changing viewInsets, which indicates keyboard showing up. + WidgetsBinding.instance.addObserver(this); + _lastBottomViewInset = View.of(context).viewInsets.bottom; + if (!widget.readOnly) { + _scheduleShowCaretOnScreen(withAnimation: true); + } + final TextSelection? updatedSelection = _adjustedSelectionWhenFocused(); + if (updatedSelection != null) { + _handleSelectionChanged(updatedSelection, null); + } + } else { + WidgetsBinding.instance.removeObserver(this); + setState(() { + _currentPromptRectRange = null; + }); + } + updateKeepAlive(); + } + + TextSelection? _adjustedSelectionWhenFocused() { + TextSelection? selection; + final bool isDesktop = switch (defaultTargetPlatform) { + TargetPlatform.android || + TargetPlatform.iOS || + TargetPlatform.fuchsia => + false, + TargetPlatform.macOS || + TargetPlatform.linux || + TargetPlatform.windows => + true, + }; + final bool shouldSelectAll = widget.selectionEnabled && + (kIsWeb || isDesktop) && + !_isMultiline && + !_nextFocusChangeIsInternal && + !_justResumed; + _justResumed = false; + if (shouldSelectAll) { + // On native web and desktop platforms, single line tags + // select all when receiving focus. + selection = + TextSelection(baseOffset: 0, extentOffset: _value.text.length); + } else if (!_value.selection.isValid) { + // Place cursor at the end if the selection is invalid when we receive focus. + selection = TextSelection.collapsed(offset: _value.text.length); + } + return selection; + } + + void _compositeCallback(Layer layer) { + // The callback can be invoked when the layer is detached. + // The input connection can be closed by the platform in which case this + // widget doesn't rebuild. + if (!renderEditable.attached || !_hasInputConnection) { + return; + } + assert(mounted); + assert((context as Element).debugIsActive); + _updateSizeAndTransform(); + } + + // Must be called after layout. + // See https://github.com/flutter/flutter/issues/126312 + void _updateSizeAndTransform() { + final Size size = renderEditable.size; + final Matrix4 transform = renderEditable.getTransformTo(null); + _textInputConnection!.setEditableSizeAndTransform(size, transform); + } + + void _schedulePeriodicPostFrameCallbacks([Duration? duration]) { + if (!_hasInputConnection) { + return; + } + _updateSelectionRects(); + _updateComposingRectIfNeeded(); + _updateCaretRectIfNeeded(); + SchedulerBinding.instance.addPostFrameCallback( + _schedulePeriodicPostFrameCallbacks, + debugLabel: 'EditableText.postFrameCallbacks', + ); + } + + _ScribbleCacheKey? _scribbleCacheKey; + + void _updateSelectionRects({bool force = false}) { + if (!_stylusHandwritingEnabled || + defaultTargetPlatform != TargetPlatform.iOS) { + return; + } + + final ScrollDirection scrollDirection = + _scrollController.position.userScrollDirection; + if (scrollDirection != ScrollDirection.idle) { + return; + } + + final InlineSpan inlineSpan = renderEditable.text!; + final TextScaler effectiveTextScaler = + switch ((widget.textScaler, widget.textScaleFactor)) { + (final TextScaler textScaler, _) => textScaler, + (null, final double textScaleFactor) => + TextScaler.linear(textScaleFactor), + (null, null) => MediaQuery.textScalerOf(context), + }; + + final _ScribbleCacheKey newCacheKey = _ScribbleCacheKey( + inlineSpan: inlineSpan, + textAlign: widget.textAlign, + textDirection: _textDirection, + textScaler: effectiveTextScaler, + textHeightBehavior: widget.textHeightBehavior ?? + DefaultTextHeightBehavior.maybeOf(context), + locale: widget.locale, + structStyle: widget.strutStyle, + placeholder: _placeholderLocation, + size: renderEditable.size, + ); + + final RenderComparison comparison = force + ? RenderComparison.layout + : _scribbleCacheKey?.compare(newCacheKey) ?? RenderComparison.layout; + if (comparison.index < RenderComparison.layout.index) { + return; + } + _scribbleCacheKey = newCacheKey; + + final List rects = []; + int graphemeStart = 0; + // Can't use _value.text here: the controller value could change between + // frames. + final String plainText = + inlineSpan.toPlainText(includeSemanticsLabels: false); + final CharacterRange characterRange = CharacterRange(plainText); + while (characterRange.moveNext()) { + final int graphemeEnd = graphemeStart + characterRange.current.length; + final List boxes = renderEditable.getBoxesForSelection( + TextSelection(baseOffset: graphemeStart, extentOffset: graphemeEnd), + ); + + final TextBox? box = boxes.isEmpty ? null : boxes.first; + if (box != null) { + final Rect paintBounds = renderEditable.paintBounds; + // Stop early when characters are already below the bottom edge of the + // RenderEditable, regardless of its clipBehavior. + if (paintBounds.bottom <= box.top) { + break; + } + // Include any TextBox which intersects with the RenderEditable. + if (paintBounds.left <= box.right && + box.left <= paintBounds.right && + paintBounds.top <= box.bottom) { + // At least some part of the letter is visible within the text field. + rects.add( + SelectionRect( + position: graphemeStart, + bounds: box.toRect(), + direction: box.direction), + ); + } + } + graphemeStart = graphemeEnd; + } + _textInputConnection!.setSelectionRects(rects); + } + + // Sends the current composing rect to the embedder's text input plugin. + // + // In cases where the composing rect hasn't been updated in the embedder due + // to the lag of asynchronous messages over the channel, the position of the + // current caret rect is used instead. + // + // See: [_updateCaretRectIfNeeded] + void _updateComposingRectIfNeeded() { + final TextRange composingRange = _value.composing; + assert(mounted); + Rect? composingRect = + renderEditable.getRectForComposingRange(composingRange); + // Send the caret location instead if there's no marked text yet. + if (composingRect == null) { + final int offset = composingRange.isValid ? composingRange.start : 0; + composingRect = + renderEditable.getLocalRectForCaret(TextPosition(offset: offset)); + } + _textInputConnection!.setComposingRect(composingRect); + } + + // Sends the current caret rect to the embedder's text input plugin. + // + // The position of the caret rect is updated periodically such that if the + // user initiates composing input, the current cursor rect can be used for + // the first character until the composing rect can be sent. + // + // On selection changes, the start of the selection is used. This ensures + // that regardless of the direction the selection was created, the cursor is + // set to the position where next text input occurs. This position is used to + // position the IME's candidate selection menu. + // + // See: [_updateComposingRectIfNeeded] + void _updateCaretRectIfNeeded() { + final TextSelection? selection = renderEditable.selection; + if (selection == null || !selection.isValid) { + return; + } + final TextPosition currentTextPosition = + TextPosition(offset: selection.start); + final Rect caretRect = + renderEditable.getLocalRectForCaret(currentTextPosition); + _textInputConnection!.setCaretRect(caretRect); + } + + TextDirection get _textDirection => + widget.textDirection ?? Directionality.of(context); + + /// The renderer for this widget's descendant. + /// + /// This property is typically used to notify the renderer of input gestures + /// when [RenderEditable.ignorePointer] is true. + late final RenderEditable renderEditable = + _editableKey.currentContext!.findRenderObject()! as RenderEditable; + + @override + TextEditingValue get textEditingValue => _value; + + double get _devicePixelRatio => MediaQuery.devicePixelRatioOf(context); + + @override + void userUpdateTextEditingValue( + TextEditingValue value, SelectionChangedCause? cause) { + // Compare the current TextEditingValue with the pre-format new + // TextEditingValue value, in case the formatter would reject the change. + final bool shouldShowCaret = + widget.readOnly ? _value.selection != value.selection : _value != value; + if (shouldShowCaret) { + _scheduleShowCaretOnScreen(withAnimation: true); + } + + // Even if the value doesn't change, it may be necessary to focus and build + // the selection overlay. For example, this happens when right clicking an + // unfocused field that previously had a selection in the same spot. + if (value == textEditingValue) { + if (!widget.focusNode.hasFocus) { + _flagInternalFocus(); + widget.focusNode.requestFocus(); + _selectionOverlay ??= _createSelectionOverlay(); + } + return; + } + + _formatAndSetValue(value, cause, userInteraction: true); + } + + @override + void bringIntoView(TextPosition position) { + final Rect localRect = renderEditable.getLocalRectForCaret(position); + final RevealedOffset targetOffset = _getOffsetToRevealCaret(localRect); + + _scrollController.jumpTo(targetOffset.offset); + renderEditable.showOnScreen(rect: targetOffset.rect); + } + + /// Shows the selection toolbar at the location of the current cursor. + /// + /// Returns `false` if a toolbar couldn't be shown, such as when the toolbar + /// is already shown, or when no text selection currently exists. + @override + bool showToolbar() { + // Web is using native dom elements to enable clipboard functionality of the + // context menu: copy, paste, select, cut. It might also provide additional + // functionality depending on the browser (such as translate). Due to this, + // we should not show a Flutter toolbar for the editable text elements + // unless the browser's context menu is explicitly disabled. + if (_webContextMenuEnabled) { + return false; + } + + if (_selectionOverlay == null) { + return false; + } + if (_selectionOverlay!.toolbarIsVisible) { + return false; + } + _liveTextInputStatus?.update(); + clipboardStatus.update(); + _selectionOverlay!.showToolbar(); + // Listen to parent scroll events when the toolbar is visible so it can be + // hidden during a scroll on supported platforms. + if (_platformSupportsFadeOnScroll) { + _listeningToScrollNotificationObserver = true; + _scrollNotificationObserver + ?.removeListener(_handleContextMenuOnParentScroll); + _scrollNotificationObserver = ScrollNotificationObserver.maybeOf(context); + _scrollNotificationObserver + ?.addListener(_handleContextMenuOnParentScroll); + } + return true; + } + + @override + void hideToolbar([bool hideHandles = true]) { + // Stop listening to parent scroll events when toolbar is hidden. + _disposeScrollNotificationObserver(); + if (hideHandles) { + // Hide the handles and the toolbar. + _selectionOverlay?.hide(); + } else if (_selectionOverlay?.toolbarIsVisible ?? false) { + // Hide only the toolbar but not the handles. + _selectionOverlay?.hideToolbar(); + } + } + + /// Toggles the visibility of the toolbar. + void toggleToolbar([bool hideHandles = true]) { + final TextSelectionOverlay selectionOverlay = + _selectionOverlay ??= _createSelectionOverlay(); + if (selectionOverlay.toolbarIsVisible) { + hideToolbar(hideHandles); + } else { + showToolbar(); + } + } + + /// Shows toolbar with spell check suggestions of misspelled words that are + /// available for click-and-replace. + bool showSpellCheckSuggestionsToolbar() { + // Spell check suggestions toolbars are intended to be shown on non-web + // platforms. Additionally, the Cupertino style toolbar can't be drawn on + // the web with the HTML renderer due to + // https://github.com/flutter/flutter/issues/123560. + if (!spellCheckEnabled || + _webContextMenuEnabled || + widget.readOnly || + _selectionOverlay == null || + !_spellCheckResultsReceived || + findSuggestionSpanAtCursorIndex( + textEditingValue.selection.extentOffset) == + null) { + // 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. + /// + /// Updates the magnifier to the position given by `positionToShow`, + /// if there is a magnifier visible. + /// + /// Does nothing if a magnifier couldn't be shown, such as when the selection + /// overlay does not currently exist. + void showMagnifier(Offset positionToShow) { + if (_selectionOverlay == null) { + return; + } + + if (_selectionOverlay!.magnifierIsVisible) { + _selectionOverlay!.updateMagnifier(positionToShow); + } else { + _selectionOverlay!.showMagnifier(positionToShow); + } + } + + /// Hides the magnifier if it is visible. + void hideMagnifier() { + if (_selectionOverlay == null) { + return; + } + + if (_selectionOverlay!.magnifierIsVisible) { + _selectionOverlay!.hideMagnifier(); + } + } + + // Tracks the location a [_ScribblePlaceholder] should be rendered in the + // text. + // + // A value of -1 indicates there should be no placeholder, otherwise the + // value should be between 0 and the length of the text, inclusive. + int _placeholderLocation = -1; + + @override + void insertTextPlaceholder(Size size) { + if (!_stylusHandwritingEnabled) { + return; + } + + if (!widget.controller.selection.isValid) { + return; + } + + setState(() { + _placeholderLocation = + _value.text.length - widget.controller.selection.end; + }); + } + + @override + void removeTextPlaceholder() { + if (!_stylusHandwritingEnabled || _placeholderLocation == -1) { + return; + } + + setState(() { + _placeholderLocation = -1; + }); + } + + @override + void performSelector(String selectorName) { + final Intent? intent = intentForMacOSSelector(selectorName); + + if (intent != null) { + final BuildContext? primaryContext = primaryFocus?.context; + if (primaryContext != null) { + Actions.invoke(primaryContext, intent); + } + } + } + + @override + String get autofillId => 'EditableText-$hashCode'; + + int? _viewId; + + @override + TextInputConfiguration get textInputConfiguration { + final List? autofillHints = + widget.autofillHints?.toList(growable: false); + final AutofillConfiguration autofillConfiguration = autofillHints != null + ? AutofillConfiguration( + uniqueIdentifier: autofillId, + autofillHints: autofillHints, + currentEditingValue: currentTextEditingValue, + ) + : AutofillConfiguration.disabled; + + _viewId = View.of(context).viewId; + return TextInputConfiguration( + enableDeltaModel: true, + viewId: _viewId, + inputType: widget.keyboardType, + readOnly: widget.readOnly, + obscureText: widget.obscureText, + autocorrect: widget.autocorrect, + smartDashesType: widget.smartDashesType, + smartQuotesType: widget.smartQuotesType, + enableSuggestions: widget.enableSuggestions, + enableInteractiveSelection: widget._userSelectionEnabled, + inputAction: widget.textInputAction ?? + (widget.keyboardType == TextInputType.multiline + ? TextInputAction.newline + : TextInputAction.done), + textCapitalization: widget.textCapitalization, + keyboardAppearance: widget.keyboardAppearance, + autofillConfiguration: autofillConfiguration, + enableIMEPersonalizedLearning: widget.enableIMEPersonalizedLearning, + allowedMimeTypes: widget.contentInsertionConfiguration == null + ? const [] + : widget.contentInsertionConfiguration!.allowedMimeTypes, + ); + } + + @override + void autofill(TextEditingValue value) => updateEditingValue(value); + + // null if no promptRect should be shown. + TextRange? _currentPromptRectRange; + + @override + void showAutocorrectionPromptRect(int start, int end) { + setState(() { + _currentPromptRectRange = TextRange(start: start, end: end); + }); + } + + VoidCallback? _semanticsOnCopy(TextSelectionControls? controls) { + return widget.selectionEnabled && + _hasFocus && + (widget.selectionControls is TextSelectionHandleControls + ? copyEnabled + : copyEnabled && + (widget.selectionControls?.canCopy(this) ?? false)) + ? () { + controls?.handleCopy(this); + copySelection(SelectionChangedCause.toolbar); + } + : null; + } + + VoidCallback? _semanticsOnCut(TextSelectionControls? controls) { + return widget.selectionEnabled && + _hasFocus && + (widget.selectionControls is TextSelectionHandleControls + ? cutEnabled + : cutEnabled && + (widget.selectionControls?.canCut(this) ?? false)) + ? () { + controls?.handleCut(this); + cutSelection(SelectionChangedCause.toolbar); + } + : null; + } + + VoidCallback? _semanticsOnPaste(TextSelectionControls? controls) { + return widget.selectionEnabled && + _hasFocus && + (widget.selectionControls is TextSelectionHandleControls + ? pasteEnabled + : pasteEnabled && + (widget.selectionControls?.canPaste(this) ?? false)) && + (clipboardStatus.value == ClipboardStatus.pasteable) + ? () { + controls?.handlePaste(this); + pasteText(SelectionChangedCause.toolbar); + } + : null; + } + + // Returns the closest boundary location to `extent` but not including `extent` + // itself (unless already at the start/end of the text), in the direction + // specified by `forward`. + TextPosition _moveBeyondTextBoundary( + TextPosition extent, + bool forward, + TextBoundary textBoundary, + ) { + assert(extent.offset >= 0); + final int newOffset = forward + ? textBoundary.getTrailingTextBoundaryAt(extent.offset) ?? + _value.text.length + // if x is a boundary defined by `textBoundary`, most textBoundaries (except + // LineBreaker) guarantees `x == textBoundary.getLeadingTextBoundaryAt(x)`. + // Use x - 1 here to make sure we don't get stuck at the fixed point x. + : textBoundary.getLeadingTextBoundaryAt(extent.offset - 1) ?? 0; + return TextPosition(offset: newOffset); + } + + // Returns the closest boundary location to `extent`, including `extent` + // itself, in the direction specified by `forward`. + // + // This method returns a fixed point of itself: applying `_toTextBoundary` + // again on the returned TextPosition gives the same TextPosition. It's used + // exclusively for handling line boundaries, since performing "move to line + // start" more than once usually doesn't move you to the previous line. + TextPosition _moveToTextBoundary( + TextPosition extent, bool forward, TextBoundary textBoundary) { + assert(extent.offset >= 0); + final int caretOffset; + switch (extent.affinity) { + case TextAffinity.upstream: + if (extent.offset < 1 && !forward) { + assert(extent.offset == 0); + return const TextPosition(offset: 0); + } + // When the text affinity is upstream, the caret is associated with the + // grapheme before the code unit at `extent.offset`. + // TODO(LongCatIsLooong): don't assume extent.offset is at a grapheme + // boundary, and do this instead: + // final int graphemeStart = CharacterRange.at(string, extent.offset).stringBeforeLength - 1; + caretOffset = math.max(0, extent.offset - 1); + case TextAffinity.downstream: + caretOffset = extent.offset; + } + // The line boundary range does not include some control characters + // (most notably, Line Feed), in which case there's + // `x ∉ getTextBoundaryAt(x)`. In case `caretOffset` points to one such + // control character, we define that these control characters themselves are + // still part of the previous line, but also exclude them from the + // line boundary range since they're non-printing. IOW, no additional + // processing needed since the LineBoundary class does exactly that. + return forward + ? TextPosition( + offset: textBoundary.getTrailingTextBoundaryAt(caretOffset) ?? + _value.text.length, + affinity: TextAffinity.upstream, + ) + : TextPosition( + offset: textBoundary.getLeadingTextBoundaryAt(caretOffset) ?? 0); + } + + // --------------------------- Text Editing Actions --------------------------- + + TextBoundary _characterBoundary() => widget.obscureText + ? _CodePointBoundary(_value.text) + : CharacterBoundary(_value.text); + TextBoundary _nextWordBoundary() => widget.obscureText + ? _documentBoundary() + : renderEditable.wordBoundaries.moveByWordBoundary; + TextBoundary _linebreak() => + widget.obscureText ? _documentBoundary() : LineBoundary(renderEditable); + TextBoundary _paragraphBoundary() => ParagraphBoundary(_value.text); + TextBoundary _documentBoundary() => DocumentBoundary(_value.text); + + Action _makeOverridable(Action defaultAction) { + return Action.overridable( + context: context, defaultAction: defaultAction); + } + + /// Transpose the characters immediately before and after the current + /// collapsed selection. + /// + /// When the cursor is at the end of the text, transposes the last two + /// characters, if they exist. + /// + /// When the cursor is at the start of the text, does nothing. + void _transposeCharacters(TransposeCharactersIntent intent) { + if (_value.text.characters.length <= 1 || + !_value.selection.isCollapsed || + _value.selection.baseOffset == 0) { + return; + } + + final String text = _value.text; + final TextSelection selection = _value.selection; + final bool atEnd = selection.baseOffset == text.length; + final CharacterRange transposing = + CharacterRange.at(text, selection.baseOffset); + if (atEnd) { + transposing.moveBack(2); + } else { + transposing + ..moveBack() + ..expandNext(); + } + assert(transposing.currentCharacters.length == 2); + + userUpdateTextEditingValue( + TextEditingValue( + text: transposing.stringBefore + + transposing.currentCharacters.last + + transposing.currentCharacters.first + + transposing.stringAfter, + selection: TextSelection.collapsed( + offset: transposing.stringBeforeLength + transposing.current.length, + ), + ), + SelectionChangedCause.keyboard, + ); + } + + late final Action _transposeCharactersAction = + CallbackAction(onInvoke: _transposeCharacters); + + void _replaceText(ReplaceTextIntent intent) { + final TextEditingValue oldValue = _value; + final TextEditingValue newValue = intent.currentTextEditingValue.replaced( + intent.replacementRange, + intent.replacementText, + ); + userUpdateTextEditingValue(newValue, intent.cause); + + // If there's no change in text and selection (e.g. when selecting and + // pasting identical text), the widget won't be rebuilt on value update. + // Handle this by calling _didChangeTextEditingValue() so caret and scroll + // updates can happen. + if (newValue == oldValue) { + _didChangeTextEditingValue(); + } + } + + late final Action _replaceTextAction = + CallbackAction( + onInvoke: _replaceText, + ); + + // Scrolls either to the beginning or end of the document depending on the + // intent's `forward` parameter. + void _scrollToDocumentBoundary(ScrollToDocumentBoundaryIntent intent) { + if (intent.forward) { + bringIntoView(TextPosition(offset: _value.text.length)); + } else { + bringIntoView(const TextPosition(offset: 0)); + } + } + + /// Handles [ScrollIntent] by scrolling the [Scrollable] inside of + /// [EditableText]. + void _scroll(ScrollIntent intent) { + if (intent.type != ScrollIncrementType.page) { + return; + } + + final ScrollPosition position = _scrollController.position; + if (widget.maxLines == 1) { + _scrollController.jumpTo(position.maxScrollExtent); + return; + } + + // If the field isn't scrollable, do nothing. For example, when the lines of + // text is less than maxLines, the field has nothing to scroll. + if (position.maxScrollExtent == 0.0 && position.minScrollExtent == 0.0) { + return; + } + + final ScrollableState? state = + _scrollableKey.currentState as ScrollableState?; + final double increment = + ScrollAction.getDirectionalIncrement(state!, intent); + final double destination = clampDouble( + position.pixels + increment, + position.minScrollExtent, + position.maxScrollExtent, + ); + if (destination == position.pixels) { + return; + } + _scrollController.jumpTo(destination); + } + + /// Extend the selection down by page if the `forward` parameter is true, or + /// up by page otherwise. + void _extendSelectionByPage(ExtendSelectionByPageIntent intent) { + if (widget.maxLines == 1) { + return; + } + + final TextSelection nextSelection; + final Rect extentRect = + renderEditable.getLocalRectForCaret(_value.selection.extent); + final ScrollableState? state = + _scrollableKey.currentState as ScrollableState?; + final double increment = ScrollAction.getDirectionalIncrement( + state!, + ScrollIntent( + direction: intent.forward ? AxisDirection.down : AxisDirection.up, + type: ScrollIncrementType.page, + ), + ); + final ScrollPosition position = _scrollController.position; + if (intent.forward) { + if (_value.selection.extentOffset >= _value.text.length) { + return; + } + final Offset nextExtentOffset = + Offset(extentRect.left, extentRect.top + increment); + final double height = + position.maxScrollExtent + renderEditable.size.height; + final TextPosition nextExtent = + nextExtentOffset.dy + position.pixels >= height + ? TextPosition(offset: _value.text.length) + : renderEditable.getPositionForPoint( + renderEditable.localToGlobal(nextExtentOffset)); + nextSelection = + _value.selection.copyWith(extentOffset: nextExtent.offset); + } else { + if (_value.selection.extentOffset <= 0) { + return; + } + final Offset nextExtentOffset = + Offset(extentRect.left, extentRect.top + increment); + final TextPosition nextExtent = nextExtentOffset.dy + position.pixels <= 0 + ? const TextPosition(offset: 0) + : renderEditable.getPositionForPoint( + renderEditable.localToGlobal(nextExtentOffset)); + nextSelection = + _value.selection.copyWith(extentOffset: nextExtent.offset); + } + + bringIntoView(nextSelection.extent); + userUpdateTextEditingValue( + _value.copyWith(selection: nextSelection), + SelectionChangedCause.keyboard, + ); + } + + void _updateSelection(UpdateSelectionIntent intent) { + assert( + intent.newSelection.start <= intent.currentTextEditingValue.text.length, + 'invalid selection: ${intent.newSelection}: it must not exceed the current text length ${intent.currentTextEditingValue.text.length}', + ); + assert( + intent.newSelection.end <= intent.currentTextEditingValue.text.length, + 'invalid selection: ${intent.newSelection}: it must not exceed the current text length ${intent.currentTextEditingValue.text.length}', + ); + + bringIntoView(intent.newSelection.extent); + userUpdateTextEditingValue( + intent.currentTextEditingValue.copyWith(selection: intent.newSelection), + intent.cause, + ); + } + + late final Action _updateSelectionAction = + CallbackAction(onInvoke: _updateSelection); + + late final _UpdateTextSelectionVerticallyAction< + DirectionalCaretMovementIntent> _verticalSelectionUpdateAction = + _UpdateTextSelectionVerticallyAction( + this); + + Object? _hideToolbarIfVisible(DismissIntent intent) { + if (_selectionOverlay?.toolbarIsVisible ?? false) { + hideToolbar(false); + return null; + } + return Actions.invoke(context, intent); + } + + void _onTapOutside(BuildContext context, PointerDownEvent event) { + _hadFocusOnTapDown = true; + + if (widget.onTapOutside != null) { + widget.onTapOutside!(event); + } else { + _defaultOnTapOutside(context, event); + } + } + + void _onTapUpOutside(BuildContext context, PointerUpEvent event) { + if (!_hadFocusOnTapDown) { + return; + } + + // Reset to false so that subsequent events doesn't trigger the callback based on old information. + _hadFocusOnTapDown = false; + + if (widget.onTapUpOutside != null) { + widget.onTapUpOutside!(event); + } else { + _defaultOnTapUpOutside(context, event); + } + } + + /// The default behavior used if [EditableText.onTapOutside] is null. + /// + /// The `event` argument is the [PointerDownEvent] that caused the notification. + void _defaultOnTapOutside(BuildContext context, PointerDownEvent event) { + Actions.invoke( + context, + EditableTextTapOutsideIntent( + focusNode: widget.focusNode, pointerDownEvent: event), + ); + } + + /// The default behavior used if [EditableText.onTapUpOutside] is null. + /// + /// The `event` argument is the [PointerUpEvent] that caused the notification. + void _defaultOnTapUpOutside(BuildContext context, PointerUpEvent event) { + Actions.invoke( + context, + EditableTextTapUpOutsideIntent( + focusNode: widget.focusNode, pointerUpEvent: event), + ); + } + + late final Map> _actions = >{ + DoNothingAndStopPropagationTextIntent: DoNothingAction(consumesKey: false), + ReplaceTextIntent: _replaceTextAction, + UpdateSelectionIntent: _updateSelectionAction, + DirectionalFocusIntent: DirectionalFocusAction.forTextField(), + DismissIntent: + CallbackAction(onInvoke: _hideToolbarIfVisible), + + // Delete + DeleteCharacterIntent: _makeOverridable( + _DeleteTextAction( + this, + _characterBoundary, + _moveBeyondTextBoundary, + atUserRegex: _atUserRegex, + onDelAtUser: widget.onDelAtUser, + ), + ), + DeleteToNextWordBoundaryIntent: _makeOverridable( + _DeleteTextAction( + this, + _nextWordBoundary, + _moveBeyondTextBoundary, + ), + ), + DeleteToLineBreakIntent: _makeOverridable( + _DeleteTextAction( + this, _linebreak, _moveToTextBoundary), + ), + + // Extend/Move Selection + ExtendSelectionByCharacterIntent: _makeOverridable( + _UpdateTextSelectionAction( + this, + _characterBoundary, + _moveBeyondTextBoundary, + ignoreNonCollapsedSelection: false, + ), + ), + ExtendSelectionByPageIntent: _makeOverridable( + CallbackAction( + onInvoke: _extendSelectionByPage), + ), + ExtendSelectionToNextWordBoundaryIntent: _makeOverridable( + _UpdateTextSelectionAction( + this, + _nextWordBoundary, + _moveBeyondTextBoundary, + ignoreNonCollapsedSelection: true, + ), + ), + ExtendSelectionToNextParagraphBoundaryIntent: _makeOverridable( + _UpdateTextSelectionAction( + this, + _paragraphBoundary, + _moveBeyondTextBoundary, + ignoreNonCollapsedSelection: true, + ), + ), + ExtendSelectionToLineBreakIntent: _makeOverridable( + _UpdateTextSelectionAction( + this, + _linebreak, + _moveToTextBoundary, + ignoreNonCollapsedSelection: true, + ), + ), + ExtendSelectionVerticallyToAdjacentLineIntent: + _makeOverridable(_verticalSelectionUpdateAction), + ExtendSelectionVerticallyToAdjacentPageIntent: + _makeOverridable(_verticalSelectionUpdateAction), + ExtendSelectionToNextParagraphBoundaryOrCaretLocationIntent: + _makeOverridable( + _UpdateTextSelectionAction< + ExtendSelectionToNextParagraphBoundaryOrCaretLocationIntent>( + this, + _paragraphBoundary, + _moveBeyondTextBoundary, + ignoreNonCollapsedSelection: true, + ), + ), + ExtendSelectionToDocumentBoundaryIntent: _makeOverridable( + _UpdateTextSelectionAction( + this, + _documentBoundary, + _moveBeyondTextBoundary, + ignoreNonCollapsedSelection: true, + ), + ), + ExtendSelectionToNextWordBoundaryOrCaretLocationIntent: _makeOverridable( + _UpdateTextSelectionAction< + ExtendSelectionToNextWordBoundaryOrCaretLocationIntent>( + this, + _nextWordBoundary, + _moveBeyondTextBoundary, + ignoreNonCollapsedSelection: true, + ), + ), + ScrollToDocumentBoundaryIntent: _makeOverridable( + CallbackAction( + onInvoke: _scrollToDocumentBoundary), + ), + ScrollIntent: CallbackAction(onInvoke: _scroll), + + // Expand Selection + ExpandSelectionToLineBreakIntent: _makeOverridable( + _UpdateTextSelectionAction( + this, + _linebreak, + _moveToTextBoundary, + ignoreNonCollapsedSelection: true, + isExpand: true, + ), + ), + ExpandSelectionToDocumentBoundaryIntent: _makeOverridable( + _UpdateTextSelectionAction( + this, + _documentBoundary, + _moveToTextBoundary, + ignoreNonCollapsedSelection: true, + isExpand: true, + extentAtIndex: true, + ), + ), + + // Copy Paste + SelectAllTextIntent: _makeOverridable(_SelectAllAction(this)), + CopySelectionTextIntent: _makeOverridable(_CopySelectionAction(this)), + PasteTextIntent: _makeOverridable( + CallbackAction( + onInvoke: (PasteTextIntent intent) => pasteText(intent.cause), + ), + ), + + TransposeCharactersIntent: _makeOverridable(_transposeCharactersAction), + EditableTextTapOutsideIntent: + _makeOverridable(_EditableTextTapOutsideAction()), + EditableTextTapUpOutsideIntent: + _makeOverridable(_EditableTextTapUpOutsideAction()), + }; + + @protected + @override + Widget build(BuildContext context) { + assert(debugCheckHasMediaQuery(context)); + super.build(context); // See AutomaticKeepAliveClientMixin. + + final TextSelectionControls? controls = widget.selectionControls; + final TextScaler effectiveTextScaler = + switch ((widget.textScaler, widget.textScaleFactor)) { + (final TextScaler textScaler, _) => textScaler, + (null, final double textScaleFactor) => + TextScaler.linear(textScaleFactor), + (null, null) => MediaQuery.textScalerOf(context), + }; + final ui.SemanticsInputType inputType; + switch (widget.keyboardType) { + case TextInputType.phone: + inputType = ui.SemanticsInputType.phone; + case TextInputType.url: + inputType = ui.SemanticsInputType.url; + case TextInputType.emailAddress: + inputType = ui.SemanticsInputType.email; + default: + inputType = ui.SemanticsInputType.text; + } + + return _CompositionCallback( + compositeCallback: _compositeCallback, + enabled: _hasInputConnection, + child: Actions( + actions: _actions, + child: Builder( + builder: (BuildContext context) { + return TextFieldTapRegion( + groupId: widget.groupId, + onTapOutside: _hasFocus + ? (PointerDownEvent event) => _onTapOutside(context, event) + : null, + onTapUpOutside: (PointerUpEvent event) => + _onTapUpOutside(context, event), + debugLabel: kReleaseMode ? null : 'EditableText', + child: MouseRegion( + cursor: widget.mouseCursor ?? SystemMouseCursors.text, + child: UndoHistory( + value: widget.controller, + onTriggered: (TextEditingValue value) { + userUpdateTextEditingValue( + value, SelectionChangedCause.keyboard); + }, + shouldChangeUndoStack: + (TextEditingValue? oldValue, TextEditingValue newValue) { + if (!newValue.selection.isValid) { + return false; + } + + if (oldValue == null) { + return true; + } + + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + // Composing text is not counted in history coalescing. + if (!widget.controller.value.composing.isCollapsed) { + return false; + } + case TargetPlatform.android: + // Gboard on Android puts non-CJK words in composing regions. Coalesce + // composing text in order to allow the saving of partial words in that + // case. + break; + } + + return oldValue.text != newValue.text || + oldValue.composing != newValue.composing; + }, + undoStackModifier: (TextEditingValue value) { + // On Android we should discard the composing region when pushing + // a new entry to the undo stack. This prevents the TextInputPlugin + // from restarting the input on every undo/redo when the composing + // region is changed by the framework. + return defaultTargetPlatform == TargetPlatform.android + ? value.copyWith(composing: TextRange.empty) + : value; + }, + focusNode: widget.focusNode, + controller: widget.undoController, + child: Focus( + focusNode: widget.focusNode, + includeSemantics: false, + debugLabel: kReleaseMode ? null : 'EditableText', + child: NotificationListener( + onNotification: (ScrollNotification notification) { + _handleContextMenuOnScroll(notification); + _scribbleCacheKey = null; + return false; + }, + child: Scrollable( + key: _scrollableKey, + excludeFromSemantics: true, + axisDirection: _isMultiline + ? AxisDirection.down + : AxisDirection.right, + controller: _scrollController, + physics: widget.scrollPhysics, + dragStartBehavior: widget.dragStartBehavior, + restorationId: widget.restorationId, + // If a ScrollBehavior is not provided, only apply scrollbars when + // multiline. The overscroll indicator should not be applied in + // either case, glowing or stretching. + scrollBehavior: widget.scrollBehavior ?? + ScrollConfiguration.of( + context, + ).copyWith( + scrollbars: _isMultiline, overscroll: false), + viewportBuilder: + (BuildContext context, ViewportOffset offset) { + return CompositedTransformTarget( + link: _toolbarLayerLink, + child: Semantics( + inputType: inputType, + onCopy: _semanticsOnCopy(controls), + onCut: _semanticsOnCut(controls), + onPaste: _semanticsOnPaste(controls), + child: _ScribbleFocusable( + editableKey: _editableKey, + enabled: _stylusHandwritingEnabled, + focusNode: widget.focusNode, + updateSelectionRects: () { + _openInputConnection(); + _updateSelectionRects(force: true); + }, + child: SizeChangedLayoutNotifier( + child: _Editable( + key: _editableKey, + startHandleLayerLink: _startHandleLayerLink, + endHandleLayerLink: _endHandleLayerLink, + inlineSpan: buildTextSpan(), + value: _value, + cursorColor: _cursorColor, + backgroundCursorColor: + widget.backgroundCursorColor, + showCursor: _cursorVisibilityNotifier, + forceLine: widget.forceLine, + readOnly: widget.readOnly, + hasFocus: _hasFocus, + maxLines: widget.maxLines, + minLines: widget.minLines, + expands: widget.expands, + strutStyle: widget.strutStyle, + selectionColor: _selectionOverlay + ?.spellCheckToolbarIsVisible ?? + false + ? _spellCheckConfiguration + .misspelledSelectionColor ?? + widget.selectionColor + : widget.selectionColor, + textScaler: effectiveTextScaler, + textAlign: widget.textAlign, + textDirection: _textDirection, + locale: widget.locale, + textHeightBehavior: + widget.textHeightBehavior ?? + DefaultTextHeightBehavior.maybeOf( + context), + textWidthBasis: widget.textWidthBasis, + obscuringCharacter: + widget.obscuringCharacter, + obscureText: widget.obscureText, + offset: offset, + rendererIgnoresPointer: + widget.rendererIgnoresPointer, + cursorWidth: widget.cursorWidth, + cursorHeight: widget.cursorHeight, + cursorRadius: widget.cursorRadius, + cursorOffset: + widget.cursorOffset ?? Offset.zero, + selectionHeightStyle: + widget.selectionHeightStyle, + selectionWidthStyle: + widget.selectionWidthStyle, + paintCursorAboveText: + widget.paintCursorAboveText, + enableInteractiveSelection: + widget._userSelectionEnabled, + textSelectionDelegate: this, + devicePixelRatio: _devicePixelRatio, + promptRectRange: _currentPromptRectRange, + promptRectColor: + widget.autocorrectionTextRectColor, + clipBehavior: widget.clipBehavior, + ), + ), + ), + ), + ); + }, + ), + ), + ), + ), + ), + ); + }, + ), + ), + ); + } + + /// Builds [TextSpan] from current editing value. + /// + /// By default makes text in composing range appear as underlined. + /// Descendants can override this method to customize appearance of text. + TextSpan buildTextSpan() { + if (widget.obscureText) { + String text = _value.text; + text = widget.obscuringCharacter * text.length; + // Reveal the latest character in an obscured field only on mobile. + const Set mobilePlatforms = { + TargetPlatform.android, + TargetPlatform.fuchsia, + TargetPlatform.iOS, + }; + final bool brieflyShowPassword = + WidgetsBinding.instance.platformDispatcher.brieflyShowPassword && + mobilePlatforms.contains(defaultTargetPlatform); + if (brieflyShowPassword) { + final int? o = + _obscureShowCharTicksPending > 0 ? _obscureLatestCharIndex : null; + if (o != null && o >= 0 && o < text.length) { + text = text.replaceRange(o, o + 1, _value.text.substring(o, o + 1)); + } + } + return TextSpan(style: _style, text: text); + } + if (_placeholderLocation >= 0 && + _placeholderLocation <= _value.text.length) { + final List<_ScribblePlaceholder> placeholders = <_ScribblePlaceholder>[]; + final int placeholderLocation = _value.text.length - _placeholderLocation; + if (_isMultiline) { + // The zero size placeholder here allows the line to break and keep the caret on the first line. + placeholders.add(const _ScribblePlaceholder( + child: SizedBox.shrink(), size: Size.zero)); + placeholders.add( + _ScribblePlaceholder( + child: const SizedBox.shrink(), + size: Size(renderEditable.size.width, 0.0), + ), + ); + } else { + placeholders.add( + const _ScribblePlaceholder( + child: SizedBox.shrink(), size: Size(100.0, 0.0)), + ); + } + return TextSpan( + style: _style, + children: [ + TextSpan(text: _value.text.substring(0, placeholderLocation)), + ...placeholders, + TextSpan(text: _value.text.substring(placeholderLocation)), + ], + ); + } + final bool withComposing = !widget.readOnly && _hasFocus; + 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. + assert(!_value.composing.isValid || + !withComposing || + _value.isComposingRangeValid); + + final bool composingRegionOutOfRange = + !_value.isComposingRangeValid || !withComposing; + + return buildTextSpanWithSpellCheckSuggestions( + _value, + composingRegionOutOfRange, + _style, + _spellCheckConfiguration.misspelledTextStyle!, + spellCheckResults!, + ); + } + + // Read only mode should not paint text composing. + return widget.controller.buildTextSpan( + context: context, + style: _style, + withComposing: withComposing, + ); + } +} + +class _Editable extends MultiChildRenderObjectWidget { + _Editable({ + super.key, + required this.inlineSpan, + required this.value, + required this.startHandleLayerLink, + required this.endHandleLayerLink, + this.cursorColor, + this.backgroundCursorColor, + required this.showCursor, + required this.forceLine, + required this.readOnly, + this.textHeightBehavior, + required this.textWidthBasis, + required this.hasFocus, + required this.maxLines, + this.minLines, + required this.expands, + this.strutStyle, + this.selectionColor, + required this.textScaler, + required this.textAlign, + required this.textDirection, + this.locale, + required this.obscuringCharacter, + required this.obscureText, + required this.offset, + this.rendererIgnoresPointer = false, + required this.cursorWidth, + this.cursorHeight, + this.cursorRadius, + required this.cursorOffset, + required this.paintCursorAboveText, + this.selectionHeightStyle = ui.BoxHeightStyle.tight, + this.selectionWidthStyle = ui.BoxWidthStyle.tight, + this.enableInteractiveSelection = true, + required this.textSelectionDelegate, + required this.devicePixelRatio, + this.promptRectRange, + this.promptRectColor, + required this.clipBehavior, + }) : super( + children: WidgetSpan.extractFromInlineSpan(inlineSpan, textScaler)); + + final InlineSpan inlineSpan; + final TextEditingValue value; + final Color? cursorColor; + final LayerLink startHandleLayerLink; + final LayerLink endHandleLayerLink; + final Color? backgroundCursorColor; + final ValueNotifier showCursor; + final bool forceLine; + final bool readOnly; + final bool hasFocus; + final int? maxLines; + final int? minLines; + final bool expands; + final StrutStyle? strutStyle; + final Color? selectionColor; + final TextScaler textScaler; + final TextAlign textAlign; + final TextDirection textDirection; + final Locale? locale; + final String obscuringCharacter; + final bool obscureText; + final TextHeightBehavior? textHeightBehavior; + final TextWidthBasis textWidthBasis; + final ViewportOffset offset; + final bool rendererIgnoresPointer; + final double cursorWidth; + final double? cursorHeight; + final Radius? cursorRadius; + final Offset cursorOffset; + final bool paintCursorAboveText; + final ui.BoxHeightStyle selectionHeightStyle; + final ui.BoxWidthStyle selectionWidthStyle; + final bool enableInteractiveSelection; + final TextSelectionDelegate textSelectionDelegate; + final double devicePixelRatio; + final TextRange? promptRectRange; + final Color? promptRectColor; + final Clip clipBehavior; + + @override + RenderEditable createRenderObject(BuildContext context) { + return RenderEditable( + text: inlineSpan, + cursorColor: cursorColor, + startHandleLayerLink: startHandleLayerLink, + endHandleLayerLink: endHandleLayerLink, + backgroundCursorColor: backgroundCursorColor, + showCursor: showCursor, + forceLine: forceLine, + readOnly: readOnly, + hasFocus: hasFocus, + maxLines: maxLines, + minLines: minLines, + expands: expands, + strutStyle: strutStyle, + selectionColor: selectionColor, + textScaler: textScaler, + textAlign: textAlign, + textDirection: textDirection, + locale: locale ?? Localizations.maybeLocaleOf(context), + selection: value.selection, + offset: offset, + ignorePointer: rendererIgnoresPointer, + obscuringCharacter: obscuringCharacter, + obscureText: obscureText, + textHeightBehavior: textHeightBehavior, + textWidthBasis: textWidthBasis, + cursorWidth: cursorWidth, + cursorHeight: cursorHeight, + cursorRadius: cursorRadius, + cursorOffset: cursorOffset, + paintCursorAboveText: paintCursorAboveText, + selectionHeightStyle: selectionHeightStyle, + selectionWidthStyle: selectionWidthStyle, + enableInteractiveSelection: enableInteractiveSelection, + textSelectionDelegate: textSelectionDelegate, + devicePixelRatio: devicePixelRatio, + promptRectRange: promptRectRange, + promptRectColor: promptRectColor, + clipBehavior: clipBehavior, + ); + } + + @override + void updateRenderObject(BuildContext context, RenderEditable renderObject) { + renderObject + ..text = inlineSpan + ..cursorColor = cursorColor + ..startHandleLayerLink = startHandleLayerLink + ..endHandleLayerLink = endHandleLayerLink + ..backgroundCursorColor = backgroundCursorColor + ..showCursor = showCursor + ..forceLine = forceLine + ..readOnly = readOnly + ..hasFocus = hasFocus + ..maxLines = maxLines + ..minLines = minLines + ..expands = expands + ..strutStyle = strutStyle + ..selectionColor = selectionColor + ..textScaler = textScaler + ..textAlign = textAlign + ..textDirection = textDirection + ..locale = locale ?? Localizations.maybeLocaleOf(context) + ..selection = value.selection + ..offset = offset + ..ignorePointer = rendererIgnoresPointer + ..textHeightBehavior = textHeightBehavior + ..textWidthBasis = textWidthBasis + ..obscuringCharacter = obscuringCharacter + ..obscureText = obscureText + ..cursorWidth = cursorWidth + ..cursorHeight = cursorHeight + ..cursorRadius = cursorRadius + ..cursorOffset = cursorOffset + ..selectionHeightStyle = selectionHeightStyle + ..selectionWidthStyle = selectionWidthStyle + ..enableInteractiveSelection = enableInteractiveSelection + ..textSelectionDelegate = textSelectionDelegate + ..devicePixelRatio = devicePixelRatio + ..paintCursorAboveText = paintCursorAboveText + ..promptRectColor = promptRectColor + ..clipBehavior = clipBehavior + ..setPromptRectRange(promptRectRange); + } +} + +@immutable +class _ScribbleCacheKey { + const _ScribbleCacheKey({ + required this.inlineSpan, + required this.textAlign, + required this.textDirection, + required this.textScaler, + required this.textHeightBehavior, + required this.locale, + required this.structStyle, + required this.placeholder, + required this.size, + }); + + final TextAlign textAlign; + final TextDirection textDirection; + final TextScaler textScaler; + final TextHeightBehavior? textHeightBehavior; + final Locale? locale; + final StrutStyle structStyle; + final int placeholder; + final Size size; + final InlineSpan inlineSpan; + + RenderComparison compare(_ScribbleCacheKey other) { + if (identical(other, this)) { + return RenderComparison.identical; + } + final bool needsLayout = textAlign != other.textAlign || + textDirection != other.textDirection || + textScaler != other.textScaler || + (textHeightBehavior ?? const TextHeightBehavior()) != + (other.textHeightBehavior ?? const TextHeightBehavior()) || + locale != other.locale || + structStyle != other.structStyle || + placeholder != other.placeholder || + size != other.size; + return needsLayout + ? RenderComparison.layout + : inlineSpan.compareTo(other.inlineSpan); + } +} + +class _ScribbleFocusable extends StatefulWidget { + const _ScribbleFocusable({ + required this.child, + required this.focusNode, + required this.editableKey, + required this.updateSelectionRects, + required this.enabled, + }); + + final Widget child; + final FocusNode focusNode; + final GlobalKey editableKey; + final VoidCallback updateSelectionRects; + final bool enabled; + + @override + _ScribbleFocusableState createState() => _ScribbleFocusableState(); +} + +class _ScribbleFocusableState extends State<_ScribbleFocusable> + implements ScribbleClient { + _ScribbleFocusableState() + : _elementIdentifier = (_nextElementIdentifier++).toString(); + + @override + void initState() { + super.initState(); + if (widget.enabled) { + TextInput.registerScribbleElement(elementIdentifier, this); + } + } + + @override + void didUpdateWidget(_ScribbleFocusable oldWidget) { + super.didUpdateWidget(oldWidget); + if (!oldWidget.enabled && widget.enabled) { + TextInput.registerScribbleElement(elementIdentifier, this); + } + + if (oldWidget.enabled && !widget.enabled) { + TextInput.unregisterScribbleElement(elementIdentifier); + } + } + + @override + void dispose() { + TextInput.unregisterScribbleElement(elementIdentifier); + super.dispose(); + } + + RenderEditable? get renderEditable => + widget.editableKey.currentContext?.findRenderObject() as RenderEditable?; + + static int _nextElementIdentifier = 1; + final String _elementIdentifier; + + @override + String get elementIdentifier => _elementIdentifier; + + @override + void onScribbleFocus(Offset offset) { + widget.focusNode.requestFocus(); + renderEditable?.selectPositionAt( + from: offset, cause: SelectionChangedCause.stylusHandwriting); + widget.updateSelectionRects(); + } + + @override + bool isInScribbleRect(Rect rect) { + final Rect calculatedBounds = bounds; + if (renderEditable?.readOnly ?? false) { + return false; + } + if (calculatedBounds == Rect.zero) { + return false; + } + if (!calculatedBounds.overlaps(rect)) { + return false; + } + final Rect intersection = calculatedBounds.intersect(rect); + final HitTestResult result = HitTestResult(); + WidgetsBinding.instance + .hitTestInView(result, intersection.center, View.of(context).viewId); + return result.path + .any((HitTestEntry entry) => entry.target == renderEditable); + } + + @override + Rect get bounds { + final RenderBox? box = context.findRenderObject() as RenderBox?; + if (box == null || !mounted || !box.attached) { + return Rect.zero; + } + final Matrix4 transform = box.getTransformTo(null); + return MatrixUtils.transformRect( + transform, + Rect.fromLTWH(0, 0, box.size.width, box.size.height), + ); + } + + @override + Widget build(BuildContext context) { + return widget.child; + } +} + +class _ScribblePlaceholder extends WidgetSpan { + const _ScribblePlaceholder({required super.child, required this.size}); + + /// The size of the span, used in place of adding a placeholder size to the [TextPainter]. + final Size size; + + @override + void build( + ui.ParagraphBuilder builder, { + TextScaler textScaler = TextScaler.noScaling, + List? dimensions, + }) { + assert(debugAssertIsValid()); + final bool hasStyle = style != null; + if (hasStyle) { + builder.pushStyle(style!.getTextStyle(textScaler: textScaler)); + } + builder.addPlaceholder(size.width, size.height, alignment); + if (hasStyle) { + builder.pop(); + } + } +} + +/// A text boundary that uses code points as logical boundaries. +/// +/// A code point represents a single character. This may be smaller than what is +/// represented by a user-perceived character, or grapheme. For example, a +/// single grapheme (in this case a Unicode extended grapheme cluster) like +/// "👨‍👩‍👦" consists of five code points: the man emoji, a zero +/// width joiner, the woman emoji, another zero width joiner, and the boy emoji. +/// The [String] has a length of eight because each emoji consists of two code +/// units. +/// +/// Code units are the units by which Dart's String class is measured, which is +/// encoded in UTF-16. +/// +/// See also: +/// +/// * [String.runes], which deals with code points like this class. +/// * [Characters], which deals with graphemes. +/// * [CharacterBoundary], which is a [TextBoundary] like this class, but whose +/// boundaries are graphemes instead of code points. +class _CodePointBoundary extends TextBoundary { + const _CodePointBoundary(this._text); + + final String _text; + + // Returns true if the given position falls in the center of a surrogate pair. + bool _breaksSurrogatePair(int position) { + assert(position > 0 && position < _text.length && _text.length > 1); + return TextPainter.isHighSurrogate(_text.codeUnitAt(position - 1)) && + TextPainter.isLowSurrogate(_text.codeUnitAt(position)); + } + + @override + int? getLeadingTextBoundaryAt(int position) { + if (_text.isEmpty || position < 0) { + return null; + } + if (position == 0) { + return 0; + } + if (position >= _text.length) { + return _text.length; + } + if (_text.length <= 1) { + return position; + } + + return _breaksSurrogatePair(position) ? position - 1 : position; + } + + @override + int? getTrailingTextBoundaryAt(int position) { + if (_text.isEmpty || position >= _text.length) { + return null; + } + if (position < 0) { + return 0; + } + if (position == _text.length - 1) { + return _text.length; + } + if (_text.length <= 1) { + return position; + } + + return _breaksSurrogatePair(position + 1) ? position + 2 : position + 1; + } +} + +// ------------------------------- Text Actions ------------------------------- +class _DeleteTextAction + extends ContextAction { + _DeleteTextAction( + this.state, + this.getTextBoundary, + this._applyTextBoundary, { + this.atUserRegex, + this.onDelAtUser, + }); + + final EditableTextState state; + final TextBoundary Function() getTextBoundary; + final _ApplyTextBoundary _applyTextBoundary; + final RegExp? atUserRegex; + final ValueChanged? onDelAtUser; + + void _hideToolbarIfTextChanged(ReplaceTextIntent intent) { + if (state._selectionOverlay == null || + !state.selectionOverlay!.toolbarIsVisible) { + return; + } + final TextEditingValue oldValue = intent.currentTextEditingValue; + final TextEditingValue newValue = intent.currentTextEditingValue.replaced( + intent.replacementRange, + intent.replacementText, + ); + if (oldValue.text != newValue.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`. + state.hideToolbar(false); + } + } + + @override + Object? invoke(T intent, [BuildContext? context]) { + final TextSelection selection = state._value.selection; + if (!selection.isValid) { + return null; + } + assert(selection.isValid); + // Expands the selection to ensure the range covers full graphemes. + final TextBoundary atomicBoundary = state._characterBoundary(); + if (!selection.isCollapsed) { + // Expands the selection to ensure the range covers full graphemes. + final TextRange range = TextRange( + start: atomicBoundary.getLeadingTextBoundaryAt(selection.start) ?? + state._value.text.length, + end: atomicBoundary.getTrailingTextBoundaryAt(selection.end - 1) ?? 0, + ); + final ReplaceTextIntent replaceTextIntent = ReplaceTextIntent( + state._value, + '', + range, + SelectionChangedCause.keyboard, + ); + _hideToolbarIfTextChanged(replaceTextIntent); + return Actions.invoke(context!, replaceTextIntent); + } + + final value = state._value; + final text = value.text; + + if (!intent.forward) { + if (text.isNotEmpty && selection.baseOffset != 0) { + String subText = text.substring(0, selection.baseOffset); + RegExpMatch? match = atUserRegex?.firstMatch(subText); + if (match != null) { + onDelAtUser?.call(match.group(0)!.trim()); + final range = TextRange(start: match.start, end: match.end); + final ReplaceTextIntent replaceTextIntent = ReplaceTextIntent( + value, + '', + range, + SelectionChangedCause.keyboard, + ); + _hideToolbarIfTextChanged(replaceTextIntent); + return Actions.invoke(context!, replaceTextIntent); + } + } + } + + final int target = + _applyTextBoundary(selection.base, intent.forward, getTextBoundary()) + .offset; + + final TextRange rangeToDelete = TextSelection( + baseOffset: intent.forward + ? atomicBoundary.getLeadingTextBoundaryAt(selection.baseOffset) ?? + text.length + : atomicBoundary + .getTrailingTextBoundaryAt(selection.baseOffset - 1) ?? + 0, + extentOffset: target, + ); + final ReplaceTextIntent replaceTextIntent = ReplaceTextIntent( + value, + '', + rangeToDelete, + SelectionChangedCause.keyboard, + ); + _hideToolbarIfTextChanged(replaceTextIntent); + return Actions.invoke(context!, replaceTextIntent); + } + + @override + bool get isActionEnabled => + !state.widget.readOnly && state._value.selection.isValid; +} + +class _UpdateTextSelectionAction + extends ContextAction { + _UpdateTextSelectionAction( + this.state, + this.getTextBoundary, + this.applyTextBoundary, { + required this.ignoreNonCollapsedSelection, + this.isExpand = false, + this.extentAtIndex = false, + }); + + final EditableTextState state; + final bool ignoreNonCollapsedSelection; + final bool isExpand; + final bool extentAtIndex; + final TextBoundary Function() getTextBoundary; + final _ApplyTextBoundary applyTextBoundary; + + static const int NEWLINE_CODE_UNIT = 10; + + // Returns true iff the given position is at a wordwrap boundary in the + // upstream position. + bool _isAtWordwrapUpstream(TextPosition position) { + final TextPosition end = TextPosition( + offset: state.renderEditable.getLineAtOffset(position).end, + affinity: TextAffinity.upstream, + ); + return end == position && + end.offset != state.textEditingValue.text.length && + state.textEditingValue.text.codeUnitAt(position.offset) != + NEWLINE_CODE_UNIT; + } + + // Returns true if the given position at a wordwrap boundary in the + // downstream position. + bool _isAtWordwrapDownstream(TextPosition position) { + final TextPosition start = TextPosition( + offset: state.renderEditable.getLineAtOffset(position).start, + ); + return start == position && + start.offset != 0 && + state.textEditingValue.text.codeUnitAt(position.offset - 1) != + NEWLINE_CODE_UNIT; + } + + @override + Object? invoke(T intent, [BuildContext? context]) { + final TextSelection selection = state._value.selection; + assert(selection.isValid); + + final bool collapseSelection = + intent.collapseSelection || !state.widget.selectionEnabled; + if (!selection.isCollapsed && + !ignoreNonCollapsedSelection && + collapseSelection) { + return Actions.invoke( + context!, + UpdateSelectionIntent( + state._value, + TextSelection.collapsed( + offset: intent.forward ? selection.end : selection.start), + SelectionChangedCause.keyboard, + ), + ); + } + + TextPosition extent = selection.extent; + // If continuesAtWrap is true extent and is at the relevant wordwrap, then + // move it just to the other side of the wordwrap. + if (intent.continuesAtWrap) { + if (intent.forward && _isAtWordwrapUpstream(extent)) { + extent = TextPosition(offset: extent.offset); + } else if (!intent.forward && _isAtWordwrapDownstream(extent)) { + extent = TextPosition( + offset: extent.offset, affinity: TextAffinity.upstream); + } + } + + final bool shouldTargetBase = isExpand && + (intent.forward + ? selection.baseOffset > selection.extentOffset + : selection.baseOffset < selection.extentOffset); + final TextPosition newExtent = applyTextBoundary( + shouldTargetBase ? selection.base : extent, + intent.forward, + getTextBoundary(), + ); + final TextSelection newSelection = collapseSelection || + (!isExpand && newExtent.offset == selection.baseOffset) + ? TextSelection.fromPosition(newExtent) + : isExpand + ? selection.expandTo( + newExtent, extentAtIndex || selection.isCollapsed) + : selection.extendTo(newExtent); + + final bool shouldCollapseToBase = intent.collapseAtReversal && + (selection.baseOffset - selection.extentOffset) * + (selection.baseOffset - newSelection.extentOffset) < + 0; + final TextSelection newRange = shouldCollapseToBase + ? TextSelection.fromPosition(selection.base) + : newSelection; + return Actions.invoke( + context!, + UpdateSelectionIntent( + state._value, newRange, SelectionChangedCause.keyboard), + ); + } + + @override + bool get isActionEnabled => state._value.selection.isValid; +} + +class _UpdateTextSelectionVerticallyAction< + T extends DirectionalCaretMovementIntent> extends ContextAction { + _UpdateTextSelectionVerticallyAction(this.state); + + final EditableTextState state; + + VerticalCaretMovementRun? _verticalMovementRun; + TextSelection? _runSelection; + + void stopCurrentVerticalRunIfSelectionChanges() { + final TextSelection? runSelection = _runSelection; + if (runSelection == null) { + assert(_verticalMovementRun == null); + return; + } + _runSelection = state._value.selection; + final TextSelection currentSelection = state.widget.controller.selection; + final bool continueCurrentRun = currentSelection.isValid && + currentSelection.isCollapsed && + currentSelection.baseOffset == runSelection.baseOffset && + currentSelection.extentOffset == runSelection.extentOffset; + if (!continueCurrentRun) { + _verticalMovementRun = null; + _runSelection = null; + } + } + + @override + void invoke(T intent, [BuildContext? context]) { + assert(state._value.selection.isValid); + + final bool collapseSelection = + intent.collapseSelection || !state.widget.selectionEnabled; + final TextEditingValue value = state._textEditingValueforTextLayoutMetrics; + if (!value.selection.isValid) { + return; + } + + if (_verticalMovementRun?.isValid == false) { + _verticalMovementRun = null; + _runSelection = null; + } + + final VerticalCaretMovementRun currentRun = _verticalMovementRun ?? + state.renderEditable + .startVerticalCaretMovement(state.renderEditable.selection!.extent); + + final bool shouldMove = intent + is ExtendSelectionVerticallyToAdjacentPageIntent + ? currentRun.moveByOffset( + (intent.forward ? 1.0 : -1.0) * state.renderEditable.size.height, + ) + : intent.forward + ? currentRun.moveNext() + : currentRun.movePrevious(); + final TextPosition newExtent = shouldMove + ? currentRun.current + : intent.forward + ? TextPosition(offset: value.text.length) + : const TextPosition(offset: 0); + final TextSelection newSelection = collapseSelection + ? TextSelection.fromPosition(newExtent) + : value.selection.extendTo(newExtent); + + Actions.invoke( + context!, + UpdateSelectionIntent( + value, newSelection, SelectionChangedCause.keyboard), + ); + if (state._value.selection == newSelection) { + _verticalMovementRun = currentRun; + _runSelection = newSelection; + } + } + + @override + bool get isActionEnabled => state._value.selection.isValid; +} + +class _SelectAllAction extends ContextAction { + _SelectAllAction(this.state); + + final EditableTextState state; + + @override + Object? invoke(SelectAllTextIntent intent, [BuildContext? context]) { + return Actions.invoke( + context!, + UpdateSelectionIntent( + state._value, + TextSelection(baseOffset: 0, extentOffset: state._value.text.length), + intent.cause, + ), + ); + } + + @override + bool get isActionEnabled => state.widget.selectionEnabled; +} + +class _CopySelectionAction extends ContextAction { + _CopySelectionAction(this.state); + + final EditableTextState state; + + @override + void invoke(CopySelectionTextIntent intent, [BuildContext? context]) { + if (intent.collapseSelection) { + state.cutSelection(intent.cause); + } else { + state.copySelection(intent.cause); + } + } + + @override + bool get isActionEnabled => + state._value.selection.isValid && !state._value.selection.isCollapsed; +} + +/// A [ClipboardStatusNotifier] whose [value] is hardcoded to +/// [ClipboardStatus.pasteable]. +/// +/// Useful to avoid showing a permission dialog on web, which happens when +/// [Clipboard.hasStrings] is called. +class _WebClipboardStatusNotifier extends ClipboardStatusNotifier { + @override + ClipboardStatus value = ClipboardStatus.pasteable; + + @override + Future update() { + return Future.value(); + } +} + +class _EditableTextTapOutsideAction + extends ContextAction { + _EditableTextTapOutsideAction(); + + @override + void invoke(EditableTextTapOutsideIntent intent, [BuildContext? context]) { + // The focus dropping behavior is only present on desktop platforms. + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.iOS: + case TargetPlatform.fuchsia: + // On mobile platforms, we don't unfocus on touch events unless they're + // in the web browser, but we do unfocus for all other kinds of events. + switch (intent.pointerDownEvent.kind) { + case ui.PointerDeviceKind.touch: + if (kIsWeb) { + intent.focusNode.unfocus(); + } + case ui.PointerDeviceKind.mouse: + case ui.PointerDeviceKind.stylus: + case ui.PointerDeviceKind.invertedStylus: + case ui.PointerDeviceKind.unknown: + intent.focusNode.unfocus(); + case ui.PointerDeviceKind.trackpad: + throw UnimplementedError( + 'Unexpected pointer down event for trackpad'); + } + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + intent.focusNode.unfocus(); + } + } +} + +class _EditableTextTapUpOutsideAction + extends ContextAction { + _EditableTextTapUpOutsideAction(); + + @override + void invoke(EditableTextTapUpOutsideIntent intent, [BuildContext? context]) { + // The default action is a no-op. + } +} diff --git a/lib/common/widgets/text_field/spell_check.dart b/lib/common/widgets/text_field/spell_check.dart new file mode 100644 index 00000000..f2b57893 --- /dev/null +++ b/lib/common/widgets/text_field/spell_check.dart @@ -0,0 +1,461 @@ +// 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. + +/// @docImport 'editable_text.dart'; +library; + +import 'dart:math' as math; + +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; + +/// Controls how spell check is performed for text input. +/// +/// This configuration determines the [SpellCheckService] used to fetch the +/// [List] spell check results and the [TextStyle] used to +/// mark misspelled words within text input. +@immutable +class SpellCheckConfiguration { + /// Creates a configuration that specifies the service and suggestions handler + /// for spell check. + const SpellCheckConfiguration({ + this.spellCheckService, + this.misspelledSelectionColor, + this.misspelledTextStyle, + this.spellCheckSuggestionsToolbarBuilder, + }) : _spellCheckEnabled = true; + + /// Creates a configuration that disables spell check. + const SpellCheckConfiguration.disabled() + : _spellCheckEnabled = false, + spellCheckService = null, + spellCheckSuggestionsToolbarBuilder = null, + misspelledTextStyle = null, + misspelledSelectionColor = null; + + /// The service used to fetch spell check results for text input. + final SpellCheckService? spellCheckService; + + /// The color the paint the selection highlight when spell check is showing + /// suggestions for a misspelled word. + /// + /// For example, on iOS, the selection appears red while the spell check menu + /// is showing. + final Color? misspelledSelectionColor; + + /// Style used to indicate misspelled words. + /// + /// This is nullable to allow style-specific wrappers of [EditableText] + /// to infer this, but this must be specified if this configuration is + /// provided directly to [EditableText] or its construction will fail with an + /// 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. + bool get spellCheckEnabled => _spellCheckEnabled; + + /// Returns a copy of the current [SpellCheckConfiguration] instance with + /// specified overrides. + SpellCheckConfiguration copyWith({ + SpellCheckService? spellCheckService, + Color? misspelledSelectionColor, + TextStyle? misspelledTextStyle, + EditableTextContextMenuBuilder? spellCheckSuggestionsToolbarBuilder, + }) { + if (!_spellCheckEnabled) { + // A new configuration should be constructed to enable spell check. + return const SpellCheckConfiguration.disabled(); + } + + return SpellCheckConfiguration( + spellCheckService: spellCheckService ?? this.spellCheckService, + misspelledSelectionColor: + misspelledSelectionColor ?? this.misspelledSelectionColor, + misspelledTextStyle: misspelledTextStyle ?? this.misspelledTextStyle, + spellCheckSuggestionsToolbarBuilder: + spellCheckSuggestionsToolbarBuilder ?? + this.spellCheckSuggestionsToolbarBuilder, + ); + } + + @override + String toString() { + return '${objectRuntimeType(this, 'SpellCheckConfiguration')}(' + '${_spellCheckEnabled ? 'enabled' : 'disabled'}, ' + 'service: $spellCheckService, ' + 'text style: $misspelledTextStyle, ' + 'toolbar builder: $spellCheckSuggestionsToolbarBuilder' + ')'; + } + + @override + bool operator ==(Object other) { + if (other.runtimeType != runtimeType) { + return false; + } + return other is SpellCheckConfiguration && + other.spellCheckService == spellCheckService && + other.misspelledTextStyle == misspelledTextStyle && + other.spellCheckSuggestionsToolbarBuilder == + spellCheckSuggestionsToolbarBuilder && + other._spellCheckEnabled == _spellCheckEnabled; + } + + @override + int get hashCode => Object.hash( + spellCheckService, + misspelledTextStyle, + spellCheckSuggestionsToolbarBuilder, + _spellCheckEnabled, + ); +} + +// Methods for displaying spell check results: + +/// Adjusts spell check results to correspond to [newText] if the only results +/// that the handler has access to are the [results] corresponding to +/// [resultsText]. +/// +/// Used in the case where the request for the spell check results of the +/// [newText] is lagging in order to avoid display of incorrect results. +List _correctSpellCheckResults( + String newText, + String resultsText, + List results, +) { + final List correctedSpellCheckResults = []; + int spanPointer = 0; + int offset = 0; + + // Assumes that the order of spans has not been jumbled for optimization + // purposes, and will only search since the previously found span. + int searchStart = 0; + + while (spanPointer < results.length) { + final SuggestionSpan currentSpan = results[spanPointer]; + final String currentSpanText = resultsText.substring( + currentSpan.range.start, + currentSpan.range.end, + ); + final int spanLength = currentSpan.range.end - currentSpan.range.start; + + // Try finding SuggestionSpan from resultsText in new text. + final String escapedText = RegExp.escape(currentSpanText); + final RegExp currentSpanTextRegexp = RegExp('\\b$escapedText\\b'); + final int foundIndex = + newText.substring(searchStart).indexOf(currentSpanTextRegexp); + + // Check whether word was found exactly where expected or elsewhere in the newText. + final bool currentSpanFoundExactly = + currentSpan.range.start == foundIndex + searchStart; + final bool currentSpanFoundExactlyWithOffset = + currentSpan.range.start + offset == foundIndex + searchStart; + final bool currentSpanFoundElsewhere = foundIndex >= 0; + + if (currentSpanFoundExactly || currentSpanFoundExactlyWithOffset) { + // currentSpan was found at the same index in newText and resultsText + // or at the same index with the previously calculated adjustment by + // the offset value, so apply it to new text by adding it to the list of + // corrected results. + final SuggestionSpan adjustedSpan = SuggestionSpan( + TextRange( + start: currentSpan.range.start + offset, + end: currentSpan.range.end + offset), + currentSpan.suggestions, + ); + + // Start search for the next misspelled word at the end of currentSpan. + searchStart = + math.min(currentSpan.range.end + 1 + offset, newText.length); + correctedSpellCheckResults.add(adjustedSpan); + } else if (currentSpanFoundElsewhere) { + // Word was pushed forward but not modified. + final int adjustedSpanStart = searchStart + foundIndex; + final int adjustedSpanEnd = adjustedSpanStart + spanLength; + final SuggestionSpan adjustedSpan = SuggestionSpan( + TextRange(start: adjustedSpanStart, end: adjustedSpanEnd), + currentSpan.suggestions, + ); + + // Start search for the next misspelled word at the end of the + // adjusted currentSpan. + searchStart = math.min(adjustedSpanEnd + 1, newText.length); + // Adjust offset to reflect the difference between where currentSpan + // was positioned in resultsText versus in newText. + offset = adjustedSpanStart - currentSpan.range.start; + correctedSpellCheckResults.add(adjustedSpan); + } + spanPointer++; + } + return correctedSpellCheckResults; +} + +/// Builds the [TextSpan] tree given the current state of the text input and +/// spell check results. +/// +/// The [value] is the current [TextEditingValue] requested to be rendered +/// by a text input widget. The [composingWithinCurrentTextRange] value +/// represents whether or not there is a valid composing region in the +/// [value]. The [style] is the [TextStyle] to render the [value]'s text with, +/// and the [misspelledTextStyle] is the [TextStyle] to render misspelled +/// words within the [value]'s text with. The [spellCheckResults] are the +/// results of spell checking the [value]'s text. +TextSpan buildTextSpanWithSpellCheckSuggestions( + TextEditingValue value, + bool composingWithinCurrentTextRange, + TextStyle? style, + TextStyle misspelledTextStyle, + SpellCheckResults spellCheckResults, +) { + List spellCheckResultsSpans = + spellCheckResults.suggestionSpans; + final String spellCheckResultsText = spellCheckResults.spellCheckedText; + + if (spellCheckResultsText != value.text) { + spellCheckResultsSpans = _correctSpellCheckResults( + value.text, + spellCheckResultsText, + spellCheckResultsSpans, + ); + } + + // We will draw the TextSpan tree based on the composing region, if it is + // available. + // TODO(camsim99): The two separate strategies for building TextSpan trees + // based on the availability of a composing region should be merged: + // https://github.com/flutter/flutter/issues/124142. + final bool shouldConsiderComposingRegion = + defaultTargetPlatform == TargetPlatform.android; + if (shouldConsiderComposingRegion) { + return TextSpan( + style: style, + children: _buildSubtreesWithComposingRegion( + spellCheckResultsSpans, + value, + style, + misspelledTextStyle, + composingWithinCurrentTextRange, + ), + ); + } + + return TextSpan( + style: style, + children: _buildSubtreesWithoutComposingRegion( + spellCheckResultsSpans, + value, + style, + misspelledTextStyle, + value.selection.baseOffset, + ), + ); +} + +/// Builds the [TextSpan] tree for spell check without considering the composing +/// region. Instead, uses the cursor to identify the word that's actively being +/// edited and shouldn't be spell checked. This is useful for platforms and IMEs +/// that don't use the composing region for the active word. +List _buildSubtreesWithoutComposingRegion( + List? spellCheckSuggestions, + TextEditingValue value, + TextStyle? style, + TextStyle misspelledStyle, + int cursorIndex, +) { + final List textSpanTreeChildren = []; + + int textPointer = 0; + int currentSpanPointer = 0; + int endIndex; + final String text = value.text; + final TextStyle misspelledJointStyle = + style?.merge(misspelledStyle) ?? misspelledStyle; + bool cursorInCurrentSpan = false; + + // Add text interwoven with any misspelled words to the tree. + if (spellCheckSuggestions != null) { + while (textPointer < text.length && + currentSpanPointer < spellCheckSuggestions.length) { + final SuggestionSpan currentSpan = + spellCheckSuggestions[currentSpanPointer]; + + if (currentSpan.range.start > textPointer) { + endIndex = currentSpan.range.start < text.length + ? currentSpan.range.start + : text.length; + textSpanTreeChildren.add( + TextSpan(style: style, text: text.substring(textPointer, endIndex)), + ); + textPointer = endIndex; + } else { + endIndex = currentSpan.range.end < text.length + ? currentSpan.range.end + : text.length; + cursorInCurrentSpan = currentSpan.range.start <= cursorIndex && + currentSpan.range.end >= cursorIndex; + textSpanTreeChildren.add( + TextSpan( + style: cursorInCurrentSpan ? style : misspelledJointStyle, + text: text.substring(currentSpan.range.start, endIndex), + ), + ); + + textPointer = endIndex; + currentSpanPointer++; + } + } + } + + // Add any remaining text to the tree if applicable. + if (textPointer < text.length) { + textSpanTreeChildren.add( + TextSpan(style: style, text: text.substring(textPointer, text.length)), + ); + } + + return textSpanTreeChildren; +} + +/// Builds [TextSpan] subtree for text with misspelled words with logic based on +/// a valid composing region. +List _buildSubtreesWithComposingRegion( + List? spellCheckSuggestions, + TextEditingValue value, + TextStyle? style, + TextStyle misspelledStyle, + bool composingWithinCurrentTextRange, +) { + final List textSpanTreeChildren = []; + + int textPointer = 0; + int currentSpanPointer = 0; + int endIndex; + SuggestionSpan currentSpan; + final String text = value.text; + final TextRange composingRegion = value.composing; + final TextStyle composingTextStyle = + style?.merge(const TextStyle(decoration: TextDecoration.underline)) ?? + const TextStyle(decoration: TextDecoration.underline); + final TextStyle misspelledJointStyle = + style?.merge(misspelledStyle) ?? misspelledStyle; + bool textPointerWithinComposingRegion = false; + bool currentSpanIsComposingRegion = false; + + // Add text interwoven with any misspelled words to the tree. + if (spellCheckSuggestions != null) { + while (textPointer < text.length && + currentSpanPointer < spellCheckSuggestions.length) { + currentSpan = spellCheckSuggestions[currentSpanPointer]; + + if (currentSpan.range.start > textPointer) { + endIndex = currentSpan.range.start < text.length + ? currentSpan.range.start + : text.length; + textPointerWithinComposingRegion = + composingRegion.start >= textPointer && + composingRegion.end <= endIndex && + !composingWithinCurrentTextRange; + + if (textPointerWithinComposingRegion) { + _addComposingRegionTextSpans( + textSpanTreeChildren, + text, + textPointer, + composingRegion, + style, + composingTextStyle, + ); + textSpanTreeChildren.add( + TextSpan( + style: style, + text: text.substring(composingRegion.end, endIndex)), + ); + } else { + textSpanTreeChildren.add( + TextSpan(style: style, text: text.substring(textPointer, endIndex)), + ); + } + + textPointer = endIndex; + } else { + endIndex = currentSpan.range.end < text.length + ? currentSpan.range.end + : text.length; + currentSpanIsComposingRegion = textPointer >= composingRegion.start && + endIndex <= composingRegion.end && + !composingWithinCurrentTextRange; + textSpanTreeChildren.add( + TextSpan( + style: currentSpanIsComposingRegion + ? composingTextStyle + : misspelledJointStyle, + text: text.substring(currentSpan.range.start, endIndex), + ), + ); + + textPointer = endIndex; + currentSpanPointer++; + } + } + } + + // Add any remaining text to the tree if applicable. + if (textPointer < text.length) { + if (textPointer < composingRegion.start && + !composingWithinCurrentTextRange) { + _addComposingRegionTextSpans( + textSpanTreeChildren, + text, + textPointer, + composingRegion, + style, + composingTextStyle, + ); + + if (composingRegion.end != text.length) { + textSpanTreeChildren.add( + TextSpan( + style: style, + text: text.substring(composingRegion.end, text.length)), + ); + } + } else { + textSpanTreeChildren.add( + TextSpan(style: style, text: text.substring(textPointer, text.length)), + ); + } + } + + return textSpanTreeChildren; +} + +/// Helper method to create [TextSpan] tree children for specified range of +/// text up to and including the composing region. +void _addComposingRegionTextSpans( + List treeChildren, + String text, + int start, + TextRange composingRegion, + TextStyle? style, + TextStyle composingTextStyle, +) { + treeChildren.add(TextSpan( + style: style, text: text.substring(start, composingRegion.start))); + treeChildren.add( + TextSpan( + style: composingTextStyle, + text: text.substring(composingRegion.start, composingRegion.end), + ), + ); +} diff --git a/lib/common/widgets/text_field/spell_check_suggestions_toolbar.dart b/lib/common/widgets/text_field/spell_check_suggestions_toolbar.dart new file mode 100644 index 00000000..7c79f68f --- /dev/null +++ b/lib/common/widgets/text_field/spell_check_suggestions_toolbar.dart @@ -0,0 +1,259 @@ +// 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:PiliPlus/common/widgets/text_field/editable_text.dart'; +import 'package:flutter/cupertino.dart' hide EditableText, EditableTextState; +import 'package:flutter/material.dart' hide EditableText, EditableTextState; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/services.dart' + show SelectionChangedCause, SuggestionSpan; + +// 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 maximum number of suggestions in the toolbar is 3, plus a delete button. +const int _kMaxSuggestions = 3; + +/// 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. +/// +/// See also: +/// +/// * [CupertinoSpellCheckSuggestionsToolbar], which is similar but builds an +/// iOS-style spell check toolbar. +class SpellCheckSuggestionsToolbar extends StatelessWidget { + /// Constructs a [SpellCheckSuggestionsToolbar]. + /// + /// [buttonItems] must not contain more than four items, generally three + /// suggestions and one delete button. + const SpellCheckSuggestionsToolbar( + {super.key, required this.anchor, required this.buttonItems}) + : assert(buttonItems.length <= _kMaxSuggestions + 1); + + /// Constructs a [SpellCheckSuggestionsToolbar] with the default children for + /// an [EditableText]. + /// + /// See also: + /// * [CupertinoSpellCheckSuggestionsToolbar.editableText], which is similar + /// but builds an iOS-style toolbar. + SpellCheckSuggestionsToolbar.editableText({ + super.key, + required EditableTextState editableTextState, + }) : buttonItems = + buildButtonItems(editableTextState) ?? [], + anchor = getToolbarAnchor(editableTextState.contextMenuAnchors); + + /// {@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. + /// + /// Must not contain more than four items, typically three suggestions and a + /// delete button. + /// + /// See also: + /// + /// * [AdaptiveTextSelectionToolbar.buttonItems], the list of + /// [ContextMenuButtonItem]s that are used to build the buttons of the + /// text selection toolbar. + /// * [CupertinoSpellCheckSuggestionsToolbar.buttonItems], the list of + /// [ContextMenuButtonItem]s used to build the Cupertino style spell check + /// suggestions toolbar. + final List buttonItems; + + /// Builds the button items for the toolbar based on the available + /// spell check suggestions. + static List? buildButtonItems( + EditableTextState editableTextState) { + // Determine if composing region is misspelled. + final SuggestionSpan? spanAtCursorIndex = + editableTextState.findSuggestionSpanAtCursorIndex( + editableTextState.currentTextEditingValue.selection.baseOffset, + ); + + if (spanAtCursorIndex == null) { + return null; + } + + final List buttonItems = []; + + // Build suggestion buttons. + for (final String suggestion + in spanAtCursorIndex.suggestions.take(_kMaxSuggestions)) { + buttonItems.add( + ContextMenuButtonItem( + onPressed: () { + if (!editableTextState.mounted) { + return; + } + _replaceText( + editableTextState, suggestion, spanAtCursorIndex.range); + }, + label: suggestion, + ), + ); + } + + // Build delete button. + final ContextMenuButtonItem deleteButton = ContextMenuButtonItem( + onPressed: () { + if (!editableTextState.mounted) { + return; + } + _replaceText(editableTextState, '', + editableTextState.currentTextEditingValue.composing); + }, + type: ContextMenuButtonType.delete, + ); + buttonItems.add(deleteButton); + + return buttonItems; + } + + static void _replaceText( + EditableTextState editableTextState, + String text, + TextRange replacementRange, + ) { + // Replacement cannot be performed if the text is read only or obscured. + assert(!editableTextState.widget.readOnly && + !editableTextState.widget.obscureText); + + final TextEditingValue newValue = + editableTextState.textEditingValue.replaced( + replacementRange, + text, + ); + editableTextState.userUpdateTextEditingValue( + newValue, SelectionChangedCause.toolbar); + + // Schedule a call to bringIntoView() after renderEditable updates. + SchedulerBinding.instance.addPostFrameCallback((Duration duration) { + if (editableTextState.mounted) { + editableTextState + .bringIntoView(editableTextState.textEditingValue.selection.extent); + } + }, debugLabel: 'SpellCheckerSuggestionsToolbar.bringIntoView'); + editableTextState.hideToolbar(); + } + + /// Determines the Offset that the toolbar will be anchored to. + static Offset getToolbarAnchor(TextSelectionToolbarAnchors anchors) { + // Since this will be positioned below the anchor point, use the secondary + // anchor by default. + return anchors.secondaryAnchor == null + ? anchors.primaryAnchor + : anchors.secondaryAnchor!; + } + + /// Builds the toolbar buttons based on the [buttonItems]. + List _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) { + if (buttonItems.isEmpty) { + return const SizedBox.shrink(); + } + + // Adjust toolbar height if needed. + final double spellCheckSuggestionsToolbarHeight = + _kDefaultToolbarHeight - (48.0 * (4 - buttonItems.length)); + // Incorporate the padding distance between the content and toolbar. + final MediaQueryData mediaQueryData = MediaQuery.of(context); + final double softKeyboardViewInsetsBottom = + mediaQueryData.viewInsets.bottom; + final double paddingAbove = mediaQueryData.padding.top + + CupertinoTextSelectionToolbar.kToolbarScreenPadding; + // Makes up for the Padding. + final Offset localAdjustment = Offset( + CupertinoTextSelectionToolbar.kToolbarScreenPadding, + paddingAbove, + ); + + return Padding( + padding: EdgeInsets.fromLTRB( + CupertinoTextSelectionToolbar.kToolbarScreenPadding, + paddingAbove, + CupertinoTextSelectionToolbar.kToolbarScreenPadding, + CupertinoTextSelectionToolbar.kToolbarScreenPadding + + softKeyboardViewInsetsBottom, + ), + child: CustomSingleChildLayout( + delegate: SpellCheckSuggestionsToolbarLayoutDelegate( + anchor: anchor - 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: _SpellCheckSuggestionsToolbarContainer( + height: spellCheckSuggestionsToolbarHeight, + children: [..._buildToolbarButtons(context)], + ), + ), + ), + ); + } +} + +/// The Material-styled toolbar outline for the spell check suggestions +/// toolbar. +class _SpellCheckSuggestionsToolbarContainer extends StatelessWidget { + const _SpellCheckSuggestionsToolbarContainer( + {required this.height, required this.children}); + + final double height; + final List children; + + @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: SizedBox( + // This width was eyeballed on a Pixel 4 emulator running Android + // API 31 for the SpellCheckSuggestionsToolbar. + width: 165.0, + height: height, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: children, + ), + ), + ); + } +} diff --git a/lib/common/widgets/text_field/system_context_menu.dart b/lib/common/widgets/text_field/system_context_menu.dart new file mode 100644 index 00000000..9969d01a --- /dev/null +++ b/lib/common/widgets/text_field/system_context_menu.dart @@ -0,0 +1,424 @@ +// 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. + +/// @docImport 'package:flutter/material.dart'; +library; + +import 'package:PiliPlus/common/widgets/text_field/editable_text.dart'; +import 'package:flutter/material.dart' hide EditableText, EditableTextState; +import 'package:flutter/services.dart'; + +/// Displays the system context menu on top of the Flutter view. +/// +/// Currently, only supports iOS 16.0 and above and displays nothing on other +/// platforms. +/// +/// The context menu is the menu that appears, for example, when doing text +/// selection. Flutter typically draws this menu itself, but this class deals +/// with the platform-rendered context menu instead. +/// +/// There can only be one system context menu visible at a time. Building this +/// widget when the system context menu is already visible will hide the old one +/// and display this one. A system context menu that is hidden is informed via +/// [onSystemHide]. +/// +/// Pass [items] to specify the buttons that will appear in the menu. Any items +/// without a title will be given a default title from [WidgetsLocalizations]. +/// +/// By default, [items] will be set to the result of [getDefaultItems]. This +/// method considers the state of the [EditableTextState] so that, for example, +/// it will only include [IOSSystemContextMenuItemCopy] if there is currently a +/// selection to copy. +/// +/// To check if the current device supports showing the system context menu, +/// call [isSupported]. +/// +/// {@tool dartpad} +/// This example shows how to create a [TextField] that uses the system context +/// menu where supported and does not show a system notification when the user +/// presses the "Paste" button. +/// +/// ** See code in examples/api/lib/widgets/system_context_menu/system_context_menu.0.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [SystemContextMenuController], which directly controls the hiding and +/// showing of the system context menu. +class SystemContextMenu extends StatefulWidget { + /// Creates an instance of [SystemContextMenu] that points to the given + /// [anchor]. + const SystemContextMenu._({ + super.key, + required this.anchor, + required this.items, + this.onSystemHide, + }); + + /// Creates an instance of [SystemContextMenu] for the field indicated by the + /// given [EditableTextState]. + factory SystemContextMenu.editableText({ + Key? key, + required EditableTextState editableTextState, + List? items, + }) { + final ( + startGlyphHeight: double startGlyphHeight, + endGlyphHeight: double endGlyphHeight + ) = editableTextState.getGlyphHeights(); + + return SystemContextMenu._( + key: key, + anchor: TextSelectionToolbarAnchors.getSelectionRect( + editableTextState.renderEditable, + startGlyphHeight, + endGlyphHeight, + editableTextState.renderEditable.getEndpointsForSelection( + editableTextState.textEditingValue.selection, + ), + ), + items: items ?? getDefaultItems(editableTextState), + onSystemHide: editableTextState.hideToolbar, + ); + } + + /// The [Rect] that the context menu should point to. + final Rect anchor; + + /// A list of the items to be displayed in the system context menu. + /// + /// When passed, items will be shown regardless of the state of text input. + /// For example, [IOSSystemContextMenuItemCopy] will produce a copy button + /// even when there is no selection to copy. Use [EditableTextState] and/or + /// the result of [getDefaultItems] to add and remove items based on the state + /// of the input. + /// + /// Defaults to the result of [getDefaultItems]. + final List items; + + /// Called when the system hides this context menu. + /// + /// For example, tapping outside of the context menu typically causes the + /// system to hide the menu. + /// + /// This is not called when showing a new system context menu causes another + /// to be hidden. + final VoidCallback? onSystemHide; + + /// Whether the current device supports showing the system context menu. + /// + /// Currently, this is only supported on newer versions of iOS. + static bool isSupported(BuildContext context) { + return MediaQuery.maybeSupportsShowingSystemContextMenu(context) ?? false; + } + + /// The default [items] for the given [EditableTextState]. + /// + /// For example, [IOSSystemContextMenuItemCopy] will only be included when the + /// field represented by the [EditableTextState] has a selection. + /// + /// See also: + /// + /// * [EditableTextState.contextMenuButtonItems], which provides the default + /// [ContextMenuButtonItem]s for the Flutter-rendered context menu. + static List getDefaultItems( + EditableTextState editableTextState) { + return [ + if (editableTextState.copyEnabled) const IOSSystemContextMenuItemCopy(), + if (editableTextState.cutEnabled) const IOSSystemContextMenuItemCut(), + if (editableTextState.pasteEnabled) const IOSSystemContextMenuItemPaste(), + if (editableTextState.selectAllEnabled) + const IOSSystemContextMenuItemSelectAll(), + if (editableTextState.lookUpEnabled) + const IOSSystemContextMenuItemLookUp(), + if (editableTextState.searchWebEnabled) + const IOSSystemContextMenuItemSearchWeb(), + ]; + } + + @override + State createState() => _SystemContextMenuState(); +} + +class _SystemContextMenuState extends State { + late final SystemContextMenuController _systemContextMenuController; + + @override + void initState() { + super.initState(); + _systemContextMenuController = + SystemContextMenuController(onSystemHide: widget.onSystemHide); + } + + @override + void dispose() { + _systemContextMenuController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + assert(SystemContextMenu.isSupported(context)); + + if (widget.items.isNotEmpty) { + final WidgetsLocalizations localizations = + WidgetsLocalizations.of(context); + final List itemDatas = widget.items + .map((IOSSystemContextMenuItem item) => item.getData(localizations)) + .toList(); + _systemContextMenuController.showWithItems(widget.anchor, itemDatas); + } + + return const SizedBox.shrink(); + } +} + +/// Describes a context menu button that will be rendered in the iOS system +/// context menu and not by Flutter itself. +/// +/// See also: +/// +/// * [SystemContextMenu], a widget that can be used to display the system +/// context menu. +/// * [IOSSystemContextMenuItemData], which performs a similar role but at the +/// method channel level and mirrors the requirements of the method channel +/// API. +/// * [ContextMenuButtonItem], which performs a similar role for Flutter-drawn +/// context menus. +@immutable +sealed class IOSSystemContextMenuItem { + const IOSSystemContextMenuItem(); + + /// The text to display to the user. + /// + /// Not exposed for some built-in menu items whose title is always set by the + /// platform. + String? get title => null; + + /// Returns the representation of this class used by method channels. + IOSSystemContextMenuItemData getData(WidgetsLocalizations localizations); + + @override + int get hashCode => title.hashCode; + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + return other is IOSSystemContextMenuItem && other.title == title; + } +} + +/// Creates an instance of [IOSSystemContextMenuItem] for the system's built-in +/// copy button. +/// +/// Should only appear when there is a selection that can be copied. +/// +/// The title and action are both handled by the platform. +/// +/// See also: +/// +/// * [SystemContextMenu], a widget that can be used to display the system +/// context menu. +/// * [IOSSystemContextMenuItemDataCopy], which specifies the data to be sent to +/// the platform for this same button. +final class IOSSystemContextMenuItemCopy extends IOSSystemContextMenuItem { + /// Creates an instance of [IOSSystemContextMenuItemCopy]. + const IOSSystemContextMenuItemCopy(); + + @override + IOSSystemContextMenuItemDataCopy getData(WidgetsLocalizations localizations) { + return const IOSSystemContextMenuItemDataCopy(); + } +} + +/// Creates an instance of [IOSSystemContextMenuItem] for the system's built-in +/// cut button. +/// +/// Should only appear when there is a selection that can be cut. +/// +/// The title and action are both handled by the platform. +/// +/// See also: +/// +/// * [SystemContextMenu], a widget that can be used to display the system +/// context menu. +/// * [IOSSystemContextMenuItemDataCut], which specifies the data to be sent to +/// the platform for this same button. +final class IOSSystemContextMenuItemCut extends IOSSystemContextMenuItem { + /// Creates an instance of [IOSSystemContextMenuItemCut]. + const IOSSystemContextMenuItemCut(); + + @override + IOSSystemContextMenuItemDataCut getData(WidgetsLocalizations localizations) { + return const IOSSystemContextMenuItemDataCut(); + } +} + +/// Creates an instance of [IOSSystemContextMenuItem] for the system's built-in +/// paste button. +/// +/// Should only appear when the field can receive pasted content. +/// +/// The title and action are both handled by the platform. +/// +/// See also: +/// +/// * [SystemContextMenu], a widget that can be used to display the system +/// context menu. +/// * [IOSSystemContextMenuItemDataPaste], which specifies the data to be sent +/// to the platform for this same button. +final class IOSSystemContextMenuItemPaste extends IOSSystemContextMenuItem { + /// Creates an instance of [IOSSystemContextMenuItemPaste]. + const IOSSystemContextMenuItemPaste(); + + @override + IOSSystemContextMenuItemDataPaste getData( + WidgetsLocalizations localizations) { + return const IOSSystemContextMenuItemDataPaste(); + } +} + +/// Creates an instance of [IOSSystemContextMenuItem] for the system's built-in +/// select all button. +/// +/// Should only appear when the field can have its selection changed. +/// +/// The title and action are both handled by the platform. +/// +/// See also: +/// +/// * [SystemContextMenu], a widget that can be used to display the system +/// context menu. +/// * [IOSSystemContextMenuItemDataSelectAll], which specifies the data to be +/// sent to the platform for this same button. +final class IOSSystemContextMenuItemSelectAll extends IOSSystemContextMenuItem { + /// Creates an instance of [IOSSystemContextMenuItemSelectAll]. + const IOSSystemContextMenuItemSelectAll(); + + @override + IOSSystemContextMenuItemDataSelectAll getData( + WidgetsLocalizations localizations) { + return const IOSSystemContextMenuItemDataSelectAll(); + } +} + +/// Creates an instance of [IOSSystemContextMenuItem] for the +/// system's built-in look up button. +/// +/// Should only appear when content is selected. +/// +/// The [title] is optional, but it must be specified before being sent to the +/// platform. Typically it should be set to +/// [WidgetsLocalizations.lookUpButtonLabel]. +/// +/// The action is handled by the platform. +/// +/// See also: +/// +/// * [SystemContextMenu], a widget that can be used to display the system +/// context menu. +/// * [IOSSystemContextMenuItemDataLookUp], which specifies the data to be sent +/// to the platform for this same button. +final class IOSSystemContextMenuItemLookUp extends IOSSystemContextMenuItem { + /// Creates an instance of [IOSSystemContextMenuItemLookUp]. + const IOSSystemContextMenuItemLookUp({this.title}); + + @override + final String? title; + + @override + IOSSystemContextMenuItemDataLookUp getData( + WidgetsLocalizations localizations) { + return IOSSystemContextMenuItemDataLookUp( + title: title ?? localizations.lookUpButtonLabel); + } + + @override + String toString() { + return 'IOSSystemContextMenuItemLookUp(title: $title)'; + } +} + +/// Creates an instance of [IOSSystemContextMenuItem] for the +/// system's built-in search web button. +/// +/// Should only appear when content is selected. +/// +/// The [title] is optional, but it must be specified before being sent to the +/// platform. Typically it should be set to +/// [WidgetsLocalizations.searchWebButtonLabel]. +/// +/// The action is handled by the platform. +/// +/// See also: +/// +/// * [SystemContextMenu], a widget that can be used to display the system +/// context menu. +/// * [IOSSystemContextMenuItemDataSearchWeb], which specifies the data to be +/// sent to the platform for this same button. +final class IOSSystemContextMenuItemSearchWeb extends IOSSystemContextMenuItem { + /// Creates an instance of [IOSSystemContextMenuItemSearchWeb]. + const IOSSystemContextMenuItemSearchWeb({this.title}); + + @override + final String? title; + + @override + IOSSystemContextMenuItemDataSearchWeb getData( + WidgetsLocalizations localizations) { + return IOSSystemContextMenuItemDataSearchWeb( + title: title ?? localizations.searchWebButtonLabel, + ); + } + + @override + String toString() { + return 'IOSSystemContextMenuItemSearchWeb(title: $title)'; + } +} + +/// Creates an instance of [IOSSystemContextMenuItem] for the +/// system's built-in share button. +/// +/// Opens the system share dialog. +/// +/// Should only appear when shareable content is selected. +/// +/// The [title] is optional, but it must be specified before being sent to the +/// platform. Typically it should be set to +/// [WidgetsLocalizations.shareButtonLabel]. +/// +/// See also: +/// +/// * [SystemContextMenu], a widget that can be used to display the system +/// context menu. +/// * [IOSSystemContextMenuItemDataShare], which specifies the data to be sent +/// to the platform for this same button. +final class IOSSystemContextMenuItemShare extends IOSSystemContextMenuItem { + /// Creates an instance of [IOSSystemContextMenuItemShare]. + const IOSSystemContextMenuItemShare({this.title}); + + @override + final String? title; + + @override + IOSSystemContextMenuItemDataShare getData( + WidgetsLocalizations localizations) { + return IOSSystemContextMenuItemDataShare( + title: title ?? localizations.shareButtonLabel); + } + + @override + String toString() { + return 'IOSSystemContextMenuItemShare(title: $title)'; + } +} + +// TODO(justinmc): Support the "custom" type. +// https://github.com/flutter/flutter/issues/103163 diff --git a/lib/common/widgets/text_field/text_field.dart b/lib/common/widgets/text_field/text_field.dart new file mode 100644 index 00000000..1925d2dd --- /dev/null +++ b/lib/common/widgets/text_field/text_field.dart @@ -0,0 +1,1962 @@ +// 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. + +// ignore_for_file: uri_does_not_exist_in_doc_import + +/// @docImport 'input_border.dart'; +/// @docImport 'material.dart'; +/// @docImport 'scaffold.dart'; +/// @docImport 'text_form_field.dart'; +/// @docImport 'text_theme.dart'; +library; + +import 'dart:ui' as ui show BoxHeightStyle, BoxWidthStyle; + +import 'package:PiliPlus/common/widgets/text_field/adaptive_text_selection_toolbar.dart'; +import 'package:PiliPlus/common/widgets/text_field/cupertino/cupertino_spell_check_suggestions_toolbar.dart'; +import 'package:PiliPlus/common/widgets/text_field/cupertino/cupertino_text_field.dart'; +import 'package:PiliPlus/common/widgets/text_field/editable_text.dart'; +import 'package:PiliPlus/common/widgets/text_field/spell_check.dart'; +import 'package:PiliPlus/common/widgets/text_field/spell_check_suggestions_toolbar.dart'; +import 'package:PiliPlus/common/widgets/text_field/system_context_menu.dart'; +import 'package:PiliPlus/common/widgets/text_field/text_selection.dart'; +import 'package:flutter/cupertino.dart' + hide + EditableText, + EditableTextState, + CupertinoSpellCheckSuggestionsToolbar, + SystemContextMenu, + SpellCheckConfiguration, + EditableTextContextMenuBuilder, + buildTextSpanWithSpellCheckSuggestions, + CupertinoTextField, + TextSelectionGestureDetectorBuilderDelegate, + TextSelectionGestureDetectorBuilder; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart' + hide + EditableText, + EditableTextState, + SpellCheckSuggestionsToolbar, + AdaptiveTextSelectionToolbar, + SystemContextMenu, + SpellCheckConfiguration, + EditableTextContextMenuBuilder, + buildTextSpanWithSpellCheckSuggestions, + TextSelectionGestureDetectorBuilderDelegate, + TextSelectionGestureDetectorBuilder; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; + +export 'package:flutter/services.dart' + show + SmartDashesType, + SmartQuotesType, + TextCapitalization, + TextInputAction, + TextInputType; + +// Examples can assume: +// late BuildContext context; +// late FocusNode myFocusNode; + +/// Signature for the [TextField.buildCounter] callback. +typedef InputCounterWidgetBuilder = Widget? Function( + /// The build context for the TextField. + BuildContext context, { + /// The length of the string currently in the input. + required int currentLength, + + /// The maximum string length that can be entered into the TextField. + required int? maxLength, + + /// Whether or not the TextField is currently focused. Mainly provided for + /// the [liveRegion] parameter in the [Semantics] widget for accessibility. + required bool isFocused, +}); + +class _TextFieldSelectionGestureDetectorBuilder + extends TextSelectionGestureDetectorBuilder { + _TextFieldSelectionGestureDetectorBuilder({required _TextFieldState state}) + : _state = state, + super(delegate: state); + + final _TextFieldState _state; + + @override + bool get onUserTapAlwaysCalled => _state.widget.onTapAlwaysCalled; + + @override + void onUserTap() { + _state.widget.onTap?.call(); + } +} + +/// A Material Design text field. +/// +/// A text field lets the user enter text, either with hardware keyboard or with +/// an onscreen keyboard. +/// +/// The text field calls the [onChanged] callback whenever the user changes the +/// text in the field. If the user indicates that they are done typing in the +/// field (e.g., by pressing a button on the soft keyboard), the text field +/// calls the [onSubmitted] callback. +/// +/// To control the text that is displayed in the text field, use the +/// [controller]. For example, to set the initial value of the text field, use +/// a [controller] that already contains some text. The [controller] can also +/// control the selection and composing region (and to observe changes to the +/// text, selection, and composing region). +/// +/// By default, a text field has a [decoration] that draws a divider below the +/// text field. You can use the [decoration] property to control the decoration, +/// for example by adding a label or an icon. If you set the [decoration] +/// property to null, the decoration will be removed entirely, including the +/// extra padding introduced by the decoration to save space for the labels. +/// +/// If [decoration] is non-null (which is the default), the text field requires +/// one of its ancestors to be a [Material] widget. +/// +/// To integrate the [TextField] into a [Form] with other [FormField] widgets, +/// consider using [TextFormField]. +/// +/// {@template flutter.material.textfield.wantKeepAlive} +/// When the widget has focus, it will prevent itself from disposing via its +/// underlying [EditableText]'s [AutomaticKeepAliveClientMixin.wantKeepAlive] in +/// order to avoid losing the selection. Removing the focus will allow it to be +/// disposed. +/// {@endtemplate} +/// +/// Remember to call [TextEditingController.dispose] on the [TextEditingController] +/// when it is no longer needed. This will ensure we discard any resources used +/// by the object. +/// +/// If this field is part of a scrolling container that lazily constructs its +/// children, like a [ListView] or a [CustomScrollView], then a [controller] +/// should be specified. The controller's lifetime should be managed by a +/// stateful widget ancestor of the scrolling container. +/// +/// ## Obscured Input +/// +/// {@tool dartpad} +/// This example shows how to create a [TextField] that will obscure input. The +/// [InputDecoration] surrounds the field in a border using [OutlineInputBorder] +/// and adds a label. +/// +/// ** See code in examples/api/lib/material/text_field/text_field.0.dart ** +/// {@end-tool} +/// +/// ## Reading values +/// +/// A common way to read a value from a TextField is to use the [onSubmitted] +/// callback. This callback is applied to the text field's current value when +/// the user finishes editing. +/// +/// {@tool dartpad} +/// This sample shows how to get a value from a TextField via the [onSubmitted] +/// callback. +/// +/// ** See code in examples/api/lib/material/text_field/text_field.1.dart ** +/// {@end-tool} +/// +/// {@macro flutter.widgets.EditableText.lifeCycle} +/// +/// For most applications the [onSubmitted] callback will be sufficient for +/// reacting to user input. +/// +/// The [onEditingComplete] callback also runs when the user finishes editing. +/// It's different from [onSubmitted] because it has a default value which +/// updates the text controller and yields the keyboard focus. Applications that +/// require different behavior can override the default [onEditingComplete] +/// callback. +/// +/// Keep in mind you can also always read the current string from a TextField's +/// [TextEditingController] using [TextEditingController.text]. +/// +/// ## Handling emojis and other complex characters +/// {@macro flutter.widgets.EditableText.onChanged} +/// +/// In the live Dartpad example above, try typing the emoji 👨‍👩‍👦 +/// into the field and submitting. Because the example code measures the length +/// with `value.characters.length`, the emoji is correctly counted as a single +/// character. +/// +/// {@macro flutter.widgets.editableText.showCaretOnScreen} +/// +/// {@macro flutter.widgets.editableText.accessibility} +/// +/// {@tool dartpad} +/// This sample shows how to style a text field to match a filled or outlined +/// Material Design 3 text field. +/// +/// ** See code in examples/api/lib/material/text_field/text_field.2.dart ** +/// {@end-tool} +/// +/// ## Scrolling Considerations +/// +/// If this [TextField] is not a descendant of [Scaffold] and is being used +/// within a [Scrollable] or nested [Scrollable]s, consider placing a +/// [ScrollNotificationObserver] above the root [Scrollable] that contains this +/// [TextField] to ensure proper scroll coordination for [TextField] and its +/// components like [TextSelectionOverlay]. +/// +/// See also: +/// +/// * [TextFormField], which integrates with the [Form] widget. +/// * [InputDecorator], which shows the labels and other visual elements that +/// surround the actual text editing widget. +/// * [EditableText], which is the raw text editing control at the heart of a +/// [TextField]. The [EditableText] widget is rarely used directly unless +/// you are implementing an entirely different design language, such as +/// Cupertino. +/// * +/// * Cookbook: [Create and style a text field](https://docs.flutter.dev/cookbook/forms/text-input) +/// * Cookbook: [Handle changes to a text field](https://docs.flutter.dev/cookbook/forms/text-field-changes) +/// * Cookbook: [Retrieve the value of a text field](https://docs.flutter.dev/cookbook/forms/retrieve-input) +/// * Cookbook: [Focus and text fields](https://docs.flutter.dev/cookbook/forms/focus) +class TextField extends StatefulWidget { + /// Creates a Material Design text field. + /// + /// If [decoration] is non-null (which is the default), the text field requires + /// one of its ancestors to be a [Material] widget. + /// + /// To remove the decoration entirely (including the extra padding introduced + /// by the decoration to save space for the labels), set the [decoration] to + /// null. + /// + /// The [maxLines] property can be set to null to remove the restriction on + /// the number of lines. By default, it is one, meaning this is a single-line + /// text field. [maxLines] must not be zero. + /// + /// The [maxLength] property is set to null by default, which means the + /// number of characters allowed in the text field is not restricted. If + /// [maxLength] is set a character counter will be displayed below the + /// field showing how many characters have been entered. If the value is + /// set to a positive integer it will also display the maximum allowed + /// number of characters to be entered. If the value is set to + /// [TextField.noMaxLength] then only the current length is displayed. + /// + /// After [maxLength] characters have been input, additional input + /// is ignored, unless [maxLengthEnforcement] is set to + /// [MaxLengthEnforcement.none]. + /// The text field enforces the length with a [LengthLimitingTextInputFormatter], + /// which is evaluated after the supplied [inputFormatters], if any. + /// The [maxLength] value must be either null or greater than zero. + /// + /// If [maxLengthEnforcement] is set to [MaxLengthEnforcement.none], then more + /// than [maxLength] characters may be entered, and the error counter and + /// divider will switch to the [decoration].errorStyle when the limit is + /// exceeded. + /// + /// The text cursor is not shown if [showCursor] is false or if [showCursor] + /// is null (the default) and [readOnly] is true. + /// + /// The [selectionHeightStyle] and [selectionWidthStyle] properties allow + /// changing the shape of the selection highlighting. These properties default + /// to [ui.BoxHeightStyle.tight] and [ui.BoxWidthStyle.tight], respectively. + /// + /// See also: + /// + /// * [maxLength], which discusses the precise meaning of "number of + /// characters" and how it may differ from the intuitive meaning. + const TextField({ + super.key, + this.groupId = EditableText, + this.controller, + this.focusNode, + this.undoController, + this.decoration = const InputDecoration(), + TextInputType? keyboardType, + this.textInputAction, + this.textCapitalization = TextCapitalization.none, + this.style, + this.strutStyle, + this.textAlign = TextAlign.start, + this.textAlignVertical, + this.textDirection, + this.readOnly = false, + @Deprecated( + 'Use `contextMenuBuilder` instead. ' + 'This feature was deprecated after v3.3.0-0.5.pre.', + ) + this.toolbarOptions, + this.showCursor, + this.autofocus = false, + this.statesController, + this.obscuringCharacter = '•', + this.obscureText = false, + this.autocorrect = true, + SmartDashesType? smartDashesType, + SmartQuotesType? smartQuotesType, + this.enableSuggestions = true, + this.maxLines = 1, + this.minLines, + this.expands = false, + this.maxLength, + this.maxLengthEnforcement, + this.onChanged, + this.onEditingComplete, + this.onSubmitted, + this.onAppPrivateCommand, + this.inputFormatters, + this.enabled, + this.ignorePointers, + this.cursorWidth = 2.0, + this.cursorHeight, + this.cursorRadius, + this.cursorOpacityAnimates, + this.cursorColor, + this.cursorErrorColor, + this.selectionHeightStyle = ui.BoxHeightStyle.tight, + this.selectionWidthStyle = ui.BoxWidthStyle.tight, + this.keyboardAppearance, + this.scrollPadding = const EdgeInsets.all(20.0), + this.dragStartBehavior = DragStartBehavior.start, + bool? enableInteractiveSelection, + this.selectionControls, + this.onTap, + this.onTapAlwaysCalled = false, + this.onTapOutside, + this.onTapUpOutside, + this.mouseCursor, + this.buildCounter, + this.scrollController, + this.scrollPhysics, + this.autofillHints = const [], + this.contentInsertionConfiguration, + this.clipBehavior = Clip.hardEdge, + this.restorationId, + @Deprecated( + 'Use `stylusHandwritingEnabled` instead. ' + 'This feature was deprecated after v3.27.0-0.2.pre.', + ) + this.scribbleEnabled = true, + this.stylusHandwritingEnabled = + EditableText.defaultStylusHandwritingEnabled, + this.enableIMEPersonalizedLearning = true, + this.contextMenuBuilder = _defaultContextMenuBuilder, + this.canRequestFocus = true, + this.spellCheckConfiguration, + this.magnifierConfiguration, + this.onDelAtUser, + this.onMention, + }) : assert(obscuringCharacter.length == 1), + smartDashesType = smartDashesType ?? + (obscureText ? SmartDashesType.disabled : SmartDashesType.enabled), + smartQuotesType = smartQuotesType ?? + (obscureText ? SmartQuotesType.disabled : SmartQuotesType.enabled), + assert(maxLines == null || maxLines > 0), + assert(minLines == null || minLines > 0), + assert( + (maxLines == null) || (minLines == null) || (maxLines >= minLines), + "minLines can't be greater than maxLines", + ), + assert( + !expands || (maxLines == null && minLines == null), + 'minLines and maxLines must be null when expands is true.', + ), + assert(!obscureText || maxLines == 1, + 'Obscured fields cannot be multiline.'), + assert(maxLength == null || + maxLength == TextField.noMaxLength || + maxLength > 0), + // Assert the following instead of setting it directly to avoid surprising the user by silently changing the value they set. + assert( + !identical(textInputAction, TextInputAction.newline) || + maxLines == 1 || + !identical(keyboardType, TextInputType.text), + 'Use keyboardType TextInputType.multiline when using TextInputAction.newline on a multiline TextField.', + ), + keyboardType = keyboardType ?? + (maxLines == 1 ? TextInputType.text : TextInputType.multiline), + enableInteractiveSelection = + enableInteractiveSelection ?? (!readOnly || !obscureText); + + final VoidCallback? onMention; + + final ValueChanged? onDelAtUser; + + /// The configuration for the magnifier of this text field. + /// + /// By default, builds a [CupertinoTextMagnifier] on iOS and [TextMagnifier] + /// on Android, and builds nothing on all other platforms. To suppress the + /// magnifier, consider passing [TextMagnifierConfiguration.disabled]. + /// + /// {@macro flutter.widgets.magnifier.intro} + /// + /// {@tool dartpad} + /// This sample demonstrates how to customize the magnifier that this text field uses. + /// + /// ** See code in examples/api/lib/widgets/text_magnifier/text_magnifier.0.dart ** + /// {@end-tool} + final TextMagnifierConfiguration? magnifierConfiguration; + + /// {@macro flutter.widgets.editableText.groupId} + final Object groupId; + + /// Controls the text being edited. + /// + /// If null, this widget will create its own [TextEditingController]. + final TextEditingController? controller; + + /// Defines the keyboard focus for this widget. + /// + /// The [focusNode] is a long-lived object that's typically managed by a + /// [StatefulWidget] parent. See [FocusNode] for more information. + /// + /// To give the keyboard focus to this widget, provide a [focusNode] and then + /// use the current [FocusScope] to request the focus: + /// + /// ```dart + /// FocusScope.of(context).requestFocus(myFocusNode); + /// ``` + /// + /// This happens automatically when the widget is tapped. + /// + /// To be notified when the widget gains or loses the focus, add a listener + /// to the [focusNode]: + /// + /// ```dart + /// myFocusNode.addListener(() { print(myFocusNode.hasFocus); }); + /// ``` + /// + /// If null, this widget will create its own [FocusNode]. + /// + /// ## Keyboard + /// + /// Requesting the focus will typically cause the keyboard to be shown + /// if it's not showing already. + /// + /// On Android, the user can hide the keyboard - without changing the focus - + /// with the system back button. They can restore the keyboard's visibility + /// by tapping on a text field. The user might hide the keyboard and + /// switch to a physical keyboard, or they might just need to get it + /// out of the way for a moment, to expose something it's + /// obscuring. In this case requesting the focus again will not + /// cause the focus to change, and will not make the keyboard visible. + /// + /// This widget builds an [EditableText] and will ensure that the keyboard is + /// showing when it is tapped by calling [EditableTextState.requestKeyboard()]. + final FocusNode? focusNode; + + /// The decoration to show around the text field. + /// + /// By default, draws a horizontal line under the text field but can be + /// configured to show an icon, label, hint text, and error text. + /// + /// Specify null to remove the decoration entirely (including the + /// extra padding introduced by the decoration to save space for the labels). + final InputDecoration? decoration; + + /// {@macro flutter.widgets.editableText.keyboardType} + final TextInputType keyboardType; + + /// {@template flutter.widgets.TextField.textInputAction} + /// The type of action button to use for the keyboard. + /// + /// Defaults to [TextInputAction.newline] if [keyboardType] is + /// [TextInputType.multiline] and [TextInputAction.done] otherwise. + /// {@endtemplate} + final TextInputAction? textInputAction; + + /// {@macro flutter.widgets.editableText.textCapitalization} + final TextCapitalization textCapitalization; + + /// The style to use for the text being edited. + /// + /// This text style is also used as the base style for the [decoration]. + /// + /// If null, [TextTheme.bodyLarge] will be used. When the text field is disabled, + /// [TextTheme.bodyLarge] with an opacity of 0.38 will be used instead. + /// + /// If null and [ThemeData.useMaterial3] is false, [TextTheme.titleMedium] will + /// be used. When the text field is disabled, [TextTheme.titleMedium] with + /// [ThemeData.disabledColor] will be used instead. + final TextStyle? style; + + /// {@macro flutter.widgets.editableText.strutStyle} + final StrutStyle? strutStyle; + + /// {@macro flutter.widgets.editableText.textAlign} + final TextAlign textAlign; + + /// {@macro flutter.material.InputDecorator.textAlignVertical} + final TextAlignVertical? textAlignVertical; + + /// {@macro flutter.widgets.editableText.textDirection} + final TextDirection? textDirection; + + /// {@macro flutter.widgets.editableText.autofocus} + final bool autofocus; + + /// Represents the interactive "state" of this widget in terms of a set of + /// [WidgetState]s, including [WidgetState.disabled], [WidgetState.hovered], + /// [WidgetState.error], and [WidgetState.focused]. + /// + /// Classes based on this one can provide their own + /// [WidgetStatesController] to which they've added listeners. + /// They can also update the controller's [WidgetStatesController.value] + /// however, this may only be done when it's safe to call + /// [State.setState], like in an event handler. + /// + /// The controller's [WidgetStatesController.value] represents the set of + /// states that a widget's visual properties, typically [WidgetStateProperty] + /// values, are resolved against. It is _not_ the intrinsic state of the widget. + /// The widget is responsible for ensuring that the controller's + /// [WidgetStatesController.value] tracks its intrinsic state. For example + /// one cannot request the keyboard focus for a widget by adding [WidgetState.focused] + /// to its controller. When the widget gains the or loses the focus it will + /// [WidgetStatesController.update] its controller's [WidgetStatesController.value] + /// and notify listeners of the change. + final MaterialStatesController? statesController; + + /// {@macro flutter.widgets.editableText.obscuringCharacter} + final String obscuringCharacter; + + /// {@macro flutter.widgets.editableText.obscureText} + final bool obscureText; + + /// {@macro flutter.widgets.editableText.autocorrect} + final bool autocorrect; + + /// {@macro flutter.services.TextInputConfiguration.smartDashesType} + final SmartDashesType smartDashesType; + + /// {@macro flutter.services.TextInputConfiguration.smartQuotesType} + final SmartQuotesType smartQuotesType; + + /// {@macro flutter.services.TextInputConfiguration.enableSuggestions} + final bool enableSuggestions; + + /// {@macro flutter.widgets.editableText.maxLines} + /// * [expands], which determines whether the field should fill the height of + /// its parent. + final int? maxLines; + + /// {@macro flutter.widgets.editableText.minLines} + /// * [expands], which determines whether the field should fill the height of + /// its parent. + final int? minLines; + + /// {@macro flutter.widgets.editableText.expands} + final bool expands; + + /// {@macro flutter.widgets.editableText.readOnly} + final bool readOnly; + + /// Configuration of toolbar options. + /// + /// If not set, select all and paste will default to be enabled. Copy and cut + /// will be disabled if [obscureText] is true. If [readOnly] is true, + /// paste and cut will be disabled regardless. + @Deprecated( + 'Use `contextMenuBuilder` instead. ' + 'This feature was deprecated after v3.3.0-0.5.pre.', + ) + final ToolbarOptions? toolbarOptions; + + /// {@macro flutter.widgets.editableText.showCursor} + final bool? showCursor; + + /// If [maxLength] is set to this value, only the "current input length" + /// part of the character counter is shown. + static const int noMaxLength = -1; + + /// The maximum number of characters (Unicode grapheme clusters) to allow in + /// the text field. + /// + /// If set, a character counter will be displayed below the + /// field showing how many characters have been entered. If set to a number + /// greater than 0, it will also display the maximum number allowed. If set + /// to [TextField.noMaxLength] then only the current character count is displayed. + /// + /// After [maxLength] characters have been input, additional input + /// is ignored, unless [maxLengthEnforcement] is set to + /// [MaxLengthEnforcement.none]. + /// + /// The text field enforces the length with a [LengthLimitingTextInputFormatter], + /// which is evaluated after the supplied [inputFormatters], if any. + /// + /// This value must be either null, [TextField.noMaxLength], or greater than 0. + /// If null (the default) then there is no limit to the number of characters + /// that can be entered. If set to [TextField.noMaxLength], then no limit will + /// be enforced, but the number of characters entered will still be displayed. + /// + /// Whitespace characters (e.g. newline, space, tab) are included in the + /// character count. + /// + /// If [maxLengthEnforcement] is [MaxLengthEnforcement.none], then more than + /// [maxLength] characters may be entered, but the error counter and divider + /// will switch to the [decoration]'s [InputDecoration.errorStyle] when the + /// limit is exceeded. + /// + /// {@macro flutter.services.lengthLimitingTextInputFormatter.maxLength} + final int? maxLength; + + /// Determines how the [maxLength] limit should be enforced. + /// + /// {@macro flutter.services.textFormatter.effectiveMaxLengthEnforcement} + /// + /// {@macro flutter.services.textFormatter.maxLengthEnforcement} + final MaxLengthEnforcement? maxLengthEnforcement; + + /// {@macro flutter.widgets.editableText.onChanged} + /// + /// See also: + /// + /// * [inputFormatters], which are called before [onChanged] + /// runs and can validate and change ("format") the input value. + /// * [onEditingComplete], [onSubmitted]: + /// which are more specialized input change notifications. + final ValueChanged? onChanged; + + /// {@macro flutter.widgets.editableText.onEditingComplete} + final VoidCallback? onEditingComplete; + + /// {@macro flutter.widgets.editableText.onSubmitted} + /// + /// See also: + /// + /// * [TextInputAction.next] and [TextInputAction.previous], which + /// automatically shift the focus to the next/previous focusable item when + /// the user is done editing. + final ValueChanged? onSubmitted; + + /// {@macro flutter.widgets.editableText.onAppPrivateCommand} + final AppPrivateCommandCallback? onAppPrivateCommand; + + /// {@macro flutter.widgets.editableText.inputFormatters} + final List? inputFormatters; + + /// If false the text field is "disabled": it ignores taps and its + /// [decoration] is rendered in grey. + /// + /// If non-null this property overrides the [decoration]'s + /// [InputDecoration.enabled] property. + final bool? enabled; + + /// Determines whether this widget ignores pointer events. + /// + /// Defaults to null, and when null, does nothing. + final bool? ignorePointers; + + /// {@macro flutter.widgets.editableText.cursorWidth} + final double cursorWidth; + + /// {@macro flutter.widgets.editableText.cursorHeight} + final double? cursorHeight; + + /// {@macro flutter.widgets.editableText.cursorRadius} + final Radius? cursorRadius; + + /// {@macro flutter.widgets.editableText.cursorOpacityAnimates} + final bool? cursorOpacityAnimates; + + /// The color of the cursor. + /// + /// The cursor indicates the current location of text insertion point in + /// the field. + /// + /// If this is null it will default to the ambient + /// [DefaultSelectionStyle.cursorColor]. If that is null, and the + /// [ThemeData.platform] is [TargetPlatform.iOS] or [TargetPlatform.macOS] + /// it will use [CupertinoThemeData.primaryColor]. Otherwise it will use + /// the value of [ColorScheme.primary] of [ThemeData.colorScheme]. + final Color? cursorColor; + + /// The color of the cursor when the [InputDecorator] is showing an error. + /// + /// If this is null it will default to [TextStyle.color] of + /// [InputDecoration.errorStyle]. If that is null, it will use + /// [ColorScheme.error] of [ThemeData.colorScheme]. + final Color? cursorErrorColor; + + /// Controls how tall the selection highlight boxes are computed to be. + /// + /// See [ui.BoxHeightStyle] for details on available styles. + final ui.BoxHeightStyle selectionHeightStyle; + + /// Controls how wide the selection highlight boxes are computed to be. + /// + /// See [ui.BoxWidthStyle] for details on available styles. + final ui.BoxWidthStyle selectionWidthStyle; + + /// The appearance of the keyboard. + /// + /// This setting is only honored on iOS devices. + /// + /// If unset, defaults to [ThemeData.brightness]. + final Brightness? keyboardAppearance; + + /// {@macro flutter.widgets.editableText.scrollPadding} + final EdgeInsets scrollPadding; + + /// {@macro flutter.widgets.editableText.enableInteractiveSelection} + final bool enableInteractiveSelection; + + /// {@macro flutter.widgets.editableText.selectionControls} + final TextSelectionControls? selectionControls; + + /// {@macro flutter.widgets.scrollable.dragStartBehavior} + final DragStartBehavior dragStartBehavior; + + /// {@macro flutter.widgets.editableText.selectionEnabled} + bool get selectionEnabled => enableInteractiveSelection; + + /// {@template flutter.material.textfield.onTap} + /// Called for the first tap in a series of taps. + /// + /// The text field builds a [GestureDetector] to handle input events like tap, + /// to trigger focus requests, to move the caret, adjust the selection, etc. + /// Handling some of those events by wrapping the text field with a competing + /// GestureDetector is problematic. + /// + /// To unconditionally handle taps, without interfering with the text field's + /// internal gesture detector, provide this callback. + /// + /// If the text field is created with [enabled] false, taps will not be + /// recognized. + /// + /// To be notified when the text field gains or loses the focus, provide a + /// [focusNode] and add a listener to that. + /// + /// To listen to arbitrary pointer events without competing with the + /// text field's internal gesture detector, use a [Listener]. + /// {@endtemplate} + /// + /// If [onTapAlwaysCalled] is enabled, this will also be called for consecutive + /// taps. + final GestureTapCallback? onTap; + + /// Whether [onTap] should be called for every tap. + /// + /// Defaults to false, so [onTap] is only called for each distinct tap. When + /// enabled, [onTap] is called for every tap including consecutive taps. + final bool onTapAlwaysCalled; + + /// {@macro flutter.widgets.editableText.onTapOutside} + /// + /// {@tool dartpad} + /// This example shows how to use a `TextFieldTapRegion` to wrap a set of + /// "spinner" buttons that increment and decrement a value in the [TextField] + /// without causing the text field to lose keyboard focus. + /// + /// This example includes a generic `SpinnerField` class that you can copy + /// into your own project and customize. + /// + /// ** See code in examples/api/lib/widgets/tap_region/text_field_tap_region.0.dart ** + /// {@end-tool} + /// + /// See also: + /// + /// * [TapRegion] for how the region group is determined. + final TapRegionCallback? onTapOutside; + + /// {@macro flutter.widgets.editableText.onTapUpOutside} + final TapRegionUpCallback? onTapUpOutside; + + /// The cursor for a mouse pointer when it enters or is hovering over the + /// widget. + /// + /// If [mouseCursor] is a [WidgetStateMouseCursor], + /// [WidgetStateProperty.resolve] is used for the following [WidgetState]s: + /// + /// * [WidgetState.error]. + /// * [WidgetState.hovered]. + /// * [WidgetState.focused]. + /// * [WidgetState.disabled]. + /// + /// If this property is null, [WidgetStateMouseCursor.textable] will be used. + /// + /// The [mouseCursor] is the only property of [TextField] that controls the + /// appearance of the mouse pointer. All other properties related to "cursor" + /// stand for the text cursor, which is usually a blinking vertical line at + /// the editing position. + final MouseCursor? mouseCursor; + + /// Callback that generates a custom [InputDecoration.counter] widget. + /// + /// See [InputCounterWidgetBuilder] for an explanation of the passed in + /// arguments. The returned widget will be placed below the line in place of + /// the default widget built when [InputDecoration.counterText] is specified. + /// + /// The returned widget will be wrapped in a [Semantics] widget for + /// accessibility, but it also needs to be accessible itself. For example, + /// if returning a Text widget, set the [Text.semanticsLabel] property. + /// + /// {@tool snippet} + /// ```dart + /// Widget counter( + /// BuildContext context, + /// { + /// required int currentLength, + /// required int? maxLength, + /// required bool isFocused, + /// } + /// ) { + /// return Text( + /// '$currentLength of $maxLength characters', + /// semanticsLabel: 'character count', + /// ); + /// } + /// ``` + /// {@end-tool} + /// + /// If buildCounter returns null, then no counter and no Semantics widget will + /// be created at all. + final InputCounterWidgetBuilder? buildCounter; + + /// {@macro flutter.widgets.editableText.scrollPhysics} + final ScrollPhysics? scrollPhysics; + + /// {@macro flutter.widgets.editableText.scrollController} + final ScrollController? scrollController; + + /// {@macro flutter.widgets.editableText.autofillHints} + /// {@macro flutter.services.AutofillConfiguration.autofillHints} + final Iterable? autofillHints; + + /// {@macro flutter.material.Material.clipBehavior} + /// + /// Defaults to [Clip.hardEdge]. + final Clip clipBehavior; + + /// {@template flutter.material.textfield.restorationId} + /// Restoration ID to save and restore the state of the text field. + /// + /// If non-null, the text field will persist and restore its current scroll + /// offset and - if no [controller] has been provided - the content of the + /// text field. If a [controller] has been provided, it is the responsibility + /// of the owner of that controller to persist and restore it, e.g. by using + /// a [RestorableTextEditingController]. + /// + /// The state of this widget is persisted in a [RestorationBucket] claimed + /// from the surrounding [RestorationScope] using the provided restoration ID. + /// + /// See also: + /// + /// * [RestorationManager], which explains how state restoration works in + /// Flutter. + /// {@endtemplate} + final String? restorationId; + + /// {@macro flutter.widgets.editableText.scribbleEnabled} + @Deprecated( + 'Use `stylusHandwritingEnabled` instead. ' + 'This feature was deprecated after v3.27.0-0.2.pre.', + ) + final bool scribbleEnabled; + + /// {@macro flutter.widgets.editableText.stylusHandwritingEnabled} + final bool stylusHandwritingEnabled; + + /// {@macro flutter.services.TextInputConfiguration.enableIMEPersonalizedLearning} + final bool enableIMEPersonalizedLearning; + + /// {@macro flutter.widgets.editableText.contentInsertionConfiguration} + final ContentInsertionConfiguration? contentInsertionConfiguration; + + /// {@macro flutter.widgets.EditableText.contextMenuBuilder} + /// + /// If not provided, will build a default menu based on the platform. + /// + /// See also: + /// + /// * [AdaptiveTextSelectionToolbar], which is built by default. + /// * [BrowserContextMenu], which allows the browser's context menu on web to + /// be disabled and Flutter-rendered context menus to appear. + final EditableTextContextMenuBuilder? contextMenuBuilder; + + /// Determine whether this text field can request the primary focus. + /// + /// Defaults to true. If false, the text field will not request focus + /// when tapped, or when its context menu is displayed. If false it will not + /// be possible to move the focus to the text field with tab key. + final bool canRequestFocus; + + /// {@macro flutter.widgets.undoHistory.controller} + final UndoHistoryController? undoController; + + static Widget _defaultContextMenuBuilder( + BuildContext context, + EditableTextState editableTextState, + ) { + if (defaultTargetPlatform == TargetPlatform.iOS && + SystemContextMenu.isSupported(context)) { + return SystemContextMenu.editableText( + editableTextState: editableTextState); + } + return AdaptiveTextSelectionToolbar.editableText( + editableTextState: editableTextState); + } + + /// {@macro flutter.widgets.EditableText.spellCheckConfiguration} + /// + /// If [SpellCheckConfiguration.misspelledTextStyle] is not specified in this + /// configuration, then [materialMisspelledTextStyle] is used by default. + final SpellCheckConfiguration? spellCheckConfiguration; + + /// The [TextStyle] used to indicate misspelled words in the Material style. + /// + /// See also: + /// * [SpellCheckConfiguration.misspelledTextStyle], the style configured to + /// mark misspelled words with. + /// * [CupertinoTextField.cupertinoMisspelledTextStyle], the style configured + /// to mark misspelled words with in the Cupertino style. + static const TextStyle materialMisspelledTextStyle = TextStyle( + decoration: TextDecoration.underline, + decorationColor: Colors.red, + decorationStyle: TextDecorationStyle.wavy, + ); + + /// Default builder for [TextField]'s spell check suggestions toolbar. + /// + /// On Apple platforms, builds an iOS-style toolbar. Everywhere else, builds + /// an Android-style toolbar. + /// + /// See also: + /// * [spellCheckConfiguration], where this is typically specified for + /// [TextField]. + /// * [SpellCheckConfiguration.spellCheckSuggestionsToolbarBuilder], the + /// parameter for which this is the default value for [TextField]. + /// * [CupertinoTextField.defaultSpellCheckSuggestionsToolbarBuilder], which + /// is like this but specifies the default for [CupertinoTextField]. + @visibleForTesting + static Widget defaultSpellCheckSuggestionsToolbarBuilder( + BuildContext context, + EditableTextState editableTextState, + ) { + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + return CupertinoSpellCheckSuggestionsToolbar.editableText( + editableTextState: editableTextState, + ); + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + return SpellCheckSuggestionsToolbar.editableText( + editableTextState: editableTextState); + } + } + + /// Returns a new [SpellCheckConfiguration] where the given configuration has + /// had any missing values replaced with their defaults for the Android + /// platform. + static SpellCheckConfiguration inferAndroidSpellCheckConfiguration( + SpellCheckConfiguration? configuration, + ) { + if (configuration == null || + configuration == const SpellCheckConfiguration.disabled()) { + return const SpellCheckConfiguration.disabled(); + } + return configuration.copyWith( + misspelledTextStyle: configuration.misspelledTextStyle ?? + TextField.materialMisspelledTextStyle, + spellCheckSuggestionsToolbarBuilder: + configuration.spellCheckSuggestionsToolbarBuilder ?? + TextField.defaultSpellCheckSuggestionsToolbarBuilder, + ); + } + + @override + State createState() => _TextFieldState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add( + DiagnosticsProperty('controller', controller, + defaultValue: null), + ) + ..add(DiagnosticsProperty('focusNode', focusNode, + defaultValue: null)) + ..add( + DiagnosticsProperty( + 'undoController', + undoController, + defaultValue: null, + ), + ) + ..add(DiagnosticsProperty('enabled', enabled, defaultValue: null)) + ..add( + DiagnosticsProperty( + 'decoration', + decoration, + defaultValue: const InputDecoration(), + ), + ) + ..add( + DiagnosticsProperty( + 'keyboardType', + keyboardType, + defaultValue: TextInputType.text, + ), + ) + ..add(DiagnosticsProperty('style', style, defaultValue: null)) + ..add(DiagnosticsProperty('autofocus', autofocus, + defaultValue: false)) + ..add( + DiagnosticsProperty('obscuringCharacter', obscuringCharacter, + defaultValue: '•'), + ) + ..add(DiagnosticsProperty('obscureText', obscureText, + defaultValue: false)) + ..add(DiagnosticsProperty('autocorrect', autocorrect, + defaultValue: true)) + ..add( + EnumProperty( + 'smartDashesType', + smartDashesType, + defaultValue: + obscureText ? SmartDashesType.disabled : SmartDashesType.enabled, + ), + ) + ..add( + EnumProperty( + 'smartQuotesType', + smartQuotesType, + defaultValue: + obscureText ? SmartQuotesType.disabled : SmartQuotesType.enabled, + ), + ) + ..add( + DiagnosticsProperty('enableSuggestions', enableSuggestions, + defaultValue: true), + ) + ..add(IntProperty('maxLines', maxLines, defaultValue: 1)) + ..add(IntProperty('minLines', minLines, defaultValue: null)) + ..add(DiagnosticsProperty('expands', expands, defaultValue: false)) + ..add(IntProperty('maxLength', maxLength, defaultValue: null)) + ..add( + EnumProperty( + 'maxLengthEnforcement', + maxLengthEnforcement, + defaultValue: null, + ), + ) + ..add( + EnumProperty('textInputAction', textInputAction, + defaultValue: null), + ) + ..add( + EnumProperty( + 'textCapitalization', + textCapitalization, + defaultValue: TextCapitalization.none, + ), + ) + ..add(EnumProperty('textAlign', textAlign, + defaultValue: TextAlign.start)) + ..add( + DiagnosticsProperty( + 'textAlignVertical', + textAlignVertical, + defaultValue: null, + ), + ) + ..add(EnumProperty('textDirection', textDirection, + defaultValue: null)) + ..add(DoubleProperty('cursorWidth', cursorWidth, defaultValue: 2.0)) + ..add(DoubleProperty('cursorHeight', cursorHeight, defaultValue: null)) + ..add(DiagnosticsProperty('cursorRadius', cursorRadius, + defaultValue: null)) + ..add( + DiagnosticsProperty( + 'cursorOpacityAnimates', cursorOpacityAnimates, + defaultValue: null), + ) + ..add(ColorProperty('cursorColor', cursorColor, defaultValue: null)) + ..add(ColorProperty('cursorErrorColor', cursorErrorColor, + defaultValue: null)) + ..add( + DiagnosticsProperty( + 'keyboardAppearance', keyboardAppearance, + defaultValue: null), + ) + ..add( + DiagnosticsProperty( + 'scrollPadding', + scrollPadding, + defaultValue: const EdgeInsets.all(20.0), + ), + ) + ..add( + FlagProperty( + 'selectionEnabled', + value: selectionEnabled, + defaultValue: true, + ifFalse: 'selection disabled', + ), + ) + ..add( + DiagnosticsProperty( + 'selectionControls', + selectionControls, + defaultValue: null, + ), + ) + ..add( + DiagnosticsProperty( + 'scrollController', + scrollController, + defaultValue: null, + ), + ) + ..add( + DiagnosticsProperty('scrollPhysics', scrollPhysics, + defaultValue: null), + ) + ..add( + DiagnosticsProperty('clipBehavior', clipBehavior, + defaultValue: Clip.hardEdge), + ) + ..add( + DiagnosticsProperty('scribbleEnabled', scribbleEnabled, + defaultValue: true), + ) + ..add( + DiagnosticsProperty( + 'stylusHandwritingEnabled', + stylusHandwritingEnabled, + defaultValue: EditableText.defaultStylusHandwritingEnabled, + ), + ) + ..add( + DiagnosticsProperty( + 'enableIMEPersonalizedLearning', + enableIMEPersonalizedLearning, + defaultValue: true, + ), + ) + ..add( + DiagnosticsProperty( + 'spellCheckConfiguration', + spellCheckConfiguration, + defaultValue: null, + ), + ) + ..add( + DiagnosticsProperty>( + 'contentCommitMimeTypes', + contentInsertionConfiguration?.allowedMimeTypes ?? const [], + defaultValue: contentInsertionConfiguration == null + ? const [] + : kDefaultContentInsertionMimeTypes, + ), + ); + } +} + +class _TextFieldState extends State + with RestorationMixin + implements TextSelectionGestureDetectorBuilderDelegate, AutofillClient { + RestorableTextEditingController? _controller; + TextEditingController get _effectiveController => + widget.controller ?? _controller!.value; + + FocusNode? _focusNode; + FocusNode get _effectiveFocusNode => + widget.focusNode ?? (_focusNode ??= FocusNode()); + + MaxLengthEnforcement get _effectiveMaxLengthEnforcement => + widget.maxLengthEnforcement ?? + LengthLimitingTextInputFormatter.getDefaultMaxLengthEnforcement( + Theme.of(context).platform); + + bool _isHovering = false; + + bool get needsCounter => + widget.maxLength != null && + widget.decoration != null && + widget.decoration!.counterText == null; + + bool _showSelectionHandles = false; + + late _TextFieldSelectionGestureDetectorBuilder + _selectionGestureDetectorBuilder; + + // API for TextSelectionGestureDetectorBuilderDelegate. + @override + late bool forcePressEnabled; + + @override + final GlobalKey editableTextKey = + GlobalKey(); + + @override + bool get selectionEnabled => widget.selectionEnabled && _isEnabled; + // End of API for TextSelectionGestureDetectorBuilderDelegate. + + bool get _isEnabled => widget.enabled ?? widget.decoration?.enabled ?? true; + + int get _currentLength => _effectiveController.value.text.characters.length; + + bool get _hasIntrinsicError => + widget.maxLength != null && + widget.maxLength! > 0 && + (widget.controller == null + ? !restorePending && + _effectiveController.value.text.characters.length > + widget.maxLength! + : _effectiveController.value.text.characters.length > + widget.maxLength!); + + bool get _hasError => + widget.decoration?.errorText != null || + widget.decoration?.error != null || + _hasIntrinsicError; + + Color get _errorColor => + widget.cursorErrorColor ?? + _getEffectiveDecoration().errorStyle?.color ?? + Theme.of(context).colorScheme.error; + + InputDecoration _getEffectiveDecoration() { + final MaterialLocalizations localizations = + MaterialLocalizations.of(context); + final ThemeData themeData = Theme.of(context); + final InputDecoration effectiveDecoration = + (widget.decoration ?? const InputDecoration()) + .applyDefaults(themeData.inputDecorationTheme) + .copyWith( + enabled: _isEnabled, + hintMaxLines: widget.decoration?.hintMaxLines ?? widget.maxLines, + ); + + // No need to build anything if counter or counterText were given directly. + if (effectiveDecoration.counter != null || + effectiveDecoration.counterText != null) { + return effectiveDecoration; + } + + // If buildCounter was provided, use it to generate a counter widget. + Widget? counter; + final int currentLength = _currentLength; + if (effectiveDecoration.counter == null && + effectiveDecoration.counterText == null && + widget.buildCounter != null) { + final bool isFocused = _effectiveFocusNode.hasFocus; + final Widget? builtCounter = widget.buildCounter!( + context, + currentLength: currentLength, + maxLength: widget.maxLength, + isFocused: isFocused, + ); + // If buildCounter returns null, don't add a counter widget to the field. + if (builtCounter != null) { + counter = Semantics( + container: true, liveRegion: isFocused, child: builtCounter); + } + return effectiveDecoration.copyWith(counter: counter); + } + + if (widget.maxLength == null) { + return effectiveDecoration; + } // No counter widget + + String counterText = '$currentLength'; + String semanticCounterText = ''; + + // Handle a real maxLength (positive number) + if (widget.maxLength! > 0) { + // Show the maxLength in the counter + counterText += '/${widget.maxLength}'; + final int remaining = + (widget.maxLength! - currentLength).clamp(0, widget.maxLength!); + semanticCounterText = + localizations.remainingTextFieldCharacterCount(remaining); + } + + if (_hasIntrinsicError) { + return effectiveDecoration.copyWith( + errorText: effectiveDecoration.errorText ?? '', + counterStyle: effectiveDecoration.errorStyle ?? + (themeData.useMaterial3 + ? _m3CounterErrorStyle(context) + : _m2CounterErrorStyle(context)), + counterText: counterText, + semanticCounterText: semanticCounterText, + ); + } + + return effectiveDecoration.copyWith( + counterText: counterText, + semanticCounterText: semanticCounterText, + ); + } + + @override + void initState() { + super.initState(); + _selectionGestureDetectorBuilder = + _TextFieldSelectionGestureDetectorBuilder(state: this); + if (widget.controller == null) { + _createLocalController(); + } + _effectiveFocusNode.canRequestFocus = widget.canRequestFocus && _isEnabled; + _effectiveFocusNode.addListener(_handleFocusChanged); + _initStatesController(); + } + + bool get _canRequestFocus { + final NavigationMode mode = + MediaQuery.maybeNavigationModeOf(context) ?? NavigationMode.traditional; + return switch (mode) { + NavigationMode.traditional => widget.canRequestFocus && _isEnabled, + NavigationMode.directional => true, + }; + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _effectiveFocusNode.canRequestFocus = _canRequestFocus; + } + + @override + void didUpdateWidget(TextField oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.controller == null && oldWidget.controller != null) { + _createLocalController(oldWidget.controller!.value); + } else if (widget.controller != null && oldWidget.controller == null) { + unregisterFromRestoration(_controller!); + _controller!.dispose(); + _controller = null; + } + + if (widget.focusNode != oldWidget.focusNode) { + (oldWidget.focusNode ?? _focusNode)?.removeListener(_handleFocusChanged); + (widget.focusNode ?? _focusNode)?.addListener(_handleFocusChanged); + } + + _effectiveFocusNode.canRequestFocus = _canRequestFocus; + + if (_effectiveFocusNode.hasFocus && + widget.readOnly != oldWidget.readOnly && + _isEnabled) { + if (_effectiveController.selection.isCollapsed) { + _showSelectionHandles = !widget.readOnly; + } + } + + if (widget.statesController == oldWidget.statesController) { + _statesController.update(MaterialState.disabled, !_isEnabled); + _statesController.update(MaterialState.hovered, _isHovering); + _statesController.update( + MaterialState.focused, _effectiveFocusNode.hasFocus); + _statesController.update(MaterialState.error, _hasError); + } else { + oldWidget.statesController?.removeListener(_handleStatesControllerChange); + if (widget.statesController != null) { + _internalStatesController?.dispose(); + _internalStatesController = null; + } + _initStatesController(); + } + } + + @override + void restoreState(RestorationBucket? oldBucket, bool initialRestore) { + if (_controller != null) { + _registerController(); + } + } + + void _registerController() { + assert(_controller != null); + registerForRestoration(_controller!, 'controller'); + } + + void _createLocalController([TextEditingValue? value]) { + assert(_controller == null); + _controller = value == null + ? RestorableTextEditingController() + : RestorableTextEditingController.fromValue(value); + if (!restorePending) { + _registerController(); + } + } + + @override + String? get restorationId => widget.restorationId; + + @override + void dispose() { + _effectiveFocusNode.removeListener(_handleFocusChanged); + _focusNode?.dispose(); + _controller?.dispose(); + _statesController.removeListener(_handleStatesControllerChange); + _internalStatesController?.dispose(); + super.dispose(); + } + + EditableTextState? get _editableText => editableTextKey.currentState; + + void _requestKeyboard() { + _editableText?.requestKeyboard(); + } + + bool _shouldShowSelectionHandles(SelectionChangedCause? cause) { + // When the text field is activated by something that doesn't trigger the + // selection overlay, we shouldn't show the handles either. + if (!_selectionGestureDetectorBuilder.shouldShowSelectionToolbar) { + return false; + } + + if (cause == SelectionChangedCause.keyboard) { + return false; + } + + if (widget.readOnly && _effectiveController.selection.isCollapsed) { + return false; + } + + if (!_isEnabled) { + return false; + } + + if (cause == SelectionChangedCause.longPress || + cause == SelectionChangedCause.stylusHandwriting) { + return true; + } + + if (_effectiveController.text.isNotEmpty) { + return true; + } + + return false; + } + + void _handleFocusChanged() { + setState(() { + // Rebuild the widget on focus change to show/hide the text selection + // highlight. + }); + _statesController.update( + MaterialState.focused, _effectiveFocusNode.hasFocus); + } + + void _handleSelectionChanged( + TextSelection selection, SelectionChangedCause? cause) { + final bool willShowSelectionHandles = _shouldShowSelectionHandles(cause); + if (willShowSelectionHandles != _showSelectionHandles) { + setState(() { + _showSelectionHandles = willShowSelectionHandles; + }); + } + + switch (Theme.of(context).platform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + case TargetPlatform.linux: + case TargetPlatform.windows: + case TargetPlatform.fuchsia: + case TargetPlatform.android: + if (cause == SelectionChangedCause.longPress) { + _editableText?.bringIntoView(selection.extent); + } + } + + switch (Theme.of(context).platform) { + case TargetPlatform.iOS: + case TargetPlatform.fuchsia: + case TargetPlatform.android: + break; + case TargetPlatform.macOS: + case TargetPlatform.linux: + case TargetPlatform.windows: + if (cause == SelectionChangedCause.drag) { + _editableText?.hideToolbar(); + } + } + } + + /// Toggle the toolbar when a selection handle is tapped. + void _handleSelectionHandleTapped() { + if (_effectiveController.selection.isCollapsed) { + _editableText!.toggleToolbar(); + } + } + + void _handleHover(bool hovering) { + if (hovering != _isHovering) { + setState(() { + _isHovering = hovering; + }); + _statesController.update(MaterialState.hovered, _isHovering); + } + } + + // Material states controller. + MaterialStatesController? _internalStatesController; + + void _handleStatesControllerChange() { + // Force a rebuild to resolve MaterialStateProperty properties. + setState(() {}); + } + + MaterialStatesController get _statesController => + widget.statesController ?? _internalStatesController!; + + void _initStatesController() { + if (widget.statesController == null) { + _internalStatesController = MaterialStatesController(); + } + _statesController.update(MaterialState.disabled, !_isEnabled); + _statesController.update(MaterialState.hovered, _isHovering); + _statesController.update( + MaterialState.focused, _effectiveFocusNode.hasFocus); + _statesController.update(MaterialState.error, _hasError); + _statesController.addListener(_handleStatesControllerChange); + } + + // AutofillClient implementation start. + @override + String get autofillId => _editableText!.autofillId; + + @override + void autofill(TextEditingValue newEditingValue) => + _editableText!.autofill(newEditingValue); + + @override + TextInputConfiguration get textInputConfiguration { + final List? autofillHints = + widget.autofillHints?.toList(growable: false); + final AutofillConfiguration autofillConfiguration = autofillHints != null + ? AutofillConfiguration( + uniqueIdentifier: autofillId, + autofillHints: autofillHints, + currentEditingValue: _effectiveController.value, + hintText: (widget.decoration ?? const InputDecoration()).hintText, + ) + : AutofillConfiguration.disabled; + + return _editableText!.textInputConfiguration.copyWith( + autofillConfiguration: autofillConfiguration, + ); + } + // AutofillClient implementation end. + + TextStyle _getInputStyleForState(TextStyle style) { + final ThemeData theme = Theme.of(context); + final TextStyle stateStyle = MaterialStateProperty.resolveAs( + theme.useMaterial3 + ? _m3StateInputStyle(context)! + : _m2StateInputStyle(context)!, + _statesController.value, + ); + final TextStyle providedStyle = + MaterialStateProperty.resolveAs(style, _statesController.value); + return providedStyle.merge(stateStyle); + } + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMaterial(context)); + assert(debugCheckHasMaterialLocalizations(context)); + assert(debugCheckHasDirectionality(context)); + assert( + !(widget.style != null && + !widget.style!.inherit && + (widget.style!.fontSize == null || + widget.style!.textBaseline == null)), + 'inherit false style must supply fontSize and textBaseline', + ); + + final ThemeData theme = Theme.of(context); + final DefaultSelectionStyle selectionStyle = + DefaultSelectionStyle.of(context); + final TextStyle? providedStyle = MaterialStateProperty.resolveAs( + widget.style, + _statesController.value, + ); + final TextStyle style = _getInputStyleForState( + theme.useMaterial3 + ? _m3InputStyle(context) + : theme.textTheme.titleMedium!, + ).merge(providedStyle); + final Brightness keyboardAppearance = + widget.keyboardAppearance ?? theme.brightness; + final TextEditingController controller = _effectiveController; + final FocusNode focusNode = _effectiveFocusNode; + final List formatters = [ + ...?widget.inputFormatters, + if (widget.maxLength != null) + LengthLimitingTextInputFormatter( + widget.maxLength, + maxLengthEnforcement: _effectiveMaxLengthEnforcement, + ), + ]; + + // Set configuration as disabled if not otherwise specified. If specified, + // ensure that configuration uses the correct style for misspelled words for + // the current platform, unless a custom style is specified. + final SpellCheckConfiguration spellCheckConfiguration; + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + spellCheckConfiguration = + CupertinoTextField.inferIOSSpellCheckConfiguration( + widget.spellCheckConfiguration, + ); + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + spellCheckConfiguration = TextField.inferAndroidSpellCheckConfiguration( + widget.spellCheckConfiguration, + ); + } + + TextSelectionControls? textSelectionControls = widget.selectionControls; + final bool paintCursorAboveText; + bool? cursorOpacityAnimates = widget.cursorOpacityAnimates; + Offset? cursorOffset; + final Color cursorColor; + final Color selectionColor; + Color? autocorrectionTextRectColor; + Radius? cursorRadius = widget.cursorRadius; + VoidCallback? handleDidGainAccessibilityFocus; + VoidCallback? handleDidLoseAccessibilityFocus; + + switch (theme.platform) { + case TargetPlatform.iOS: + final CupertinoThemeData cupertinoTheme = CupertinoTheme.of(context); + forcePressEnabled = true; + textSelectionControls ??= cupertinoTextSelectionHandleControls; + paintCursorAboveText = true; + cursorOpacityAnimates ??= true; + cursorColor = _hasError + ? _errorColor + : widget.cursorColor ?? + selectionStyle.cursorColor ?? + cupertinoTheme.primaryColor; + selectionColor = selectionStyle.selectionColor ?? + cupertinoTheme.primaryColor.withOpacity(0.40); + cursorRadius ??= const Radius.circular(2.0); + cursorOffset = Offset( + iOSHorizontalOffset / MediaQuery.devicePixelRatioOf(context), 0); + autocorrectionTextRectColor = selectionColor; + + case TargetPlatform.macOS: + final CupertinoThemeData cupertinoTheme = CupertinoTheme.of(context); + forcePressEnabled = false; + textSelectionControls ??= cupertinoDesktopTextSelectionHandleControls; + paintCursorAboveText = true; + cursorOpacityAnimates ??= false; + cursorColor = _hasError + ? _errorColor + : widget.cursorColor ?? + selectionStyle.cursorColor ?? + cupertinoTheme.primaryColor; + selectionColor = selectionStyle.selectionColor ?? + cupertinoTheme.primaryColor.withOpacity(0.40); + cursorRadius ??= const Radius.circular(2.0); + cursorOffset = Offset( + iOSHorizontalOffset / MediaQuery.devicePixelRatioOf(context), 0); + handleDidGainAccessibilityFocus = () { + // Automatically activate the TextField when it receives accessibility focus. + if (!_effectiveFocusNode.hasFocus && + _effectiveFocusNode.canRequestFocus) { + _effectiveFocusNode.requestFocus(); + } + }; + handleDidLoseAccessibilityFocus = () { + _effectiveFocusNode.unfocus(); + }; + + case TargetPlatform.android: + case TargetPlatform.fuchsia: + forcePressEnabled = false; + textSelectionControls ??= materialTextSelectionHandleControls; + paintCursorAboveText = false; + cursorOpacityAnimates ??= false; + cursorColor = _hasError + ? _errorColor + : widget.cursorColor ?? + selectionStyle.cursorColor ?? + theme.colorScheme.primary; + selectionColor = selectionStyle.selectionColor ?? + theme.colorScheme.primary.withOpacity(0.40); + + case TargetPlatform.linux: + forcePressEnabled = false; + textSelectionControls ??= desktopTextSelectionHandleControls; + paintCursorAboveText = false; + cursorOpacityAnimates ??= false; + cursorColor = _hasError + ? _errorColor + : widget.cursorColor ?? + selectionStyle.cursorColor ?? + theme.colorScheme.primary; + selectionColor = selectionStyle.selectionColor ?? + theme.colorScheme.primary.withOpacity(0.40); + handleDidGainAccessibilityFocus = () { + // Automatically activate the TextField when it receives accessibility focus. + if (!_effectiveFocusNode.hasFocus && + _effectiveFocusNode.canRequestFocus) { + _effectiveFocusNode.requestFocus(); + } + }; + handleDidLoseAccessibilityFocus = () { + _effectiveFocusNode.unfocus(); + }; + + case TargetPlatform.windows: + forcePressEnabled = false; + textSelectionControls ??= desktopTextSelectionHandleControls; + paintCursorAboveText = false; + cursorOpacityAnimates ??= false; + cursorColor = _hasError + ? _errorColor + : widget.cursorColor ?? + selectionStyle.cursorColor ?? + theme.colorScheme.primary; + selectionColor = selectionStyle.selectionColor ?? + theme.colorScheme.primary.withOpacity(0.40); + handleDidGainAccessibilityFocus = () { + // Automatically activate the TextField when it receives accessibility focus. + if (!_effectiveFocusNode.hasFocus && + _effectiveFocusNode.canRequestFocus) { + _effectiveFocusNode.requestFocus(); + } + }; + handleDidLoseAccessibilityFocus = () { + _effectiveFocusNode.unfocus(); + }; + } + + Widget child = RepaintBoundary( + child: UnmanagedRestorationScope( + bucket: bucket, + child: EditableText( + key: editableTextKey, + readOnly: widget.readOnly || !_isEnabled, + toolbarOptions: widget.toolbarOptions, + showCursor: widget.showCursor, + showSelectionHandles: _showSelectionHandles, + controller: controller, + focusNode: focusNode, + undoController: widget.undoController, + keyboardType: widget.keyboardType, + textInputAction: widget.textInputAction, + textCapitalization: widget.textCapitalization, + style: style, + strutStyle: widget.strutStyle, + textAlign: widget.textAlign, + textDirection: widget.textDirection, + autofocus: widget.autofocus, + obscuringCharacter: widget.obscuringCharacter, + obscureText: widget.obscureText, + autocorrect: widget.autocorrect, + smartDashesType: widget.smartDashesType, + smartQuotesType: widget.smartQuotesType, + enableSuggestions: widget.enableSuggestions, + maxLines: widget.maxLines, + minLines: widget.minLines, + expands: widget.expands, + // Only show the selection highlight when the text field is focused. + selectionColor: focusNode.hasFocus ? selectionColor : null, + selectionControls: + widget.selectionEnabled ? textSelectionControls : null, + onChanged: widget.onChanged, + onSelectionChanged: _handleSelectionChanged, + onEditingComplete: widget.onEditingComplete, + onSubmitted: widget.onSubmitted, + onAppPrivateCommand: widget.onAppPrivateCommand, + groupId: widget.groupId, + onSelectionHandleTapped: _handleSelectionHandleTapped, + onTapOutside: widget.onTapOutside, + onTapUpOutside: widget.onTapUpOutside, + inputFormatters: formatters, + rendererIgnoresPointer: true, + mouseCursor: MouseCursor.defer, // TextField will handle the cursor + cursorWidth: widget.cursorWidth, + cursorHeight: widget.cursorHeight, + cursorRadius: cursorRadius, + cursorColor: cursorColor, + selectionHeightStyle: widget.selectionHeightStyle, + selectionWidthStyle: widget.selectionWidthStyle, + cursorOpacityAnimates: cursorOpacityAnimates, + cursorOffset: cursorOffset, + paintCursorAboveText: paintCursorAboveText, + backgroundCursorColor: CupertinoColors.inactiveGray, + scrollPadding: widget.scrollPadding, + keyboardAppearance: keyboardAppearance, + enableInteractiveSelection: widget.enableInteractiveSelection, + dragStartBehavior: widget.dragStartBehavior, + scrollController: widget.scrollController, + scrollPhysics: widget.scrollPhysics, + autofillClient: this, + autocorrectionTextRectColor: autocorrectionTextRectColor, + clipBehavior: widget.clipBehavior, + restorationId: 'editable', + scribbleEnabled: widget.scribbleEnabled, + stylusHandwritingEnabled: widget.stylusHandwritingEnabled, + enableIMEPersonalizedLearning: widget.enableIMEPersonalizedLearning, + contentInsertionConfiguration: widget.contentInsertionConfiguration, + contextMenuBuilder: widget.contextMenuBuilder, + spellCheckConfiguration: spellCheckConfiguration, + magnifierConfiguration: widget.magnifierConfiguration ?? + TextMagnifier.adaptiveMagnifierConfiguration, + onDelAtUser: widget.onDelAtUser, + onMention: widget.onMention, + ), + ), + ); + + if (widget.decoration != null) { + child = AnimatedBuilder( + animation: Listenable.merge([focusNode, controller]), + builder: (BuildContext context, Widget? child) { + return InputDecorator( + decoration: _getEffectiveDecoration(), + baseStyle: widget.style, + textAlign: widget.textAlign, + textAlignVertical: widget.textAlignVertical, + isHovering: _isHovering, + isFocused: focusNode.hasFocus, + isEmpty: controller.value.text.isEmpty, + expands: widget.expands, + child: child, + ); + }, + child: child, + ); + } + final MouseCursor effectiveMouseCursor = + MaterialStateProperty.resolveAs( + widget.mouseCursor ?? MaterialStateMouseCursor.textable, + _statesController.value, + ); + + final int? semanticsMaxValueLength; + if (_effectiveMaxLengthEnforcement != MaxLengthEnforcement.none && + widget.maxLength != null && + widget.maxLength! > 0) { + semanticsMaxValueLength = widget.maxLength; + } else { + semanticsMaxValueLength = null; + } + + return MouseRegion( + cursor: effectiveMouseCursor, + onEnter: (PointerEnterEvent event) => _handleHover(true), + onExit: (PointerExitEvent event) => _handleHover(false), + child: TextFieldTapRegion( + child: IgnorePointer( + ignoring: widget.ignorePointers ?? !_isEnabled, + child: AnimatedBuilder( + animation: controller, // changes the _currentLength + builder: (BuildContext context, Widget? child) { + return Semantics( + enabled: _isEnabled, + maxValueLength: semanticsMaxValueLength, + currentValueLength: _currentLength, + onTap: widget.readOnly + ? null + : () { + if (!_effectiveController.selection.isValid) { + _effectiveController.selection = + TextSelection.collapsed( + offset: _effectiveController.text.length, + ); + } + _requestKeyboard(); + }, + onDidGainAccessibilityFocus: handleDidGainAccessibilityFocus, + onDidLoseAccessibilityFocus: handleDidLoseAccessibilityFocus, + onFocus: _isEnabled + ? () { + assert( + _effectiveFocusNode.canRequestFocus, + 'Received SemanticsAction.focus from the engine. However, the FocusNode ' + 'of this text field cannot gain focus. This likely indicates a bug. ' + 'If this text field cannot be focused (e.g. because it is not ' + 'enabled), then its corresponding semantics node must be configured ' + 'such that the assistive technology cannot request focus on it.', + ); + + if (_effectiveFocusNode.canRequestFocus && + !_effectiveFocusNode.hasFocus) { + _effectiveFocusNode.requestFocus(); + } else if (!widget.readOnly) { + // If the platform requested focus, that means that previously the + // platform believed that the text field did not have focus (even + // though Flutter's widget system believed otherwise). This likely + // means that the on-screen keyboard is hidden, or more generally, + // there is no current editing session in this field. To correct + // that, keyboard must be requested. + // + // A concrete scenario where this can happen is when the user + // dismisses the keyboard on the web. The editing session is + // closed by the engine, but the text field widget stays focused + // in the framework. + _requestKeyboard(); + } + } + : null, + child: child, + ); + }, + child: _selectionGestureDetectorBuilder.buildGestureDetector( + behavior: HitTestBehavior.translucent, + child: child, + ), + ), + ), + ), + ); + } +} + +TextStyle? _m2StateInputStyle(BuildContext context) => + MaterialStateTextStyle.resolveWith((Set states) { + final ThemeData theme = Theme.of(context); + if (states.contains(MaterialState.disabled)) { + return TextStyle(color: theme.disabledColor); + } + return TextStyle(color: theme.textTheme.titleMedium?.color); + }); + +TextStyle _m2CounterErrorStyle(BuildContext context) => Theme.of(context) + .textTheme + .bodySmall! + .copyWith(color: Theme.of(context).colorScheme.error); + +// BEGIN GENERATED TOKEN PROPERTIES - TextField + +// Do not edit by hand. The code between the "BEGIN GENERATED" and +// "END GENERATED" comments are generated from data in the Material +// Design token database by the script: +// dev/tools/gen_defaults/bin/gen_defaults.dart. + +// dart format off +TextStyle? _m3StateInputStyle(BuildContext context) => + MaterialStateTextStyle.resolveWith((Set states) { + if (states.contains(MaterialState.disabled)) { + return TextStyle( + color: Theme.of(context) + .textTheme + .bodyLarge! + .color + ?.withOpacity(0.38)); + } + return TextStyle(color: Theme.of(context).textTheme.bodyLarge!.color); + }); + +TextStyle _m3InputStyle(BuildContext context) => + Theme.of(context).textTheme.bodyLarge!; + +TextStyle _m3CounterErrorStyle(BuildContext context) => Theme.of(context) + .textTheme + .bodySmall! + .copyWith(color: Theme.of(context).colorScheme.error); +// dart format on + +// END GENERATED TOKEN PROPERTIES - TextField diff --git a/lib/common/widgets/text_field/text_selection.dart b/lib/common/widgets/text_field/text_selection.dart new file mode 100644 index 00000000..683a5cef --- /dev/null +++ b/lib/common/widgets/text_field/text_selection.dart @@ -0,0 +1,1473 @@ +import 'dart:math' as math; +import 'dart:ui'; + +import 'package:PiliPlus/common/widgets/text_field/editable_text.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart' hide EditableText, EditableTextState; + +abstract class TextSelectionGestureDetectorBuilderDelegate { + /// [GlobalKey] to the [EditableText] for which the + /// [TextSelectionGestureDetectorBuilder] will build a [TextSelectionGestureDetector]. + GlobalKey get editableTextKey; + + /// Whether the text field should respond to force presses. + bool get forcePressEnabled; + + /// Whether the user may select text in the text field. + bool get selectionEnabled; +} + +/// Builds a [TextSelectionGestureDetector] to wrap an [EditableText]. +/// +/// The class implements sensible defaults for many user interactions +/// with an [EditableText] (see the documentation of the various gesture handler +/// methods, e.g. [onTapDown], [onForcePressStart], etc.). Subclasses of +/// [TextSelectionGestureDetectorBuilder] can change the behavior performed in +/// responds to these gesture events by overriding the corresponding handler +/// methods of this class. +/// +/// The resulting [TextSelectionGestureDetector] to wrap an [EditableText] is +/// obtained by calling [buildGestureDetector]. +/// +/// A [TextSelectionGestureDetectorBuilder] must be provided a +/// [TextSelectionGestureDetectorBuilderDelegate], from which information about +/// the [EditableText] may be obtained. Typically, the [State] of the widget +/// that builds the [EditableText] implements this interface, and then passes +/// itself as the [delegate]. +/// +/// See also: +/// +/// * [TextField], which uses a subclass to implement the Material-specific +/// gesture logic of an [EditableText]. +/// * [CupertinoTextField], which uses a subclass to implement the +/// Cupertino-specific gesture logic of an [EditableText]. +class TextSelectionGestureDetectorBuilder { + /// Creates a [TextSelectionGestureDetectorBuilder]. + TextSelectionGestureDetectorBuilder({required this.delegate}); + + /// The delegate for this [TextSelectionGestureDetectorBuilder]. + /// + /// The delegate provides the builder with information about what actions can + /// currently be performed on the text field. Based on this, the builder adds + /// the correct gesture handlers to the gesture detector. + /// + /// Typically implemented by a [State] of a widget that builds an + /// [EditableText]. + @protected + final TextSelectionGestureDetectorBuilderDelegate delegate; + + // Shows the magnifier on supported platforms at the given offset, currently + // only Android and iOS. + void _showMagnifierIfSupportedByPlatform(Offset positionToShow) { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.iOS: + editableText.showMagnifier(positionToShow); + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + } + } + + // Hides the magnifier on supported platforms, currently only Android and iOS. + void _hideMagnifierIfSupportedByPlatform() { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.iOS: + editableText.hideMagnifier(); + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + } + } + + /// Returns true if lastSecondaryTapDownPosition was on selection. + bool get _lastSecondaryTapWasOnSelection { + assert(renderEditable.lastSecondaryTapDownPosition != null); + if (renderEditable.selection == null) { + return false; + } + + final TextPosition textPosition = renderEditable.getPositionForPoint( + renderEditable.lastSecondaryTapDownPosition!, + ); + + return renderEditable.selection!.start <= textPosition.offset && + renderEditable.selection!.end >= textPosition.offset; + } + + bool _positionWasOnSelectionExclusive(TextPosition textPosition) { + final TextSelection? selection = renderEditable.selection; + if (selection == null) { + return false; + } + + return selection.start < textPosition.offset && + selection.end > textPosition.offset; + } + + bool _positionWasOnSelectionInclusive(TextPosition textPosition) { + final TextSelection? selection = renderEditable.selection; + if (selection == null) { + return false; + } + + return selection.start <= textPosition.offset && + selection.end >= textPosition.offset; + } + + // Expand the selection to the given global position. + // + // Either base or extent will be moved to the last tapped position, whichever + // is closest. The selection will never shrink or pivot, only grow. + // + // If fromSelection is given, will expand from that selection instead of the + // current selection in renderEditable. + // + // See also: + // + // * [_extendSelection], which is similar but pivots the selection around + // the base. + void _expandSelection( + Offset offset, + SelectionChangedCause cause, [ + TextSelection? fromSelection, + ]) { + assert(renderEditable.selection?.baseOffset != null); + + final TextPosition tappedPosition = + renderEditable.getPositionForPoint(offset); + final TextSelection selection = fromSelection ?? renderEditable.selection!; + final bool baseIsCloser = + (tappedPosition.offset - selection.baseOffset).abs() < + (tappedPosition.offset - selection.extentOffset).abs(); + final TextSelection nextSelection = selection.copyWith( + baseOffset: baseIsCloser ? selection.extentOffset : selection.baseOffset, + extentOffset: tappedPosition.offset, + ); + + editableText.userUpdateTextEditingValue( + editableText.textEditingValue.copyWith(selection: nextSelection), + cause, + ); + } + + // Extend the selection to the given global position. + // + // Holds the base in place and moves the extent. + // + // See also: + // + // * [_expandSelection], which is similar but always increases the size of + // the selection. + void _extendSelection(Offset offset, SelectionChangedCause cause) { + assert(renderEditable.selection?.baseOffset != null); + + final TextPosition tappedPosition = + renderEditable.getPositionForPoint(offset); + final TextSelection selection = renderEditable.selection!; + final TextSelection nextSelection = + selection.copyWith(extentOffset: tappedPosition.offset); + + editableText.userUpdateTextEditingValue( + editableText.textEditingValue.copyWith(selection: nextSelection), + cause, + ); + } + + /// Whether to show the selection toolbar. + /// + /// It is based on the signal source when a [onTapDown] is called. This getter + /// will return true if current [onTapDown] event is triggered by a touch or + /// a stylus. + bool get shouldShowSelectionToolbar => _shouldShowSelectionToolbar; + bool _shouldShowSelectionToolbar = true; + + /// The [State] of the [EditableText] for which the builder will provide a + /// [TextSelectionGestureDetector]. + @protected + EditableTextState get editableText => delegate.editableTextKey.currentState!; + + /// The [RenderObject] of the [EditableText] for which the builder will + /// provide a [TextSelectionGestureDetector]. + @protected + RenderEditable get renderEditable => editableText.renderEditable; + + /// Whether the Shift key was pressed when the most recent [PointerDownEvent] + /// was tracked by the [BaseTapAndDragGestureRecognizer]. + bool _isShiftPressed = false; + + /// The viewport offset pixels of any [Scrollable] containing the + /// [RenderEditable] at the last drag start. + double _dragStartScrollOffset = 0.0; + + /// The viewport offset pixels of the [RenderEditable] at the last drag start. + double _dragStartViewportOffset = 0.0; + + double get _scrollPosition { + final ScrollableState? scrollableState = + delegate.editableTextKey.currentContext == null + ? null + : Scrollable.maybeOf(delegate.editableTextKey.currentContext!); + return scrollableState == null ? 0.0 : scrollableState.position.pixels; + } + + AxisDirection? get _scrollDirection { + final ScrollableState? scrollableState = + delegate.editableTextKey.currentContext == null + ? null + : Scrollable.maybeOf(delegate.editableTextKey.currentContext!); + return scrollableState?.axisDirection; + } + + // For a shift + tap + drag gesture, the TextSelection at the point of the + // tap. Mac uses this value to reset to the original selection when an + // inversion of the base and offset happens. + TextSelection? _dragStartSelection; + + // For iOS long press behavior when the field is not focused. iOS uses this value + // to determine if a long press began on a field that was not focused. + // + // If the field was not focused when the long press began, a long press will select + // the word and a long press move will select word-by-word. If the field was + // focused, the cursor moves to the long press position. + bool _longPressStartedWithoutFocus = false; + + /// Handler for [TextSelectionGestureDetector.onTapTrackStart]. + /// + /// See also: + /// + /// * [TextSelectionGestureDetector.onTapTrackStart], which triggers this + /// callback. + @protected + void onTapTrackStart() { + _isShiftPressed = HardwareKeyboard.instance.logicalKeysPressed + .intersection({ + LogicalKeyboardKey.shiftLeft, + LogicalKeyboardKey.shiftRight, + }).isNotEmpty; + } + + /// Handler for [TextSelectionGestureDetector.onTapTrackReset]. + /// + /// See also: + /// + /// * [TextSelectionGestureDetector.onTapTrackReset], which triggers this + /// callback. + @protected + void onTapTrackReset() { + _isShiftPressed = false; + } + + /// Handler for [TextSelectionGestureDetector.onTapDown]. + /// + /// By default, it forwards the tap to [RenderEditable.handleTapDown] and sets + /// [shouldShowSelectionToolbar] to true if the tap was initiated by a finger or stylus. + /// + /// See also: + /// + /// * [TextSelectionGestureDetector.onTapDown], which triggers this callback. + @protected + void onTapDown(TapDragDownDetails details) { + if (!delegate.selectionEnabled) { + return; + } + + // TODO(Renzo-Olivares): Migrate text selection gestures away from saving state + // in renderEditable. The gesture callbacks can use the details objects directly + // in callbacks variants that provide them [TapGestureRecognizer.onSecondaryTap] + // vs [TapGestureRecognizer.onSecondaryTapUp] instead of having to track state in + // renderEditable. When this migration is complete we should remove this hack. + // See https://github.com/flutter/flutter/issues/115130. + renderEditable + .handleTapDown(TapDownDetails(globalPosition: details.globalPosition)); + // The selection overlay should only be shown when the user is interacting + // through a touch screen (via either a finger or a stylus). A mouse shouldn't + // trigger the selection overlay. + // For backwards-compatibility, we treat a null kind the same as touch. + final PointerDeviceKind? kind = details.kind; + // TODO(justinmc): Should a desktop platform show its selection toolbar when + // receiving a tap event? Say a Windows device with a touchscreen. + // https://github.com/flutter/flutter/issues/106586 + _shouldShowSelectionToolbar = kind == null || + kind == PointerDeviceKind.touch || + kind == PointerDeviceKind.stylus; + + // It is impossible to extend the selection when the shift key is pressed, if the + // renderEditable.selection is invalid. + final bool isShiftPressedValid = + _isShiftPressed && renderEditable.selection?.baseOffset != null; + switch (defaultTargetPlatform) { + case TargetPlatform.android: + if (editableText.widget.stylusHandwritingEnabled) { + final bool stylusEnabled = switch (kind) { + PointerDeviceKind.stylus || + PointerDeviceKind.invertedStylus => + editableText.widget.stylusHandwritingEnabled, + _ => false, + }; + if (stylusEnabled) { + Scribe.isFeatureAvailable().then((bool isAvailable) { + if (isAvailable) { + renderEditable.selectPosition( + cause: SelectionChangedCause.stylusHandwriting); + Scribe.startStylusHandwriting(); + } + }); + } + } + case TargetPlatform.fuchsia: + case TargetPlatform.iOS: + // On mobile platforms the selection is set on tap up. + break; + case TargetPlatform.macOS: + editableText.hideToolbar(); + // On macOS, a shift-tapped unfocused field expands from 0, not from the + // previous selection. + if (isShiftPressedValid) { + final TextSelection? fromSelection = renderEditable.hasFocus + ? null + : const TextSelection.collapsed(offset: 0); + _expandSelection( + details.globalPosition, SelectionChangedCause.tap, fromSelection); + return; + } + // On macOS, a tap/click places the selection in a precise position. + // This differs from iOS/iPadOS, where if the gesture is done by a touch + // then the selection moves to the closest word edge, instead of a + // precise position. + renderEditable.selectPosition(cause: SelectionChangedCause.tap); + case TargetPlatform.linux: + case TargetPlatform.windows: + editableText.hideToolbar(); + if (isShiftPressedValid) { + _extendSelection(details.globalPosition, SelectionChangedCause.tap); + return; + } + renderEditable.selectPosition(cause: SelectionChangedCause.tap); + } + } + + /// Handler for [TextSelectionGestureDetector.onForcePressStart]. + /// + /// By default, it selects the word at the position of the force press, + /// if selection is enabled. + /// + /// This callback is only applicable when force press is enabled. + /// + /// See also: + /// + /// * [TextSelectionGestureDetector.onForcePressStart], which triggers this + /// callback. + @protected + void onForcePressStart(ForcePressDetails details) { + assert(delegate.forcePressEnabled); + _shouldShowSelectionToolbar = true; + if (!delegate.selectionEnabled) { + return; + } + renderEditable.selectWordsInRange( + from: details.globalPosition, + cause: SelectionChangedCause.forcePress, + ); + editableText.showToolbar(); + } + + /// Handler for [TextSelectionGestureDetector.onForcePressEnd]. + /// + /// By default, it selects words in the range specified in [details] and shows + /// toolbar if it is necessary. + /// + /// This callback is only applicable when force press is enabled. + /// + /// See also: + /// + /// * [TextSelectionGestureDetector.onForcePressEnd], which triggers this + /// callback. + @protected + void onForcePressEnd(ForcePressDetails details) { + assert(delegate.forcePressEnabled); + renderEditable.selectWordsInRange( + from: details.globalPosition, + cause: SelectionChangedCause.forcePress, + ); + if (shouldShowSelectionToolbar) { + editableText.showToolbar(); + } + } + + /// Whether the provided [onUserTap] callback should be dispatched on every + /// tap or only non-consecutive taps. + /// + /// Defaults to false. + @protected + bool get onUserTapAlwaysCalled => false; + + /// Handler for [TextSelectionGestureDetector.onUserTap]. + /// + /// By default, it serves as placeholder to enable subclass override. + /// + /// See also: + /// + /// * [TextSelectionGestureDetector.onUserTap], which triggers this + /// callback. + /// * [TextSelectionGestureDetector.onUserTapAlwaysCalled], which controls + /// whether this callback is called only on the first tap in a series + /// of taps. + @protected + void onUserTap() { + /* Subclass should override this method if needed. */ + } + + /// Handler for [TextSelectionGestureDetector.onSingleTapUp]. + /// + /// By default, it selects word edge if selection is enabled. + /// + /// See also: + /// + /// * [TextSelectionGestureDetector.onSingleTapUp], which triggers + /// this callback. + @protected + void onSingleTapUp(TapDragUpDetails details) { + if (!delegate.selectionEnabled) { + editableText.requestKeyboard(); + return; + } + // It is impossible to extend the selection when the shift key is pressed, if the + // renderEditable.selection is invalid. + final bool isShiftPressedValid = + _isShiftPressed && renderEditable.selection?.baseOffset != null; + switch (defaultTargetPlatform) { + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + break; + // On desktop platforms the selection is set on tap down. + case TargetPlatform.android: + editableText.hideToolbar(false); + if (isShiftPressedValid) { + _extendSelection(details.globalPosition, SelectionChangedCause.tap); + return; + } + renderEditable.selectPosition(cause: SelectionChangedCause.tap); + editableText.showSpellCheckSuggestionsToolbar(); + case TargetPlatform.fuchsia: + editableText.hideToolbar(false); + if (isShiftPressedValid) { + _extendSelection(details.globalPosition, SelectionChangedCause.tap); + return; + } + renderEditable.selectPosition(cause: SelectionChangedCause.tap); + case TargetPlatform.iOS: + if (isShiftPressedValid) { + // On iOS, a shift-tapped unfocused field expands from 0, not from + // the previous selection. + final TextSelection? fromSelection = renderEditable.hasFocus + ? null + : const TextSelection.collapsed(offset: 0); + _expandSelection( + details.globalPosition, SelectionChangedCause.tap, fromSelection); + return; + } + switch (details.kind) { + case PointerDeviceKind.mouse: + case PointerDeviceKind.trackpad: + case PointerDeviceKind.stylus: + case PointerDeviceKind.invertedStylus: + // TODO(camsim99): Determine spell check toolbar behavior in these cases: + // https://github.com/flutter/flutter/issues/119573. + // Precise devices should place the cursor at a precise position if the + // word at the text position is not misspelled. + renderEditable.selectPosition(cause: SelectionChangedCause.tap); + case PointerDeviceKind.touch: + case PointerDeviceKind.unknown: + // If the word that was tapped is misspelled, select the word and show the spell check suggestions + // toolbar once. If additional taps are made on a misspelled word, toggle the toolbar. If the word + // is not misspelled, default to the following behavior: + // + // Toggle the toolbar when the tap is exclusively within the bounds of a non-collapsed `previousSelection`, + // and the editable is focused. + // + // Toggle the toolbar if the `previousSelection` is collapsed, the tap is on the selection, the + // TextAffinity remains the same, the editable field is not read only, and the editable is focused. + // The TextAffinity is important when the cursor is on the boundary of a line wrap, if the affinity + // is different (i.e. it is downstream), the selection should move to the following line and not toggle + // the toolbar. + // + // Selects the word edge closest to the tap when the editable is not focused, or if the tap was neither exclusively + // or inclusively on `previousSelection`. If the selection remains the same after selecting the word edge, then we + // toggle the toolbar, if the editable field is not read only. If the selection changes then we hide the toolbar. + final TextSelection previousSelection = renderEditable.selection ?? + editableText.textEditingValue.selection; + final TextPosition textPosition = + renderEditable.getPositionForPoint( + details.globalPosition, + ); + final bool isAffinityTheSame = + textPosition.affinity == previousSelection.affinity; + final bool wordAtCursorIndexIsMisspelled = editableText + .findSuggestionSpanAtCursorIndex(textPosition.offset) != + null; + + if (wordAtCursorIndexIsMisspelled) { + renderEditable.selectWord(cause: SelectionChangedCause.tap); + if (previousSelection != + editableText.textEditingValue.selection) { + editableText.showSpellCheckSuggestionsToolbar(); + } else { + editableText.toggleToolbar(false); + } + } else if (((_positionWasOnSelectionExclusive(textPosition) && + !previousSelection.isCollapsed) || + (_positionWasOnSelectionInclusive(textPosition) && + previousSelection.isCollapsed && + isAffinityTheSame && + !renderEditable.readOnly)) && + renderEditable.hasFocus) { + editableText.toggleToolbar(false); + } else { + renderEditable.selectWordEdge(cause: SelectionChangedCause.tap); + if (previousSelection == + editableText.textEditingValue.selection && + renderEditable.hasFocus && + !renderEditable.readOnly) { + editableText.toggleToolbar(false); + } else { + editableText.hideToolbar(false); + } + } + } + } + editableText.requestKeyboard(); + } + + /// Handler for [TextSelectionGestureDetector.onSingleTapCancel]. + /// + /// By default, it serves as placeholder to enable subclass override. + /// + /// See also: + /// + /// * [TextSelectionGestureDetector.onSingleTapCancel], which triggers + /// this callback. + @protected + void onSingleTapCancel() { + /* Subclass should override this method if needed. */ + } + + /// Handler for [TextSelectionGestureDetector.onSingleLongTapStart]. + /// + /// By default, it selects text position specified in [details] if selection + /// is enabled. + /// + /// See also: + /// + /// * [TextSelectionGestureDetector.onSingleLongTapStart], which triggers + /// this callback. + @protected + void onSingleLongTapStart(LongPressStartDetails details) { + if (!delegate.selectionEnabled) { + return; + } + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + if (!renderEditable.hasFocus) { + _longPressStartedWithoutFocus = true; + renderEditable.selectWord(cause: SelectionChangedCause.longPress); + } else if (renderEditable.readOnly) { + renderEditable.selectWord(cause: SelectionChangedCause.longPress); + if (editableText.context.mounted) { + Feedback.forLongPress(editableText.context); + } + } else { + renderEditable.selectPositionAt( + from: details.globalPosition, + cause: SelectionChangedCause.longPress, + ); + // Show the floating cursor. + final RawFloatingCursorPoint cursorPoint = RawFloatingCursorPoint( + state: FloatingCursorDragState.Start, + startLocation: ( + renderEditable.globalToLocal(details.globalPosition), + TextPosition( + offset: editableText.textEditingValue.selection.baseOffset, + affinity: editableText.textEditingValue.selection.affinity, + ), + ), + offset: Offset.zero, + ); + editableText.updateFloatingCursor(cursorPoint); + } + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + renderEditable.selectWord(cause: SelectionChangedCause.longPress); + if (editableText.context.mounted) { + Feedback.forLongPress(editableText.context); + } + } + + _showMagnifierIfSupportedByPlatform(details.globalPosition); + + _dragStartViewportOffset = renderEditable.offset.pixels; + _dragStartScrollOffset = _scrollPosition; + } + + /// Handler for [TextSelectionGestureDetector.onSingleLongTapMoveUpdate]. + /// + /// By default, it updates the selection location specified in [details] if + /// selection is enabled. + /// + /// See also: + /// + /// * [TextSelectionGestureDetector.onSingleLongTapMoveUpdate], which + /// triggers this callback. + @protected + void onSingleLongTapMoveUpdate(LongPressMoveUpdateDetails details) { + if (!delegate.selectionEnabled) { + return; + } + // Adjust the drag start offset for possible viewport offset changes. + final Offset editableOffset = renderEditable.maxLines == 1 + ? Offset(renderEditable.offset.pixels - _dragStartViewportOffset, 0.0) + : Offset(0.0, renderEditable.offset.pixels - _dragStartViewportOffset); + final Offset scrollableOffset = switch (axisDirectionToAxis( + _scrollDirection ?? AxisDirection.left, + )) { + Axis.horizontal => Offset(_scrollPosition - _dragStartScrollOffset, 0.0), + Axis.vertical => Offset(0.0, _scrollPosition - _dragStartScrollOffset), + }; + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + if (_longPressStartedWithoutFocus || renderEditable.readOnly) { + renderEditable.selectWordsInRange( + from: details.globalPosition - + details.offsetFromOrigin - + editableOffset - + scrollableOffset, + to: details.globalPosition, + cause: SelectionChangedCause.longPress, + ); + } else { + renderEditable.selectPositionAt( + from: details.globalPosition, + cause: SelectionChangedCause.longPress, + ); + // Update the floating cursor. + final RawFloatingCursorPoint cursorPoint = RawFloatingCursorPoint( + state: FloatingCursorDragState.Update, + offset: details.offsetFromOrigin, + ); + editableText.updateFloatingCursor(cursorPoint); + } + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + renderEditable.selectWordsInRange( + from: details.globalPosition - + details.offsetFromOrigin - + editableOffset - + scrollableOffset, + to: details.globalPosition, + cause: SelectionChangedCause.longPress, + ); + } + + _showMagnifierIfSupportedByPlatform(details.globalPosition); + } + + /// Handler for [TextSelectionGestureDetector.onSingleLongTapEnd]. + /// + /// By default, it shows toolbar if necessary. + /// + /// See also: + /// + /// * [TextSelectionGestureDetector.onSingleLongTapEnd], which triggers this + /// callback. + @protected + void onSingleLongTapEnd(LongPressEndDetails details) { + _hideMagnifierIfSupportedByPlatform(); + if (shouldShowSelectionToolbar) { + editableText.showToolbar(); + } + _longPressStartedWithoutFocus = false; + _dragStartViewportOffset = 0.0; + _dragStartScrollOffset = 0.0; + if (defaultTargetPlatform == TargetPlatform.iOS && + delegate.selectionEnabled && + editableText.textEditingValue.selection.isCollapsed) { + // Update the floating cursor. + final RawFloatingCursorPoint cursorPoint = RawFloatingCursorPoint( + state: FloatingCursorDragState.End, + ); + editableText.updateFloatingCursor(cursorPoint); + } + } + + /// Handler for [TextSelectionGestureDetector.onSecondaryTap]. + /// + /// By default, selects the word if possible and shows the toolbar. + @protected + void onSecondaryTap() { + if (!delegate.selectionEnabled) { + return; + } + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + if (!_lastSecondaryTapWasOnSelection || !renderEditable.hasFocus) { + renderEditable.selectWord(cause: SelectionChangedCause.tap); + } + if (shouldShowSelectionToolbar) { + editableText.hideToolbar(); + editableText.showToolbar(); + } + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + if (!renderEditable.hasFocus) { + renderEditable.selectPosition(cause: SelectionChangedCause.tap); + } + editableText.toggleToolbar(); + } + } + + /// Handler for [TextSelectionGestureDetector.onSecondaryTapDown]. + /// + /// See also: + /// + /// * [TextSelectionGestureDetector.onSecondaryTapDown], which triggers this + /// callback. + /// * [onSecondaryTap], which is typically called after this. + @protected + void onSecondaryTapDown(TapDownDetails details) { + // TODO(Renzo-Olivares): Migrate text selection gestures away from saving state + // in renderEditable. The gesture callbacks can use the details objects directly + // in callbacks variants that provide them [TapGestureRecognizer.onSecondaryTap] + // vs [TapGestureRecognizer.onSecondaryTapUp] instead of having to track state in + // renderEditable. When this migration is complete we should remove this hack. + // See https://github.com/flutter/flutter/issues/115130. + renderEditable.handleSecondaryTapDown( + TapDownDetails(globalPosition: details.globalPosition)); + _shouldShowSelectionToolbar = true; + } + + /// Handler for [TextSelectionGestureDetector.onDoubleTapDown]. + /// + /// By default, it selects a word through [RenderEditable.selectWord] if + /// selectionEnabled and shows toolbar if necessary. + /// + /// See also: + /// + /// * [TextSelectionGestureDetector.onDoubleTapDown], which triggers this + /// callback. + @protected + void onDoubleTapDown(TapDragDownDetails details) { + if (delegate.selectionEnabled) { + renderEditable.selectWord(cause: SelectionChangedCause.doubleTap); + if (shouldShowSelectionToolbar) { + editableText.showToolbar(); + } + } + } + + // Selects the set of paragraphs in a document that intersect a given range of + // global positions. + void _selectParagraphsInRange( + {required Offset from, Offset? to, SelectionChangedCause? cause}) { + final TextBoundary paragraphBoundary = + ParagraphBoundary(editableText.textEditingValue.text); + _selectTextBoundariesInRange( + boundary: paragraphBoundary, from: from, to: to, cause: cause); + } + + // Selects the set of lines in a document that intersect a given range of + // global positions. + void _selectLinesInRange( + {required Offset from, Offset? to, SelectionChangedCause? cause}) { + final TextBoundary lineBoundary = LineBoundary(renderEditable); + _selectTextBoundariesInRange( + boundary: lineBoundary, from: from, to: to, cause: cause); + } + + // Returns the location of a text boundary at `extent`. When `extent` is at + // the end of the text, returns the previous text boundary's location. + TextRange _moveToTextBoundary( + TextPosition extent, TextBoundary textBoundary) { + assert(extent.offset >= 0); + // Use extent.offset - 1 when `extent` is at the end of the text to retrieve + // the previous text boundary's location. + final int start = textBoundary.getLeadingTextBoundaryAt( + extent.offset == editableText.textEditingValue.text.length + ? extent.offset - 1 + : extent.offset, + ) ?? + 0; + final int end = textBoundary.getTrailingTextBoundaryAt(extent.offset) ?? + editableText.textEditingValue.text.length; + return TextRange(start: start, end: end); + } + + // Selects the set of text boundaries in a document that intersect a given + // range of global positions. + // + // The set of text boundaries selected are not strictly bounded by the range + // of global positions. + // + // The first and last endpoints of the selection will always be at the + // beginning and end of a text boundary respectively. + void _selectTextBoundariesInRange({ + required TextBoundary boundary, + required Offset from, + Offset? to, + SelectionChangedCause? cause, + }) { + final TextPosition fromPosition = renderEditable.getPositionForPoint(from); + final TextRange fromRange = _moveToTextBoundary(fromPosition, boundary); + final TextPosition toPosition = + to == null ? fromPosition : renderEditable.getPositionForPoint(to); + final TextRange toRange = toPosition == fromPosition + ? fromRange + : _moveToTextBoundary(toPosition, boundary); + final bool isFromBoundaryBeforeToBoundary = fromRange.start < toRange.end; + + final TextSelection newSelection = isFromBoundaryBeforeToBoundary + ? TextSelection(baseOffset: fromRange.start, extentOffset: toRange.end) + : TextSelection(baseOffset: fromRange.end, extentOffset: toRange.start); + + editableText.userUpdateTextEditingValue( + editableText.textEditingValue.copyWith(selection: newSelection), + cause, + ); + } + + /// Handler for [TextSelectionGestureDetector.onTripleTapDown]. + /// + /// By default, it selects a paragraph if + /// [TextSelectionGestureDetectorBuilderDelegate.selectionEnabled] is true + /// and shows the toolbar if necessary. + /// + /// See also: + /// + /// * [TextSelectionGestureDetector.onTripleTapDown], which triggers this + /// callback. + @protected + void onTripleTapDown(TapDragDownDetails details) { + if (!delegate.selectionEnabled) { + return; + } + if (renderEditable.maxLines == 1) { + editableText.selectAll(SelectionChangedCause.tap); + } else { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.iOS: + case TargetPlatform.macOS: + case TargetPlatform.windows: + _selectParagraphsInRange( + from: details.globalPosition, cause: SelectionChangedCause.tap); + case TargetPlatform.linux: + _selectLinesInRange( + from: details.globalPosition, cause: SelectionChangedCause.tap); + } + } + if (shouldShowSelectionToolbar) { + editableText.showToolbar(); + } + } + + /// Handler for [TextSelectionGestureDetector.onDragSelectionStart]. + /// + /// By default, it selects a text position specified in [details]. + /// + /// See also: + /// + /// * [TextSelectionGestureDetector.onDragSelectionStart], which triggers + /// this callback. + @protected + void onDragSelectionStart(TapDragStartDetails details) { + if (!delegate.selectionEnabled) { + return; + } + final PointerDeviceKind? kind = details.kind; + _shouldShowSelectionToolbar = kind == null || + kind == PointerDeviceKind.touch || + kind == PointerDeviceKind.stylus; + + _dragStartSelection = renderEditable.selection; + _dragStartScrollOffset = _scrollPosition; + _dragStartViewportOffset = renderEditable.offset.pixels; + + if (_TextSelectionGestureDetectorState._getEffectiveConsecutiveTapCount( + details.consecutiveTapCount, + ) > + 1) { + // Do not set the selection on a consecutive tap and drag. + return; + } + + if (_isShiftPressed && + renderEditable.selection != null && + renderEditable.selection!.isValid) { + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + _expandSelection(details.globalPosition, SelectionChangedCause.drag); + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + _extendSelection(details.globalPosition, SelectionChangedCause.drag); + } + } else { + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + switch (details.kind) { + case PointerDeviceKind.mouse: + case PointerDeviceKind.trackpad: + renderEditable.selectPositionAt( + from: details.globalPosition, + cause: SelectionChangedCause.drag, + ); + case PointerDeviceKind.stylus: + case PointerDeviceKind.invertedStylus: + case PointerDeviceKind.touch: + case PointerDeviceKind.unknown: + case null: + } + case TargetPlatform.android: + case TargetPlatform.fuchsia: + switch (details.kind) { + case PointerDeviceKind.mouse: + case PointerDeviceKind.trackpad: + renderEditable.selectPositionAt( + from: details.globalPosition, + cause: SelectionChangedCause.drag, + ); + case PointerDeviceKind.stylus: + case PointerDeviceKind.invertedStylus: + case PointerDeviceKind.touch: + case PointerDeviceKind.unknown: + // For Android, Fuchsia, and iOS platforms, a touch drag + // does not initiate unless the editable has focus. + if (renderEditable.hasFocus) { + renderEditable.selectPositionAt( + from: details.globalPosition, + cause: SelectionChangedCause.drag, + ); + _showMagnifierIfSupportedByPlatform(details.globalPosition); + } + case null: + } + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + renderEditable.selectPositionAt( + from: details.globalPosition, + cause: SelectionChangedCause.drag, + ); + } + } + } + + /// Handler for [TextSelectionGestureDetector.onDragSelectionUpdate]. + /// + /// By default, it updates the selection location specified in the provided + /// details objects. + /// + /// See also: + /// + /// * [TextSelectionGestureDetector.onDragSelectionUpdate], which triggers + /// this callback./lib/src/material/text_field.dart + @protected + void onDragSelectionUpdate(TapDragUpdateDetails details) { + if (!delegate.selectionEnabled) { + return; + } + + if (!_isShiftPressed) { + // Adjust the drag start offset for possible viewport offset changes. + final Offset editableOffset = renderEditable.maxLines == 1 + ? Offset(renderEditable.offset.pixels - _dragStartViewportOffset, 0.0) + : Offset( + 0.0, renderEditable.offset.pixels - _dragStartViewportOffset); + final Offset scrollableOffset = switch (axisDirectionToAxis( + _scrollDirection ?? AxisDirection.left, + )) { + Axis.horizontal => + Offset(_scrollPosition - _dragStartScrollOffset, 0.0), + Axis.vertical => Offset(0.0, _scrollPosition - _dragStartScrollOffset), + }; + final Offset dragStartGlobalPosition = + details.globalPosition - details.offsetFromOrigin; + + // Select word by word. + if (_TextSelectionGestureDetectorState._getEffectiveConsecutiveTapCount( + details.consecutiveTapCount, + ) == + 2) { + renderEditable.selectWordsInRange( + from: dragStartGlobalPosition - editableOffset - scrollableOffset, + to: details.globalPosition, + cause: SelectionChangedCause.drag, + ); + + switch (details.kind) { + case PointerDeviceKind.stylus: + case PointerDeviceKind.invertedStylus: + case PointerDeviceKind.touch: + case PointerDeviceKind.unknown: + return _showMagnifierIfSupportedByPlatform(details.globalPosition); + case PointerDeviceKind.mouse: + case PointerDeviceKind.trackpad: + case null: + return; + } + } + + // Select paragraph-by-paragraph. + if (_TextSelectionGestureDetectorState._getEffectiveConsecutiveTapCount( + details.consecutiveTapCount, + ) == + 3) { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.iOS: + switch (details.kind) { + case PointerDeviceKind.mouse: + case PointerDeviceKind.trackpad: + return _selectParagraphsInRange( + from: dragStartGlobalPosition - + editableOffset - + scrollableOffset, + to: details.globalPosition, + cause: SelectionChangedCause.drag, + ); + case PointerDeviceKind.stylus: + case PointerDeviceKind.invertedStylus: + case PointerDeviceKind.touch: + case PointerDeviceKind.unknown: + case null: + // Triple tap to drag is not present on these platforms when using + // non-precise pointer devices at the moment. + break; + } + return; + case TargetPlatform.linux: + return _selectLinesInRange( + from: dragStartGlobalPosition - editableOffset - scrollableOffset, + to: details.globalPosition, + cause: SelectionChangedCause.drag, + ); + case TargetPlatform.windows: + case TargetPlatform.macOS: + return _selectParagraphsInRange( + from: dragStartGlobalPosition - editableOffset - scrollableOffset, + to: details.globalPosition, + cause: SelectionChangedCause.drag, + ); + } + } + + switch (defaultTargetPlatform) { + case TargetPlatform.iOS: + // With a mouse device, a drag should select the range from the origin of the drag + // to the current position of the drag. + // + // With a touch device, nothing should happen. + switch (details.kind) { + case PointerDeviceKind.mouse: + case PointerDeviceKind.trackpad: + return renderEditable.selectPositionAt( + from: + dragStartGlobalPosition - editableOffset - scrollableOffset, + to: details.globalPosition, + cause: SelectionChangedCause.drag, + ); + case PointerDeviceKind.stylus: + case PointerDeviceKind.invertedStylus: + case PointerDeviceKind.touch: + case PointerDeviceKind.unknown: + case null: + break; + } + return; + case TargetPlatform.android: + case TargetPlatform.fuchsia: + // With a precise pointer device, such as a mouse, trackpad, or stylus, + // the drag will select the text spanning the origin of the drag to the end of the drag. + // With a touch device, the cursor should move with the drag. + switch (details.kind) { + case PointerDeviceKind.mouse: + case PointerDeviceKind.trackpad: + case PointerDeviceKind.stylus: + case PointerDeviceKind.invertedStylus: + return renderEditable.selectPositionAt( + from: + dragStartGlobalPosition - editableOffset - scrollableOffset, + to: details.globalPosition, + cause: SelectionChangedCause.drag, + ); + case PointerDeviceKind.touch: + case PointerDeviceKind.unknown: + if (renderEditable.hasFocus) { + renderEditable.selectPositionAt( + from: details.globalPosition, + cause: SelectionChangedCause.drag, + ); + return _showMagnifierIfSupportedByPlatform( + details.globalPosition); + } + case null: + break; + } + return; + case TargetPlatform.macOS: + case TargetPlatform.linux: + case TargetPlatform.windows: + return renderEditable.selectPositionAt( + from: dragStartGlobalPosition - editableOffset - scrollableOffset, + to: details.globalPosition, + cause: SelectionChangedCause.drag, + ); + } + } + + if (_dragStartSelection!.isCollapsed || + (defaultTargetPlatform != TargetPlatform.iOS && + defaultTargetPlatform != TargetPlatform.macOS)) { + return _extendSelection( + details.globalPosition, SelectionChangedCause.drag); + } + + // If the drag inverts the selection, Mac and iOS revert to the initial + // selection. + final TextSelection selection = editableText.textEditingValue.selection; + final TextPosition nextExtent = + renderEditable.getPositionForPoint(details.globalPosition); + final bool isShiftTapDragSelectionForward = + _dragStartSelection!.baseOffset < _dragStartSelection!.extentOffset; + final bool isInverted = isShiftTapDragSelectionForward + ? nextExtent.offset < _dragStartSelection!.baseOffset + : nextExtent.offset > _dragStartSelection!.baseOffset; + if (isInverted && selection.baseOffset == _dragStartSelection!.baseOffset) { + editableText.userUpdateTextEditingValue( + editableText.textEditingValue.copyWith( + selection: TextSelection( + baseOffset: _dragStartSelection!.extentOffset, + extentOffset: nextExtent.offset, + ), + ), + SelectionChangedCause.drag, + ); + } else if (!isInverted && + nextExtent.offset != _dragStartSelection!.baseOffset && + selection.baseOffset != _dragStartSelection!.baseOffset) { + editableText.userUpdateTextEditingValue( + editableText.textEditingValue.copyWith( + selection: TextSelection( + baseOffset: _dragStartSelection!.baseOffset, + extentOffset: nextExtent.offset, + ), + ), + SelectionChangedCause.drag, + ); + } else { + _extendSelection(details.globalPosition, SelectionChangedCause.drag); + } + } + + /// Handler for [TextSelectionGestureDetector.onDragSelectionEnd]. + /// + /// By default, it cleans up the state used for handling certain + /// built-in behaviors. + /// + /// See also: + /// + /// * [TextSelectionGestureDetector.onDragSelectionEnd], which triggers this + /// callback. + @protected + void onDragSelectionEnd(TapDragEndDetails details) { + if (_shouldShowSelectionToolbar && + _TextSelectionGestureDetectorState._getEffectiveConsecutiveTapCount( + details.consecutiveTapCount, + ) == + 2) { + editableText.showToolbar(); + } + + if (_isShiftPressed) { + _dragStartSelection = null; + } + + _hideMagnifierIfSupportedByPlatform(); + } + + /// Returns a [TextSelectionGestureDetector] configured with the handlers + /// provided by this builder. + /// + /// The [child] or its subtree should contain an [EditableText] whose key is + /// the [GlobalKey] provided by the [delegate]'s + /// [TextSelectionGestureDetectorBuilderDelegate.editableTextKey]. + Widget buildGestureDetector( + {Key? key, HitTestBehavior? behavior, required Widget child}) { + return TextSelectionGestureDetector( + key: key, + onTapTrackStart: onTapTrackStart, + onTapTrackReset: onTapTrackReset, + onTapDown: onTapDown, + onForcePressStart: delegate.forcePressEnabled ? onForcePressStart : null, + onForcePressEnd: delegate.forcePressEnabled ? onForcePressEnd : null, + onSecondaryTap: onSecondaryTap, + onSecondaryTapDown: onSecondaryTapDown, + onSingleTapUp: onSingleTapUp, + onSingleTapCancel: onSingleTapCancel, + onUserTap: onUserTap, + onSingleLongTapStart: onSingleLongTapStart, + onSingleLongTapMoveUpdate: onSingleLongTapMoveUpdate, + onSingleLongTapEnd: onSingleLongTapEnd, + onDoubleTapDown: onDoubleTapDown, + onTripleTapDown: onTripleTapDown, + onDragSelectionStart: onDragSelectionStart, + onDragSelectionUpdate: onDragSelectionUpdate, + onDragSelectionEnd: onDragSelectionEnd, + onUserTapAlwaysCalled: onUserTapAlwaysCalled, + behavior: behavior, + child: child, + ); + } +} + +class _TextSelectionGestureDetectorState + extends State { + // Converts the details.consecutiveTapCount from a TapAndDrag*Details object, + // which can grow to be infinitely large, to a value between 1 and 3. The value + // that the raw count is converted to is based on the default observed behavior + // on the native platforms. + // + // This method should be used in all instances when details.consecutiveTapCount + // would be used. + static int _getEffectiveConsecutiveTapCount(int rawCount) { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + // From observation, these platform's reset their tap count to 0 when + // the number of consecutive taps exceeds 3. For example on Debian Linux + // with GTK, when going past a triple click, on the fourth click the + // selection is moved to the precise click position, on the fifth click + // the word at the position is selected, and on the sixth click the + // paragraph at the position is selected. + return rawCount <= 3 + ? rawCount + : (rawCount % 3 == 0 ? 3 : rawCount % 3); + case TargetPlatform.iOS: + case TargetPlatform.macOS: + // From observation, these platform's either hold their tap count at 3. + // For example on macOS, when going past a triple click, the selection + // should be retained at the paragraph that was first selected on triple + // click. + return math.min(rawCount, 3); + case TargetPlatform.windows: + // From observation, this platform's consecutive tap actions alternate + // between double click and triple click actions. For example, after a + // triple click has selected a paragraph, on the next click the word at + // the clicked position will be selected, and on the next click the + // paragraph at the position is selected. + return rawCount < 2 ? rawCount : 2 + rawCount % 2; + } + } + + void _handleTapTrackStart() { + widget.onTapTrackStart?.call(); + } + + void _handleTapTrackReset() { + widget.onTapTrackReset?.call(); + } + + // The down handler is force-run on success of a single tap and optimistically + // run before a long press success. + void _handleTapDown(TapDragDownDetails details) { + widget.onTapDown?.call(details); + // This isn't detected as a double tap gesture in the gesture recognizer + // because it's 2 single taps, each of which may do different things depending + // on whether it's a single tap, the first tap of a double tap, the second + // tap held down, a clean double tap etc. + if (_getEffectiveConsecutiveTapCount(details.consecutiveTapCount) == 2) { + return widget.onDoubleTapDown?.call(details); + } + + if (_getEffectiveConsecutiveTapCount(details.consecutiveTapCount) == 3) { + return widget.onTripleTapDown?.call(details); + } + } + + void _handleTapUp(TapDragUpDetails details) { + if (_getEffectiveConsecutiveTapCount(details.consecutiveTapCount) == 1) { + widget.onSingleTapUp?.call(details); + widget.onUserTap?.call(); + } else if (widget.onUserTapAlwaysCalled) { + widget.onUserTap?.call(); + } + } + + void _handleTapCancel() { + widget.onSingleTapCancel?.call(); + } + + void _handleDragStart(TapDragStartDetails details) { + widget.onDragSelectionStart?.call(details); + } + + void _handleDragUpdate(TapDragUpdateDetails details) { + widget.onDragSelectionUpdate?.call(details); + } + + void _handleDragEnd(TapDragEndDetails details) { + widget.onDragSelectionEnd?.call(details); + } + + void _forcePressStarted(ForcePressDetails details) { + widget.onForcePressStart?.call(details); + } + + void _forcePressEnded(ForcePressDetails details) { + widget.onForcePressEnd?.call(details); + } + + void _handleLongPressStart(LongPressStartDetails details) { + if (widget.onSingleLongTapStart != null) { + widget.onSingleLongTapStart!(details); + } + } + + void _handleLongPressMoveUpdate(LongPressMoveUpdateDetails details) { + if (widget.onSingleLongTapMoveUpdate != null) { + widget.onSingleLongTapMoveUpdate!(details); + } + } + + void _handleLongPressEnd(LongPressEndDetails details) { + if (widget.onSingleLongTapEnd != null) { + widget.onSingleLongTapEnd!(details); + } + } + + @override + Widget build(BuildContext context) { + final Map gestures = + {}; + + gestures[TapGestureRecognizer] = + GestureRecognizerFactoryWithHandlers( + () => TapGestureRecognizer(debugOwner: this), + (TapGestureRecognizer instance) { + instance + ..onSecondaryTap = widget.onSecondaryTap + ..onSecondaryTapDown = widget.onSecondaryTapDown; + }, + ); + + if (widget.onSingleLongTapStart != null || + widget.onSingleLongTapMoveUpdate != null || + widget.onSingleLongTapEnd != null) { + gestures[LongPressGestureRecognizer] = + GestureRecognizerFactoryWithHandlers( + () => LongPressGestureRecognizer( + debugOwner: this, + supportedDevices: {PointerDeviceKind.touch}, + ), + (LongPressGestureRecognizer instance) { + instance + ..onLongPressStart = _handleLongPressStart + ..onLongPressMoveUpdate = _handleLongPressMoveUpdate + ..onLongPressEnd = _handleLongPressEnd; + }, + ); + } + + if (widget.onDragSelectionStart != null || + widget.onDragSelectionUpdate != null || + widget.onDragSelectionEnd != null) { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.iOS: + gestures[TapAndHorizontalDragGestureRecognizer] = + GestureRecognizerFactoryWithHandlers< + TapAndHorizontalDragGestureRecognizer>( + () => TapAndHorizontalDragGestureRecognizer(debugOwner: this), + (TapAndHorizontalDragGestureRecognizer instance) { + instance + // Text selection should start from the position of the first pointer + // down event. + ..dragStartBehavior = DragStartBehavior.down + ..eagerVictoryOnDrag = + defaultTargetPlatform != TargetPlatform.iOS + ..onTapTrackStart = _handleTapTrackStart + ..onTapTrackReset = _handleTapTrackReset + ..onTapDown = _handleTapDown + ..onDragStart = _handleDragStart + ..onDragUpdate = _handleDragUpdate + ..onDragEnd = _handleDragEnd + ..onTapUp = _handleTapUp + ..onCancel = _handleTapCancel; + }, + ); + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + gestures[TapAndPanGestureRecognizer] = + GestureRecognizerFactoryWithHandlers( + () => TapAndPanGestureRecognizer(debugOwner: this), + (TapAndPanGestureRecognizer instance) { + instance + // Text selection should start from the position of the first pointer + // down event. + ..dragStartBehavior = DragStartBehavior.down + ..onTapTrackStart = _handleTapTrackStart + ..onTapTrackReset = _handleTapTrackReset + ..onTapDown = _handleTapDown + ..onDragStart = _handleDragStart + ..onDragUpdate = _handleDragUpdate + ..onDragEnd = _handleDragEnd + ..onTapUp = _handleTapUp + ..onCancel = _handleTapCancel; + }, + ); + } + } + + if (widget.onForcePressStart != null || widget.onForcePressEnd != null) { + gestures[ForcePressGestureRecognizer] = + GestureRecognizerFactoryWithHandlers( + () => ForcePressGestureRecognizer(debugOwner: this), + (ForcePressGestureRecognizer instance) { + instance + ..onStart = + widget.onForcePressStart != null ? _forcePressStarted : null + ..onEnd = widget.onForcePressEnd != null ? _forcePressEnded : null; + }, + ); + } + + return RawGestureDetector( + gestures: gestures, + excludeFromSemantics: true, + behavior: widget.behavior, + child: widget.child, + ); + } +} diff --git a/lib/http/api.dart b/lib/http/api.dart index 16fb3f3d..2d9ecbfb 100644 --- a/lib/http/api.dart +++ b/lib/http/api.dart @@ -915,4 +915,6 @@ class Api { static const String spaceComic = '${HttpString.appBaseUrl}/x/v2/space/comic'; static const String spaceAudio = '/audio/music-service/web/song/upper'; + + static const String dynMention = '/x/polymer/web-dynamic/v1/mention/search'; } diff --git a/lib/http/dynamics.dart b/lib/http/dynamics.dart index ff699752..fb5f75c9 100644 --- a/lib/http/dynamics.dart +++ b/lib/http/dynamics.dart @@ -11,6 +11,8 @@ import 'package:PiliPlus/models/dynamics/vote_model.dart'; import 'package:PiliPlus/models_new/article/article_info/data.dart'; import 'package:PiliPlus/models_new/article/article_list/data.dart'; import 'package:PiliPlus/models_new/article/article_view/data.dart'; +import 'package:PiliPlus/models_new/dynamic/dyn_mention/data.dart'; +import 'package:PiliPlus/models_new/dynamic/dyn_mention/group.dart'; import 'package:PiliPlus/models_new/dynamic/dyn_reserve/data.dart'; import 'package:PiliPlus/models_new/dynamic/dyn_topic_feed/topic_card_list.dart'; import 'package:PiliPlus/models_new/dynamic/dyn_topic_top/top_details.dart'; @@ -141,11 +143,12 @@ class DynamicsHttp { "dyn_req": { "content": { "contents": [ - { - "raw_text": rawText, - "type": 1, - "biz_id": "", - }, + if (rawText != null) + { + "raw_text": rawText, + "type": 1, + "biz_id": "", + }, ...?extraContent, ], if (title?.isNotEmpty == true) 'title': title, @@ -488,4 +491,22 @@ class DynamicsHttp { return Error(res.data['message']); } } + + static Future?>> dynMention( + {String? keyword}) async { + final res = await Request().get( + Api.dynMention, + queryParameters: { + if (keyword?.isNotEmpty == true) 'keyword': keyword, + 'web_location': 333.1365, + }, + ); + if (res.data['code'] == 0) { + return Success( + DynMentionData.fromJson(res.data['data']).groups, + ); + } else { + return Error(res.data['message']); + } + } } diff --git a/lib/http/video.dart b/lib/http/video.dart index 0d76fa9e..b66f4c64 100644 --- a/lib/http/video.dart +++ b/lib/http/video.dart @@ -510,6 +510,7 @@ class VideoHttp { int? parent, List? pictures, bool? syncToDynamic, + Map? atNameToMid, }) async { if (message == '') { return {'status': false, 'msg': '请输入评论内容'}; @@ -520,13 +521,16 @@ class VideoHttp { if (root != null && root != 0) 'root': root, if (parent != null && parent != 0) 'parent': parent, 'message': message, + if (atNameToMid != null) + 'at_name_to_mid': jsonEncode(atNameToMid), // {"name":uid} if (pictures != null) 'pictures': jsonEncode(pictures), if (syncToDynamic == true) 'sync_to_dynamic': 1, 'csrf': Accounts.main.csrf, }; var res = await Request().post( Api.replyAdd, - data: FormData.fromMap(data), + data: data, + options: Options(contentType: Headers.formUrlEncodedContentType), ); log(res.toString()); if (res.data['code'] == 0) { diff --git a/lib/models_new/dynamic/dyn_mention/data.dart b/lib/models_new/dynamic/dyn_mention/data.dart new file mode 100644 index 00000000..28988517 --- /dev/null +++ b/lib/models_new/dynamic/dyn_mention/data.dart @@ -0,0 +1,13 @@ +import 'package:PiliPlus/models_new/dynamic/dyn_mention/group.dart'; + +class DynMentionData { + List? groups; + + DynMentionData({this.groups}); + + factory DynMentionData.fromJson(Map json) => DynMentionData( + groups: (json['groups'] as List?) + ?.map((e) => MentionGroup.fromJson(e as Map)) + .toList(), + ); +} diff --git a/lib/models_new/dynamic/dyn_mention/group.dart b/lib/models_new/dynamic/dyn_mention/group.dart new file mode 100644 index 00000000..e0f382a2 --- /dev/null +++ b/lib/models_new/dynamic/dyn_mention/group.dart @@ -0,0 +1,17 @@ +import 'package:PiliPlus/models_new/dynamic/dyn_mention/item.dart'; + +class MentionGroup { + String? groupName; + int? groupType; + List? items; + + MentionGroup({this.groupName, this.groupType, this.items}); + + factory MentionGroup.fromJson(Map json) => MentionGroup( + groupName: json['group_name'] as String?, + groupType: json['group_type'] as int?, + items: (json['items'] as List?) + ?.map((e) => MentionItem.fromJson(e as Map)) + .toList(), + ); +} diff --git a/lib/models_new/dynamic/dyn_mention/item.dart b/lib/models_new/dynamic/dyn_mention/item.dart new file mode 100644 index 00000000..ef909c6e --- /dev/null +++ b/lib/models_new/dynamic/dyn_mention/item.dart @@ -0,0 +1,37 @@ +class MentionItem { + String? face; + int? fans; + String? name; + int? officialVerifyType; + String? uid; + + MentionItem({ + this.face, + this.fans, + this.name, + this.officialVerifyType, + this.uid, + }); + + factory MentionItem.fromJson(Map json) => MentionItem( + face: json['face'] as String?, + fans: json['fans'] as int?, + name: json['name'] as String?, + officialVerifyType: json['official_verify_type'] as int?, + uid: json['uid'] as String?, + ); + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other is MentionItem) { + return uid == other.uid; + } + return false; + } + + @override + int get hashCode => uid.hashCode; +} diff --git a/lib/pages/article/view.dart b/lib/pages/article/view.dart index 6b78ebc1..d33a92a6 100644 --- a/lib/pages/article/view.dart +++ b/lib/pages/article/view.dart @@ -625,7 +625,7 @@ class _ArticlePageState extends State upMid: _articleCtr.upMid, callback: _getImageCallback, onCheckReply: (item) => - _articleCtr.onCheckReply(context, item, isManual: true), + _articleCtr.onCheckReply(item, isManual: true), onToggleTop: (item) => _articleCtr.onToggleTop( item, index, diff --git a/lib/pages/common/common_publish_page.dart b/lib/pages/common/common_publish_page.dart index 525bd959..49e27452 100644 --- a/lib/pages/common/common_publish_page.dart +++ b/lib/pages/common/common_publish_page.dart @@ -6,12 +6,15 @@ import 'package:PiliPlus/common/widgets/button/icon_button.dart'; import 'package:PiliPlus/http/msg.dart'; import 'package:PiliPlus/models/common/image_preview_type.dart'; import 'package:PiliPlus/models/common/publish_panel_type.dart'; +import 'package:PiliPlus/models_new/dynamic/dyn_mention/item.dart'; import 'package:PiliPlus/models_new/emote/emote.dart'; import 'package:PiliPlus/models_new/live/live_emote/emoticon.dart'; import 'package:PiliPlus/models_new/upload_bfs/data.dart'; +import 'package:PiliPlus/pages/dynamics_mention/view.dart'; import 'package:PiliPlus/utils/extension.dart'; import 'package:PiliPlus/utils/feed_back.dart'; import 'package:chat_bottom_container/chat_bottom_container.dart'; +import 'package:collection/collection.dart'; import 'package:dio/dio.dart'; import 'package:easy_debounce/easy_throttle.dart'; import 'package:flutter/material.dart'; @@ -24,14 +27,16 @@ abstract class CommonPublishPage extends StatefulWidget { const CommonPublishPage({ super.key, this.initialValue, + this.mentions, this.imageLengthLimit, this.onSave, this.autofocus = true, }); final String? initialValue; + final Set? mentions; final int? imageLengthLimit; - final ValueChanged? onSave; + final ValueChanged<({String text, Set? mentions})>? onSave; final bool autofocus; } @@ -49,11 +54,14 @@ abstract class CommonPublishPageState late final RxList pathList = [].obs; int get limit => widget.imageLengthLimit ?? 9; + Set? mentions; + @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); + mentions = widget.mentions; if (widget.initialValue?.trim().isNotEmpty == true) { enablePublish.value = true; } @@ -221,7 +229,7 @@ abstract class CommonPublishPageState offset: cursorPosition + emote.emoji!.length), ); } - widget.onSave?.call(editController.text); + widget.onSave?.call((text: editController.text, mentions: mentions)); } Widget? get customPanel => null; @@ -387,4 +395,73 @@ abstract class CommonPublishPageState } }); } + + List>? getRichContent() { + if (mentions.isNullOrEmpty) { + return null; + } + List> content = []; + void addPlainText(String text) { + content.add({ + "raw_text": text, + "type": 1, + "biz_id": "", + }); + } + + final pattern = + RegExp(mentions!.map((e) => RegExp.escape('@${e.name!}')).join('|')); + + editController.text.splitMapJoin( + pattern, + onMatch: (Match match) { + final name = match.group(0)!; + final item = + mentions!.firstWhereOrNull((e) => e.name == name.substring(1)); + if (item != null) { + content.add({ + "raw_text": name, + "type": 2, + "biz_id": item.uid, + }); + } else { + addPlainText(name); + } + return ''; + }, + onNonMatch: (String text) { + addPlainText(text); + return ''; + }, + ); + return content; + } + + double _mentionOffset = 0; + void onMention([bool fromClick = false]) { + controller.keepChatPanel(); + DynMentionPanel.onDynMention( + context, + offset: _mentionOffset, + callback: (offset) => _mentionOffset = offset, + ).then((MentionItem? res) { + if (res != null) { + enablePublish.value = true; + + (mentions ??= {}).add(res); + + String atName = '${fromClick ? '@' : ''}${res.name} '; + final int cursorPosition = editController.selection.baseOffset; + final String currentText = editController.text; + final String newText = + '${currentText.substring(0, cursorPosition)}$atName${currentText.substring(cursorPosition)}'; + editController.value = TextEditingValue( + text: newText, + selection: + TextSelection.collapsed(offset: cursorPosition + atName.length), + ); + widget.onSave?.call((text: editController.text, mentions: mentions)); + } + }); + } } diff --git a/lib/pages/common/reply_controller.dart b/lib/pages/common/reply_controller.dart index 1b6f7c4b..8ab251b9 100644 --- a/lib/pages/common/reply_controller.dart +++ b/lib/pages/common/reply_controller.dart @@ -4,6 +4,7 @@ import 'package:PiliPlus/grpc/bilibili/pagination.pb.dart'; import 'package:PiliPlus/http/loading_state.dart'; import 'package:PiliPlus/http/reply.dart'; import 'package:PiliPlus/models/common/reply/reply_sort_type.dart'; +import 'package:PiliPlus/models_new/dynamic/dyn_mention/item.dart'; import 'package:PiliPlus/pages/common/common_list_controller.dart'; import 'package:PiliPlus/pages/video/reply_new/view.dart'; import 'package:PiliPlus/services/account_service.dart'; @@ -24,7 +25,8 @@ abstract class ReplyController extends CommonListController { late Rx sortType; late Rx mode; - late final savedReplies = {}; + late final savedReplies = + ? mentions})?>{}; AccountService accountService = Get.find(); @@ -125,16 +127,16 @@ abstract class ReplyController extends CommonListController { .push( GetDialogRoute( pageBuilder: (buildContext, animation, secondaryAnimation) { + final saved = savedReplies[key]; return ReplyPage( oid: oid ?? replyItem!.oid.toInt(), root: oid != null ? 0 : replyItem!.id.toInt(), parent: oid != null ? 0 : replyItem!.id.toInt(), replyType: replyItem?.type.toInt() ?? replyType!, replyItem: replyItem, - initialValue: savedReplies[key], - onSave: (reply) { - savedReplies[key] = reply; - }, + initialValue: saved?.text, + mentions: saved?.mentions, + onSave: (reply) => savedReplies[key] = reply, hint: hint, ); }, @@ -157,7 +159,7 @@ abstract class ReplyController extends CommonListController { .then( (res) { if (res != null) { - savedReplies[key] = null; + savedReplies.remove(key); ReplyInfo replyInfo = RequestUtils.replyCast(res); if (loadingState.value.isSuccess) { List? list = loadingState.value.data; @@ -179,8 +181,8 @@ abstract class ReplyController extends CommonListController { count.value += 1; // check reply - if (enableCommAntifraud && context.mounted) { - onCheckReply(context, replyInfo, isManual: false); + if (enableCommAntifraud) { + onCheckReply(replyInfo, isManual: false); } } }, @@ -200,8 +202,7 @@ abstract class ReplyController extends CommonListController { loadingState.refresh(); } - void onCheckReply(BuildContext context, ReplyInfo replyInfo, - {required bool isManual}) { + void onCheckReply(ReplyInfo replyInfo, {required bool isManual}) { ReplyUtils.onCheckReply( replyInfo: replyInfo, biliSendCommAntifraud: _biliSendCommAntifraud, diff --git a/lib/pages/dynamics_create/view.dart b/lib/pages/dynamics_create/view.dart index 2979f6e7..d43e08f1 100644 --- a/lib/pages/dynamics_create/view.dart +++ b/lib/pages/dynamics_create/view.dart @@ -1,19 +1,18 @@ -import 'dart:math'; - import 'package:PiliPlus/common/constants.dart'; import 'package:PiliPlus/common/widgets/button/icon_button.dart'; import 'package:PiliPlus/common/widgets/button/toolbar_icon_button.dart'; import 'package:PiliPlus/common/widgets/custom_icon.dart'; import 'package:PiliPlus/common/widgets/draggable_sheet/draggable_scrollable_sheet_dyn.dart' as dyn_sheet; -import 'package:PiliPlus/common/widgets/draggable_sheet/draggable_scrollable_sheet_topic.dart' - as topic_sheet; import 'package:PiliPlus/common/widgets/pair.dart'; +import 'package:PiliPlus/common/widgets/text_field/text_field.dart' + as text_field; import 'package:PiliPlus/http/dynamics.dart'; import 'package:PiliPlus/models/common/publish_panel_type.dart'; import 'package:PiliPlus/models/common/reply/reply_option_type.dart'; import 'package:PiliPlus/models_new/dynamic/dyn_topic_top/topic_item.dart'; import 'package:PiliPlus/pages/common/common_publish_page.dart'; +import 'package:PiliPlus/pages/dynamics_mention/controller.dart'; import 'package:PiliPlus/pages/dynamics_select_topic/controller.dart'; import 'package:PiliPlus/pages/dynamics_select_topic/view.dart'; import 'package:PiliPlus/pages/emote/controller.dart'; @@ -77,11 +76,10 @@ class _CreateDynPanelState extends CommonPublishPageState { @override void dispose() { _titleEditCtr.dispose(); - try { - Get - ..delete() - ..delete(); - } catch (_) {} + Get + ..delete() + ..delete() + ..delete(); super.dispose(); } @@ -531,17 +529,28 @@ class _CreateDynPanelState extends CommonPublishPageState { Widget get _buildToolbar => Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Obx( - () => ToolbarIconButton( - onPressed: () => updatePanelType( - panelType.value == PanelType.emoji - ? PanelType.keyboard - : PanelType.emoji, + child: Row( + spacing: 16, + children: [ + Obx( + () => ToolbarIconButton( + onPressed: () => updatePanelType( + panelType.value == PanelType.emoji + ? PanelType.keyboard + : PanelType.emoji, + ), + icon: const Icon(Icons.emoji_emotions, size: 22), + tooltip: '表情', + selected: panelType.value == PanelType.emoji, + ), ), - icon: const Icon(Icons.emoji_emotions, size: 22), - tooltip: '表情', - selected: panelType.value == PanelType.emoji, - ), + ToolbarIconButton( + onPressed: () => onMention(true), + icon: const Icon(Icons.alternate_email, size: 22), + tooltip: '@', + selected: false, + ), + ], ), ); @@ -554,12 +563,14 @@ class _CreateDynPanelState extends CommonPublishPageState { } }, child: Obx( - () => TextField( + () => text_field.TextField( controller: editController, minLines: 4, maxLines: null, focusNode: focusNode, readOnly: readOnly.value, + onDelAtUser: (name) => + mentions?.removeWhere((e) => e.name == name), onChanged: (value) { bool isEmpty = value.trim().isEmpty && pathList.isEmpty; if (!isEmpty && !enablePublish.value) { @@ -578,6 +589,7 @@ class _CreateDynPanelState extends CommonPublishPageState { contentPadding: EdgeInsets.zero, ), inputFormatters: [LengthLimitingTextInputFormatter(1000)], + onMention: onMention, ), ), ), @@ -590,9 +602,11 @@ class _CreateDynPanelState extends CommonPublishPageState { Future onCustomPublish( {required String message, List? pictures}) async { SmartDialog.showLoading(msg: '正在发布'); + List>? extraContent = getRichContent(); + final hasMention = extraContent != null; var result = await DynamicsHttp.createDynamic( mid: Accounts.main.mid, - rawText: editController.text, + rawText: hasMention ? null : editController.text, pics: pictures, publishTime: _publishTime.value != null ? _publishTime.value!.millisecondsSinceEpoch ~/ 1000 @@ -601,6 +615,7 @@ class _CreateDynPanelState extends CommonPublishPageState { privatePub: _isPrivate.value ? 1 : null, title: _titleEditCtr.text, topic: topic.value, + extraContent: extraContent, ); SmartDialog.dismiss(); if (result['status']) { @@ -618,31 +633,16 @@ class _CreateDynPanelState extends CommonPublishPageState { } } - double _offset = 0; - Future _onSelectTopic() async { - TopicItem? res = await showModalBottomSheet( - context: context, - useSafeArea: true, - isScrollControlled: true, - constraints: BoxConstraints( - maxWidth: min(600, context.mediaQueryShortestSide), - ), - builder: (context) => topic_sheet.DraggableScrollableSheet( - expand: false, - snap: true, - minChildSize: 0, - maxChildSize: 1, - initialChildSize: _offset == 0 ? 0.65 : 1, - initialScrollOffset: _offset, - snapSizes: const [0.65], - builder: (context, scrollController) => SelectTopicPanel( - scrollController: scrollController, - callback: (offset) => _offset = offset, - ), - ), - ); - if (res != null) { - topic.value = Pair(first: res.id, second: res.name); - } + double _topicOffset = 0; + void _onSelectTopic() { + SelectTopicPanel.onSelectTopic( + context, + offset: _topicOffset, + callback: (offset) => _topicOffset = offset, + ).then((TopicItem? res) { + if (res != null) { + topic.value = Pair(first: res.id, second: res.name); + } + }); } } diff --git a/lib/pages/dynamics_detail/view.dart b/lib/pages/dynamics_detail/view.dart index 61740e50..76b916f7 100644 --- a/lib/pages/dynamics_detail/view.dart +++ b/lib/pages/dynamics_detail/view.dart @@ -736,7 +736,7 @@ class _DynamicDetailPageState extends State upMid: _controller.upMid, callback: _getImageCallback, onCheckReply: (item) => - _controller.onCheckReply(context, item, isManual: true), + _controller.onCheckReply(item, isManual: true), onToggleTop: (item) => _controller.onToggleTop( item, index, diff --git a/lib/pages/dynamics_mention/controller.dart b/lib/pages/dynamics_mention/controller.dart new file mode 100644 index 00000000..aaaffc1e --- /dev/null +++ b/lib/pages/dynamics_mention/controller.dart @@ -0,0 +1,31 @@ +import 'package:PiliPlus/http/dynamics.dart'; +import 'package:PiliPlus/http/loading_state.dart'; +import 'package:PiliPlus/models_new/dynamic/dyn_mention/group.dart'; +import 'package:PiliPlus/pages/common/common_list_controller.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +class DynMentionController + extends CommonListController?, MentionGroup> { + final focusNode = FocusNode(); + final controller = TextEditingController(); + + final RxBool enableClear = false.obs; + + @override + void onInit() { + super.onInit(); + queryData(); + } + + @override + Future?>> customGetData() => + DynamicsHttp.dynMention(keyword: controller.text); + + @override + void onClose() { + focusNode.dispose(); + controller.dispose(); + super.onClose(); + } +} diff --git a/lib/pages/dynamics_mention/view.dart b/lib/pages/dynamics_mention/view.dart new file mode 100644 index 00000000..e2283c90 --- /dev/null +++ b/lib/pages/dynamics_mention/view.dart @@ -0,0 +1,235 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:PiliPlus/common/widgets/draggable_sheet/draggable_scrollable_sheet_topic.dart' + as topic_sheet; +import 'package:PiliPlus/common/widgets/loading_widget/loading_widget.dart'; +import 'package:PiliPlus/http/loading_state.dart'; +import 'package:PiliPlus/models_new/dynamic/dyn_mention/group.dart'; +import 'package:PiliPlus/models_new/dynamic/dyn_mention/item.dart'; +import 'package:PiliPlus/pages/dynamics_mention/controller.dart'; +import 'package:PiliPlus/pages/dynamics_mention/widgets/item.dart'; +import 'package:PiliPlus/utils/extension.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:stream_transform/stream_transform.dart'; + +class DynMentionPanel extends StatefulWidget { + const DynMentionPanel({ + super.key, + this.scrollController, + this.callback, + }); + + final ScrollController? scrollController; + final ValueChanged? callback; + + static Future onDynMention( + BuildContext context, { + double offset = 0, + ValueChanged? callback, + }) { + return showModalBottomSheet( + context: Get.context!, + useSafeArea: true, + isScrollControlled: true, + constraints: BoxConstraints( + maxWidth: min(600, context.mediaQueryShortestSide), + ), + builder: (context) => topic_sheet.DraggableScrollableSheet( + expand: false, + snap: true, + minChildSize: 0, + maxChildSize: 1, + initialChildSize: offset == 0 ? 0.65 : 1, + initialScrollOffset: offset, + snapSizes: const [0.65], + builder: (context, scrollController) => DynMentionPanel( + scrollController: scrollController, + callback: callback, + ), + ), + ); + } + + @override + State createState() => _DynMentionPanelState(); +} + +class _DynMentionPanelState extends State { + final _controller = Get.put(DynMentionController()); + final StreamController _ctr = StreamController(); + late StreamSubscription _sub; + + @override + void initState() { + super.initState(); + if (_controller.loadingState.value is Error) { + _controller.onReload(); + } + _sub = _ctr.stream + .debounce(const Duration(milliseconds: 300), trailing: true) + .listen((value) { + _controller + ..enableClear.value = value.isNotEmpty + ..onRefresh().whenComplete(() => WidgetsBinding.instance + .addPostFrameCallback((_) => widget.scrollController?.jumpToTop())); + }); + } + + @override + void dispose() { + _sub.cancel(); + _ctr.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Column( + children: [ + SizedBox( + height: 35, + child: Center( + child: Container( + width: 32, + height: 3, + decoration: BoxDecoration( + color: theme.colorScheme.outline, + borderRadius: const BorderRadius.all(Radius.circular(3)), + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.only(left: 16, right: 16, bottom: 5), + child: TextField( + focusNode: _controller.focusNode, + controller: _controller.controller, + onChanged: _ctr.add, + decoration: InputDecoration( + border: const OutlineInputBorder( + gapPadding: 0, + borderSide: BorderSide.none, + borderRadius: BorderRadius.all(Radius.circular(25)), + ), + isDense: true, + filled: true, + fillColor: theme.colorScheme.onInverseSurface, + hintText: '输入你想@的人', + hintStyle: const TextStyle(fontSize: 14), + prefixIcon: const Padding( + padding: EdgeInsets.only(left: 12, right: 4), + child: Icon(Icons.search, size: 20), + ), + prefixIconConstraints: + const BoxConstraints(minHeight: 0, minWidth: 0), + contentPadding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + suffixIcon: Obx( + () => _controller.enableClear.value + ? Padding( + padding: const EdgeInsets.only(right: 12), + child: GestureDetector( + child: Container( + padding: const EdgeInsetsDirectional.all(2), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: theme.colorScheme.secondaryContainer, + ), + child: Icon( + Icons.clear, + size: 16, + color: theme.colorScheme.onSecondaryContainer, + ), + ), + onTap: () => _controller + ..enableClear.value = false + ..controller.clear() + ..onRefresh().whenComplete(() => WidgetsBinding + .instance + .addPostFrameCallback((_) => + widget.scrollController?.jumpToTop())), + ), + ) + : const SizedBox.shrink(), + ), + suffixIconConstraints: + const BoxConstraints(minHeight: 0, minWidth: 0), + ), + ), + ), + Expanded( + child: Obx(() => _buildBody(theme, _controller.loadingState.value)), + ), + ], + ); + } + + Widget _buildBody( + ThemeData theme, LoadingState?> loadingState) { + return switch (loadingState) { + Loading() => loadingWidget, + Success?>(:var response) => + response?.isNotEmpty == true + ? NotificationListener( + onNotification: (notification) { + if (notification is UserScrollNotification) { + if (_controller.focusNode.hasFocus) { + _controller.focusNode.unfocus(); + } + } else if (notification is ScrollEndNotification) { + widget.callback?.call(notification.metrics.pixels); + } + return false; + }, + child: CustomScrollView( + controller: widget.scrollController, + slivers: [ + ...response!.map((group) { + if (group.items!.isNullOrEmpty) { + return const SliverToBoxAdapter(); + } + return SliverMainAxisGroup( + slivers: [ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 6), + child: Text(group.groupName!), + ), + ), + SliverList.builder( + itemCount: group.items!.length, + itemBuilder: (context, index) { + return DynMentionItem( + item: group.items![index], + onTap: (e) => Get.back(result: e), + ); + }, + ), + ], + ); + }), + SliverToBoxAdapter( + child: SizedBox( + height: MediaQuery.paddingOf(context).bottom + + MediaQuery.viewInsetsOf(context).bottom + + 80, + ), + ), + ], + ), + ) + : _errWidget(), + Error(:var errMsg) => _errWidget(errMsg), + }; + } + + Widget _errWidget([String? errMsg]) => scrollErrorWidget( + errMsg: errMsg, + controller: widget.scrollController, + onReload: _controller.onReload, + ); +} diff --git a/lib/pages/dynamics_mention/widgets/item.dart b/lib/pages/dynamics_mention/widgets/item.dart new file mode 100644 index 00000000..42479838 --- /dev/null +++ b/lib/pages/dynamics_mention/widgets/item.dart @@ -0,0 +1,41 @@ +import 'package:PiliPlus/common/widgets/image/network_img_layer.dart'; +import 'package:PiliPlus/models/common/image_type.dart'; +import 'package:PiliPlus/models_new/dynamic/dyn_mention/item.dart'; +import 'package:PiliPlus/utils/num_util.dart'; +import 'package:flutter/material.dart'; + +class DynMentionItem extends StatelessWidget { + const DynMentionItem({ + super.key, + required this.item, + required this.onTap, + }); + + final MentionItem item; + final ValueChanged onTap; + + @override + Widget build(BuildContext context) { + return Material( + type: MaterialType.transparency, + child: ListTile( + dense: true, + onTap: () => onTap(item), + leading: NetworkImgLayer( + src: item.face, + width: 42, + height: 42, + type: ImageType.avatar, + ), + title: Text( + item.name!, + style: const TextStyle(fontSize: 14), + ), + subtitle: Text( + '${NumUtil.numFormat(item.fans)}粉丝', + style: TextStyle(color: Theme.of(context).colorScheme.outline), + ), + ), + ); + } +} diff --git a/lib/pages/dynamics_repost/view.dart b/lib/pages/dynamics_repost/view.dart index 91ff06c1..f127fa3d 100644 --- a/lib/pages/dynamics_repost/view.dart +++ b/lib/pages/dynamics_repost/view.dart @@ -2,15 +2,17 @@ import 'package:PiliPlus/common/widgets/button/toolbar_icon_button.dart'; import 'package:PiliPlus/common/widgets/draggable_sheet/draggable_scrollable_sheet_dyn.dart' show DraggableScrollableSheet; import 'package:PiliPlus/common/widgets/image/network_img_layer.dart'; +import 'package:PiliPlus/common/widgets/text_field/text_field.dart'; import 'package:PiliPlus/http/dynamics.dart'; import 'package:PiliPlus/models/common/publish_panel_type.dart'; import 'package:PiliPlus/models/dynamics/result.dart'; import 'package:PiliPlus/pages/common/common_publish_page.dart'; +import 'package:PiliPlus/pages/dynamics_mention/controller.dart'; import 'package:PiliPlus/pages/emote/controller.dart'; import 'package:PiliPlus/pages/emote/view.dart'; import 'package:PiliPlus/utils/accounts.dart'; import 'package:PiliPlus/utils/request_utils.dart'; -import 'package:flutter/material.dart' hide DraggableScrollableSheet; +import 'package:flutter/material.dart' hide DraggableScrollableSheet, TextField; import 'package:flutter/services.dart' show LengthLimitingTextInputFormatter; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/get.dart'; @@ -68,9 +70,9 @@ class _RepostPanelState extends CommonPublishPageState { @override void dispose() { - try { - Get.delete(); - } catch (_) {} + Get + ..delete() + ..delete(); super.dispose(); } @@ -239,6 +241,7 @@ class _RepostPanelState extends CommonPublishPageState { contentPadding: const EdgeInsets.symmetric(vertical: 10), ), inputFormatters: [LengthLimitingTextInputFormatter(1000)], + onMention: onMention, ), ), ), @@ -323,19 +326,30 @@ class _RepostPanelState extends CommonPublishPageState { Widget get _buildToolbar => Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Obx( - () => ToolbarIconButton( - onPressed: () { - updatePanelType( - panelType.value == PanelType.emoji - ? PanelType.keyboard - : PanelType.emoji, - ); - }, - icon: const Icon(Icons.emoji_emotions, size: 22), - tooltip: '表情', - selected: panelType.value == PanelType.emoji, - ), + child: Row( + spacing: 16, + children: [ + Obx( + () => ToolbarIconButton( + onPressed: () { + updatePanelType( + panelType.value == PanelType.emoji + ? PanelType.keyboard + : PanelType.emoji, + ); + }, + icon: const Icon(Icons.emoji_emotions, size: 22), + tooltip: '表情', + selected: panelType.value == PanelType.emoji, + ), + ), + ToolbarIconButton( + onPressed: () => onMention(true), + icon: const Icon(Icons.alternate_email, size: 22), + tooltip: '@', + selected: false, + ), + ], ), ); @@ -403,15 +417,23 @@ class _RepostPanelState extends CommonPublishPageState { @override Future onCustomPublish( {required String message, List? pictures}) async { + SmartDialog.showLoading(); + List>? content = getRichContent(); + final hasMention = content != null; + List>? repostContent = + widget.item?.orig != null ? extraContent(widget.item!) : null; + if (hasMention && repostContent != null) { + content.addAll(repostContent); + } var result = await DynamicsHttp.createDynamic( mid: Accounts.main.mid, dynIdStr: widget.item?.idStr ?? widget.dynIdStr, rid: widget.rid, dynType: widget.dynType, - rawText: editController.text, - extraContent: - widget.item?.orig != null ? extraContent(widget.item!) : null, + rawText: hasMention ? null : editController.text, + extraContent: content ?? repostContent, ); + SmartDialog.dismiss(); if (result['status']) { Get.back(); SmartDialog.showToast('转发成功'); diff --git a/lib/pages/dynamics_select_topic/view.dart b/lib/pages/dynamics_select_topic/view.dart index bfac0348..e99a4fe5 100644 --- a/lib/pages/dynamics_select_topic/view.dart +++ b/lib/pages/dynamics_select_topic/view.dart @@ -1,5 +1,8 @@ import 'dart:async'; +import 'dart:math'; +import 'package:PiliPlus/common/widgets/draggable_sheet/draggable_scrollable_sheet_topic.dart' + as topic_sheet; import 'package:PiliPlus/common/widgets/loading_widget/loading_widget.dart'; import 'package:PiliPlus/http/loading_state.dart'; import 'package:PiliPlus/models_new/dynamic/dyn_topic_top/topic_item.dart'; @@ -20,6 +23,34 @@ class SelectTopicPanel extends StatefulWidget { final ScrollController? scrollController; final ValueChanged? callback; + static Future onSelectTopic( + BuildContext context, { + double offset = 0, + ValueChanged? callback, + }) { + return showModalBottomSheet( + context: Get.context!, + useSafeArea: true, + isScrollControlled: true, + constraints: BoxConstraints( + maxWidth: min(600, context.mediaQueryShortestSide), + ), + builder: (context) => topic_sheet.DraggableScrollableSheet( + expand: false, + snap: true, + minChildSize: 0, + maxChildSize: 1, + initialChildSize: offset == 0 ? 0.65 : 1, + initialScrollOffset: offset, + snapSizes: const [0.65], + builder: (context, scrollController) => SelectTopicPanel( + scrollController: scrollController, + callback: callback, + ), + ), + ); + } + @override State createState() => _SelectTopicPanelState(); } diff --git a/lib/pages/live_room/view.dart b/lib/pages/live_room/view.dart index 217057eb..9279e77f 100644 --- a/lib/pages/live_room/view.dart +++ b/lib/pages/live_room/view.dart @@ -607,7 +607,7 @@ class _LiveRoomPageState extends State fromEmote: fromEmote, liveRoomController: _liveRoomController, initialValue: _liveRoomController.savedDanmaku, - onSave: (msg) => _liveRoomController.savedDanmaku = msg, + onSave: (msg) => _liveRoomController.savedDanmaku = msg.text, ); }, transitionDuration: const Duration(milliseconds: 500), diff --git a/lib/pages/match_info/view.dart b/lib/pages/match_info/view.dart index 14336af5..e4adc4d8 100644 --- a/lib/pages/match_info/view.dart +++ b/lib/pages/match_info/view.dart @@ -217,8 +217,8 @@ class _MatchInfoPageState extends State { onDelete: (item, subIndex) => _controller.onRemove(index, item, subIndex), upMid: _controller.upMid, - onCheckReply: (item) => _controller - .onCheckReply(context, item, isManual: true), + onCheckReply: (item) => + _controller.onCheckReply(item, isManual: true), onToggleTop: (item) => _controller.onToggleTop( item, index, diff --git a/lib/pages/video/controller.dart b/lib/pages/video/controller.dart index 4ff52614..ef02b4d7 100644 --- a/lib/pages/video/controller.dart +++ b/lib/pages/video/controller.dart @@ -945,7 +945,7 @@ class VideoDetailController extends GetxController bvid: bvid, progress: plPlayerController.position.value.inMilliseconds, initialValue: savedDanmaku, - onSave: (danmaku) => savedDanmaku = danmaku, + onSave: (danmaku) => savedDanmaku = danmaku.text, callback: (danmakuModel) { savedDanmaku = null; plPlayerController.danmakuController?.addDanmaku(danmakuModel); diff --git a/lib/pages/video/reply/view.dart b/lib/pages/video/reply/view.dart index 82467755..de988ddb 100644 --- a/lib/pages/video/reply/view.dart +++ b/lib/pages/video/reply/view.dart @@ -229,7 +229,7 @@ class _VideoReplyPanelState extends State onDismissed: widget.onDismissed, callback: widget.callback, onCheckReply: (item) => _videoReplyController - .onCheckReply(context, item, isManual: true), + .onCheckReply(item, isManual: true), onToggleTop: (item) => _videoReplyController.onToggleTop( item, index, diff --git a/lib/pages/video/reply_new/view.dart b/lib/pages/video/reply_new/view.dart index 96e57be8..893a2839 100644 --- a/lib/pages/video/reply_new/view.dart +++ b/lib/pages/video/reply_new/view.dart @@ -1,15 +1,17 @@ import 'dart:async'; import 'package:PiliPlus/common/widgets/button/toolbar_icon_button.dart'; +import 'package:PiliPlus/common/widgets/text_field/text_field.dart'; import 'package:PiliPlus/grpc/bilibili/main/community/reply/v1.pb.dart' show ReplyInfo; import 'package:PiliPlus/http/video.dart'; import 'package:PiliPlus/main.dart'; import 'package:PiliPlus/models/common/publish_panel_type.dart'; import 'package:PiliPlus/pages/common/common_publish_page.dart'; +import 'package:PiliPlus/pages/dynamics_mention/controller.dart'; import 'package:PiliPlus/pages/emote/view.dart'; import 'package:PiliPlus/utils/storage_pref.dart'; -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart' hide TextField; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/get.dart'; @@ -24,6 +26,7 @@ class ReplyPage extends CommonPublishPage { const ReplyPage({ super.key, super.initialValue, + super.mentions, super.imageLengthLimit, super.onSave, required this.oid, @@ -66,6 +69,12 @@ class _ReplyPageState extends CommonPublishPageState { ), ); + @override + void dispose() { + Get.delete(); + super.dispose(); + } + @override void didChangeDependencies() { super.didChangeDependencies(); @@ -141,7 +150,7 @@ class _ReplyPageState extends CommonPublishPageState { } else if (isEmpty && enablePublish.value) { enablePublish.value = false; } - widget.onSave?.call(value); + widget.onSave?.call((text: value, mentions: mentions)); }, focusNode: focusNode, decoration: InputDecoration( @@ -150,6 +159,7 @@ class _ReplyPageState extends CommonPublishPageState { hintStyle: const TextStyle(fontSize: 14), ), style: themeData.textTheme.bodyLarge, + onMention: onMention, ), ), ), @@ -163,7 +173,6 @@ class _ReplyPageState extends CommonPublishPageState { height: 52, padding: const EdgeInsets.only(left: 12, right: 12), child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Obx( () => ToolbarIconButton( @@ -177,7 +186,7 @@ class _ReplyPageState extends CommonPublishPageState { selected: panelType.value == PanelType.keyboard, ), ), - const SizedBox(width: 10), + const SizedBox(width: 8), Obx( () => ToolbarIconButton( tooltip: '表情', @@ -191,7 +200,7 @@ class _ReplyPageState extends CommonPublishPageState { ), ), if (widget.root == 0) ...[ - const SizedBox(width: 10), + const SizedBox(width: 8), ToolbarIconButton( tooltip: '图片', selected: false, @@ -199,32 +208,56 @@ class _ReplyPageState extends CommonPublishPageState { onPressed: onPickImage, ), ], - const Spacer(), - Obx( - () => TextButton.icon( - style: TextButton.styleFrom( - padding: - const EdgeInsets.symmetric(horizontal: 15, vertical: 13), - visualDensity: VisualDensity.compact, - foregroundColor: _syncToDynamic.value - ? themeData.colorScheme.secondary - : themeData.colorScheme.outline, + const SizedBox(width: 8), + ToolbarIconButton( + onPressed: () => onMention(true), + icon: const Icon(Icons.alternate_email, size: 22), + tooltip: '@', + selected: false, + ), + Expanded( + child: Center( + child: Obx( + () => TextButton( + style: TextButton.styleFrom( + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + padding: const EdgeInsets.all(13), + visualDensity: VisualDensity.compact, + foregroundColor: _syncToDynamic.value + ? themeData.colorScheme.secondary + : themeData.colorScheme.outline, + ), + onPressed: () => + _syncToDynamic.value = !_syncToDynamic.value, + child: Row( + spacing: 4, + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _syncToDynamic.value + ? Icons.check_box + : Icons.check_box_outline_blank, + size: 22, + ), + const Flexible( + child: Text( + '转到动态', + maxLines: 1, + style: TextStyle(height: 1), + strutStyle: StrutStyle(leading: 0, height: 1), + ), + ), + ], + ), + ), ), - onPressed: () => _syncToDynamic.value = !_syncToDynamic.value, - icon: Icon( - _syncToDynamic.value - ? Icons.check_box - : Icons.check_box_outline_blank, - size: 22, - ), - label: const Text('转发至动态'), ), ), - const Spacer(), Obx( () => FilledButton.tonal( onPressed: enablePublish.value ? onPublish : null, style: FilledButton.styleFrom( + tapTargetSize: MaterialTapTargetSize.shrinkWrap, padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), visualDensity: VisualDensity.compact, @@ -249,6 +282,9 @@ class _ReplyPageState extends CommonPublishPageState { message: widget.replyItem != null && widget.replyItem!.root != 0 ? ' 回复 @${widget.replyItem!.member.name} : $message' : message, + atNameToMid: mentions?.isNotEmpty == true + ? {for (var e in mentions!) e.name: e.uid} + : null, pictures: pictures, syncToDynamic: _syncToDynamic.value, ); diff --git a/lib/pages/video/reply_reply/controller.dart b/lib/pages/video/reply_reply/controller.dart index e2d3c8f1..ad63310e 100644 --- a/lib/pages/video/reply_reply/controller.dart +++ b/lib/pages/video/reply_reply/controller.dart @@ -3,10 +3,13 @@ import 'package:PiliPlus/grpc/bilibili/main/community/reply/v1.pb.dart' import 'package:PiliPlus/grpc/reply.dart'; import 'package:PiliPlus/http/loading_state.dart'; import 'package:PiliPlus/pages/common/reply_controller.dart'; +import 'package:PiliPlus/pages/video/reply_new/view.dart'; import 'package:PiliPlus/utils/id_utils.dart'; +import 'package:PiliPlus/utils/request_utils.dart'; import 'package:PiliPlus/utils/storage_pref.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:get/get_navigation/src/dialog/dialog_route.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; class VideoReplyReplyController extends ReplyController @@ -129,6 +132,67 @@ class VideoReplyReplyController extends ReplyController onReload(); } + @override + void onReply( + BuildContext context, { + int? oid, + ReplyInfo? replyItem, + int? replyType, + int? index, + }) { + assert(replyItem != null && index != null); + final oid = replyItem!.oid.toInt(); + final root = replyItem.id.toInt(); + final key = oid + root; + + Navigator.of(context) + .push( + GetDialogRoute( + pageBuilder: (buildContext, animation, secondaryAnimation) { + final saved = savedReplies[key]; + return ReplyPage( + oid: oid, + root: root, + parent: root, + replyType: this.replyType, + replyItem: replyItem, + initialValue: saved?.text, + mentions: saved?.mentions, + onSave: (reply) => savedReplies[key] = reply, + ); + }, + transitionDuration: const Duration(milliseconds: 500), + transitionBuilder: (context, animation, secondaryAnimation, child) { + const begin = Offset(0.0, 1.0); + const end = Offset.zero; + const curve = Curves.linear; + + var tween = + Tween(begin: begin, end: end).chain(CurveTween(curve: curve)); + + return SlideTransition( + position: animation.drive(tween), + child: child, + ); + }, + ), + ) + .then((res) { + if (res != null) { + savedReplies.remove(key); + ReplyInfo replyInfo = RequestUtils.replyCast(res); + + count.value += 1; + loadingState + ..value.dataOrNull?.insert(index! + 1, replyInfo) + ..refresh(); + if (enableCommAntifraud) { + onCheckReply(replyInfo, isManual: false); + } + } + }); + } + @override void onClose() { controller?.dispose(); diff --git a/lib/pages/video/reply_reply/view.dart b/lib/pages/video/reply_reply/view.dart index 1a8aa072..9db2725c 100644 --- a/lib/pages/video/reply_reply/view.dart +++ b/lib/pages/video/reply_reply/view.dart @@ -6,15 +6,12 @@ import 'package:PiliPlus/grpc/bilibili/main/community/reply/v1.pb.dart' import 'package:PiliPlus/http/loading_state.dart'; import 'package:PiliPlus/pages/common/common_slide_page.dart'; import 'package:PiliPlus/pages/video/reply/widgets/reply_item_grpc.dart'; -import 'package:PiliPlus/pages/video/reply_new/view.dart'; import 'package:PiliPlus/pages/video/reply_reply/controller.dart'; import 'package:PiliPlus/utils/num_util.dart'; import 'package:PiliPlus/utils/page_utils.dart'; -import 'package:PiliPlus/utils/request_utils.dart'; import 'package:PiliPlus/utils/utils.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import 'package:get/get_navigation/src/dialog/dialog_route.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; class VideoReplyReplyPanel extends CommonSlidePage { @@ -51,27 +48,25 @@ class VideoReplyReplyPanel extends CommonSlidePage { class _VideoReplyReplyPanelState extends CommonSlidePageState { - late VideoReplyReplyController _videoReplyReplyController; - late final _savedReplies = {}; + late VideoReplyReplyController _controller; late final itemPositionsListener = ItemPositionsListener.create(); late final _key = GlobalKey(); late final _listKey = GlobalKey(); late final _tag = Utils.makeHeroTag('${widget.rpid}${widget.dialog}${widget.isDialogue}'); - ReplyInfo? get firstFloor => - widget.firstFloor ?? _videoReplyReplyController.firstFloor; + ReplyInfo? get firstFloor => widget.firstFloor ?? _controller.firstFloor; bool get _horizontalPreview => context.orientation == Orientation.landscape && - _videoReplyReplyController.horizontalPreview; + _controller.horizontalPreview; Animation? colorAnimation; @override void initState() { super.initState(); - _videoReplyReplyController = Get.put( + _controller = Get.put( VideoReplyReplyController( hasRoot: widget.firstFloor != null, id: widget.id, @@ -159,7 +154,7 @@ class _VideoReplyReplyPanelState Widget buildList(ThemeData theme) { return ClipRect( child: refreshIndicator( - onRefresh: _videoReplyReplyController.onRefresh, + onRefresh: _controller.onRefresh, child: Obx( () => Stack( clipBehavior: Clip.none, @@ -167,27 +162,27 @@ class _VideoReplyReplyPanelState ScrollablePositionedList.builder( key: _listKey, itemPositionsListener: itemPositionsListener, - itemCount: - _itemCount(_videoReplyReplyController.loadingState.value), - itemScrollController: _videoReplyReplyController.itemScrollCtr, + itemCount: _itemCount(_controller.loadingState.value), + itemScrollController: _controller.itemScrollCtr, physics: const AlwaysScrollableScrollPhysics(), itemBuilder: (context, index) { if (widget.isDialogue) { - return _buildBody(theme, - _videoReplyReplyController.loadingState.value, index); + return _buildBody( + theme, _controller.loadingState.value, index); } else if (firstFloor != null) { if (index == 0) { return ReplyItemGrpc( replyItem: firstFloor!, replyLevel: 2, needDivider: false, - onReply: (replyItem) => _onReply(replyItem, -1), - upMid: _videoReplyReplyController.upMid, + onReply: (replyItem) => _controller.onReply(context, + replyItem: replyItem, index: -1), + upMid: _controller.upMid, onViewImage: widget.onViewImage, onDismissed: widget.onDismissed, callback: _getImageCallback, - onCheckReply: (item) => _videoReplyReplyController - .onCheckReply(context, item, isManual: true), + onCheckReply: (item) => + _controller.onCheckReply(item, isManual: true), ); } else if (index == 1) { return Divider( @@ -200,7 +195,7 @@ class _VideoReplyReplyPanelState } else { return _buildBody( theme, - _videoReplyReplyController.loadingState.value, + _controller.loadingState.value, index - 3, ); } @@ -210,7 +205,7 @@ class _VideoReplyReplyPanelState } else { return _buildBody( theme, - _videoReplyReplyController.loadingState.value, + _controller.loadingState.value, index - 1, ); } @@ -218,7 +213,7 @@ class _VideoReplyReplyPanelState }, ), if (!widget.isDialogue && - _videoReplyReplyController.loadingState.value.isSuccess) + _controller.loadingState.value.isSuccess) _header(theme), ], ), @@ -243,9 +238,9 @@ class _VideoReplyReplyPanelState mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Obx( - () => _videoReplyReplyController.count.value != -1 + () => _controller.count.value != -1 ? Text( - '相关回复共${NumUtil.numFormat(_videoReplyReplyController.count.value)}条', + '相关回复共${NumUtil.numFormat(_controller.count.value)}条', style: const TextStyle(fontSize: 13), ) : const SizedBox.shrink(), @@ -253,7 +248,7 @@ class _VideoReplyReplyPanelState SizedBox( height: 35, child: TextButton.icon( - onPressed: () => _videoReplyReplyController.queryBySort(), + onPressed: () => _controller.queryBySort(), icon: Icon( Icons.sort, size: 16, @@ -261,7 +256,7 @@ class _VideoReplyReplyPanelState ), label: Obx( () => Text( - _videoReplyReplyController.mode.value == Mode.MAIN_LIST_HOT + _controller.mode.value == Mode.MAIN_LIST_HOT ? '按热度' : '按时间', style: TextStyle( @@ -306,59 +301,6 @@ class _VideoReplyReplyPanelState } : null; - void _onReply(ReplyInfo item, int index) { - final oid = item.oid.toInt(); - final root = item.id.toInt(); - final key = oid + root; - - Navigator.of(context) - .push( - GetDialogRoute( - pageBuilder: (buildContext, animation, secondaryAnimation) { - return ReplyPage( - oid: oid, - root: root, - parent: root, - replyType: widget.replyType, - replyItem: item, - initialValue: _savedReplies[key], - onSave: (reply) { - _savedReplies[key] = reply; - }, - ); - }, - transitionDuration: const Duration(milliseconds: 500), - transitionBuilder: (context, animation, secondaryAnimation, child) { - const begin = Offset(0.0, 1.0); - const end = Offset.zero; - const curve = Curves.linear; - - var tween = - Tween(begin: begin, end: end).chain(CurveTween(curve: curve)); - - return SlideTransition( - position: animation.drive(tween), - child: child, - ); - }, - ), - ) - .then((res) { - if (res != null) { - _savedReplies.remove(key); - ReplyInfo replyInfo = RequestUtils.replyCast(res); - _videoReplyReplyController - ..count.value += 1 - ..loadingState.value.dataOrNull?.insert(index + 1, replyInfo) - ..loadingState.refresh(); - if (_videoReplyReplyController.enableCommAntifraud && mounted) { - _videoReplyReplyController.onCheckReply(context, replyInfo, - isManual: false); - } - } - }); - } - Widget _buildBody( ThemeData theme, LoadingState?> loadingState, int index) { return switch (loadingState) { @@ -375,14 +317,14 @@ class _VideoReplyReplyPanelState Success(:var response) => Builder( builder: (context) { if (index == response!.length) { - _videoReplyReplyController.onLoadMore(); + _controller.onLoadMore(); return Container( alignment: Alignment.center, margin: EdgeInsets.only( bottom: MediaQuery.paddingOf(context).bottom), height: 125, child: Text( - _videoReplyReplyController.isEnd ? '没有更多了' : '加载中...', + _controller.isEnd ? '没有更多了' : '加载中...', style: TextStyle( fontSize: 12, color: theme.colorScheme.outline, @@ -390,12 +332,11 @@ class _VideoReplyReplyPanelState ), ); } else { - if (_videoReplyReplyController.index != null && - _videoReplyReplyController.index == index) { + if (_controller.index != null && _controller.index == index) { colorAnimation ??= ColorTween( begin: theme.colorScheme.onInverseSurface, end: theme.colorScheme.surface, - ).animate(_videoReplyReplyController.controller!); + ).animate(_controller.controller!); return AnimatedBuilder( animation: colorAnimation!, builder: (context, child) { @@ -413,7 +354,7 @@ class _VideoReplyReplyPanelState ), Error(:var errMsg) => errorWidget( errMsg: errMsg, - onReload: _videoReplyReplyController.onReload, + onReload: _controller.onReload, ), }; } @@ -422,11 +363,12 @@ class _VideoReplyReplyPanelState return ReplyItemGrpc( replyItem: replyItem, replyLevel: widget.isDialogue ? 3 : 2, - onReply: (replyItem) => _onReply(replyItem, index), + onReply: (replyItem) => + _controller.onReply(context, replyItem: replyItem, index: index), onDelete: (item, subIndex) { - _videoReplyReplyController.onRemove(index, item, null); + _controller.onRemove(index, item, null); }, - upMid: _videoReplyReplyController.upMid, + upMid: _controller.upMid, showDialogue: () => _key.currentState?.showBottomSheet( backgroundColor: Colors.transparent, (context) => VideoReplyReplyPanel( @@ -441,8 +383,7 @@ class _VideoReplyReplyPanelState onViewImage: widget.onViewImage, onDismissed: widget.onDismissed, callback: _getImageCallback, - onCheckReply: (item) => _videoReplyReplyController - .onCheckReply(context, item, isManual: true), + onCheckReply: (item) => _controller.onCheckReply(item, isManual: true), ); } diff --git a/lib/pages/video/send_danmaku/view.dart b/lib/pages/video/send_danmaku/view.dart index d63ac68f..6f885b9e 100644 --- a/lib/pages/video/send_danmaku/view.dart +++ b/lib/pages/video/send_danmaku/view.dart @@ -377,7 +377,7 @@ class _SendDanmakuPanelState extends CommonPublishPageState { } else if (isEmpty && enablePublish.value) { enablePublish.value = false; } - widget.onSave?.call(value); + widget.onSave?.call((text: value, mentions: null)); }, textInputAction: TextInputAction.send, onSubmitted: (value) {