diff --git a/lib/common/widgets/flutter/text_intro/selectable_text.dart b/lib/common/widgets/flutter/text_intro/selectable_text.dart new file mode 100644 index 000000000..f075c86e7 --- /dev/null +++ b/lib/common/widgets/flutter/text_intro/selectable_text.dart @@ -0,0 +1,901 @@ +// 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 'dart:ui' as ui show BoxHeightStyle, BoxWidthStyle; + +import 'package:PiliPlus/common/widgets/flutter/text_intro/text_selection.dart'; +import 'package:flutter/cupertino.dart' + hide TextSelectionGestureDetectorBuilder; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart' hide TextSelectionGestureDetectorBuilder; +import 'package:flutter/rendering.dart'; +import 'package:flutter/scheduler.dart'; + +class _TextSpanEditingController extends TextEditingController { + _TextSpanEditingController({required TextSpan textSpan}) + : _textSpan = textSpan, + super(text: textSpan.toPlainText(includeSemanticsLabels: false)); + + final TextSpan _textSpan; + + @override + TextSpan buildTextSpan({ + required BuildContext context, + TextStyle? style, + required bool withComposing, + }) { + // This does not care about composing. + return TextSpan(style: style, children: [_textSpan]); + } + + @override + set text(String? newText) { + // This should never be reached. + throw UnimplementedError(); + } +} + +class _SelectableTextSelectionGestureDetectorBuilder + extends CustomTextSelectionGestureDetectorBuilder { + _SelectableTextSelectionGestureDetectorBuilder({ + required _SelectableTextState state, + }) : _state = state, + super(delegate: state); + + final _SelectableTextState _state; + + @override + void onSingleTapUp(TapDragUpDetails details) { + if (!delegate.selectionEnabled) { + return; + } + super.onSingleTapUp(details); + _state.widget.onTap?.call(); + } +} + +/// A run of selectable text with a single style. +/// +/// Consider using [SelectionArea] or [SelectableRegion] instead, which enable +/// selection on a widget subtree, including but not limited to [Text] widgets. +/// +/// The [SelectableText] widget displays a string of text with a single style. +/// The string might break across multiple lines or might all be displayed on +/// the same line depending on the layout constraints. +/// +/// {@youtube 560 315 https://www.youtube.com/watch?v=ZSU3ZXOs6hc} +/// +/// The [style] argument is optional. When omitted, the text will use the style +/// from the closest enclosing [DefaultTextStyle]. If the given style's +/// [TextStyle.inherit] property is true (the default), the given style will +/// be merged with the closest enclosing [DefaultTextStyle]. This merging +/// behavior is useful, for example, to make the text bold while using the +/// default font family and size. +/// +/// {@macro flutter.material.textfield.wantKeepAlive} +/// +/// {@tool snippet} +/// +/// ```dart +/// const SelectableText( +/// 'Hello! How are you?', +/// textAlign: TextAlign.center, +/// style: TextStyle(fontWeight: FontWeight.bold), +/// ) +/// ``` +/// {@end-tool} +/// +/// Using the [SelectableText.rich] constructor, the [SelectableText] widget can +/// display a paragraph with differently styled [TextSpan]s. The sample +/// that follows displays "Hello beautiful world" with different styles +/// for each word. +/// +/// {@tool snippet} +/// +/// ```dart +/// const SelectableText.rich( +/// TextSpan( +/// text: 'Hello', // default text style +/// children: [ +/// TextSpan(text: ' beautiful ', style: TextStyle(fontStyle: FontStyle.italic)), +/// TextSpan(text: 'world', style: TextStyle(fontWeight: FontWeight.bold)), +/// ], +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// ## Interactivity +/// +/// To make [SelectableText] react to touch events, use callback [onTap] to achieve +/// the desired behavior. +/// +/// ## Scrolling Considerations +/// +/// If this [SelectableText] 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 +/// [SelectableText] to ensure proper scroll coordination for [SelectableText] +/// and its components like [TextSelectionOverlay]. +/// +/// See also: +/// +/// * [Text], which is the non selectable version of this widget. +/// * [TextField], which is the editable version of this widget. +/// * [SelectionArea], which enables the selection of multiple [Text] widgets +/// and of other widgets. +class SelectableText extends StatefulWidget { + /// Creates a selectable text widget. + /// + /// If the [style] argument is null, the text will use the style from the + /// closest enclosing [DefaultTextStyle]. + /// + + /// If the [showCursor], [autofocus], [dragStartBehavior], + /// [selectionHeightStyle], [selectionWidthStyle] and [data] arguments are + /// specified, the [maxLines] argument must be greater than zero. + const SelectableText( + String this.data, { + super.key, + this.focusNode, + this.style, + this.strutStyle, + this.textAlign, + this.textDirection, + @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.showCursor = false, + this.autofocus = false, + @Deprecated( + 'Use `contextMenuBuilder` instead. ' + 'This feature was deprecated after v3.3.0-0.5.pre.', + ) + this.toolbarOptions, + this.minLines, + this.maxLines, + this.cursorWidth = 2.0, + this.cursorHeight, + this.cursorRadius, + this.cursorColor, + this.selectionColor, + this.selectionHeightStyle, + this.selectionWidthStyle, + this.dragStartBehavior = DragStartBehavior.start, + this.enableInteractiveSelection = true, + this.selectionControls, + this.onTap, + this.scrollPhysics, + this.scrollBehavior, + this.semanticsLabel, + this.textHeightBehavior, + this.textWidthBasis, + this.onSelectionChanged, + this.contextMenuBuilder = _defaultContextMenuBuilder, + this.magnifierConfiguration, + }) : 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( + textScaler == null || textScaleFactor == null, + 'textScaleFactor is deprecated and cannot be specified when textScaler is specified.', + ), + textSpan = null; + + /// Creates a selectable text widget with a [TextSpan]. + /// + /// The [TextSpan.children] attribute of the [textSpan] parameter must only + /// contain [TextSpan]s. Other types of [InlineSpan] are not allowed. + const SelectableText.rich( + TextSpan this.textSpan, { + super.key, + this.focusNode, + this.style, + this.strutStyle, + this.textAlign, + this.textDirection, + @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.showCursor = false, + this.autofocus = false, + @Deprecated( + 'Use `contextMenuBuilder` instead. ' + 'This feature was deprecated after v3.3.0-0.5.pre.', + ) + this.toolbarOptions, + this.minLines, + this.maxLines, + this.cursorWidth = 2.0, + this.cursorHeight, + this.cursorRadius, + this.cursorColor, + this.selectionColor, + this.selectionHeightStyle, + this.selectionWidthStyle, + this.dragStartBehavior = DragStartBehavior.start, + this.enableInteractiveSelection = true, + this.selectionControls, + this.onTap, + this.scrollPhysics, + this.scrollBehavior, + this.semanticsLabel, + this.textHeightBehavior, + this.textWidthBasis, + this.onSelectionChanged, + this.contextMenuBuilder = _defaultContextMenuBuilder, + this.magnifierConfiguration, + }) : 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( + textScaler == null || textScaleFactor == null, + 'textScaleFactor is deprecated and cannot be specified when textScaler is specified.', + ), + data = null; + + /// The text to display. + /// + /// This will be null if a [textSpan] is provided instead. + final String? data; + + /// The text to display as a [TextSpan]. + /// + /// This will be null if [data] is provided instead. + final TextSpan? textSpan; + + /// Defines the focus for this widget. + /// + /// Text is only selectable when widget is focused. + /// + /// The [focusNode] is a long-lived object that's typically managed by a + /// [StatefulWidget] parent. See [FocusNode] for more information. + /// + /// To give the 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] with + /// [FocusNode.skipTraversal] parameter set to `true`, which causes the widget + /// to be skipped over during focus traversal. + final FocusNode? focusNode; + + /// The style to use for the text. + /// + /// If null, defaults [DefaultTextStyle] of context. + final TextStyle? style; + + /// {@macro flutter.widgets.editableText.strutStyle} + final StrutStyle? strutStyle; + + /// {@macro flutter.widgets.editableText.textAlign} + final TextAlign? textAlign; + + /// {@macro flutter.widgets.editableText.textDirection} + final TextDirection? textDirection; + + /// {@macro flutter.widgets.editableText.textScaleFactor} + @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; + + /// {@macro flutter.widgets.editableText.autofocus} + final bool autofocus; + + /// {@macro flutter.widgets.editableText.minLines} + final int? minLines; + + /// {@macro flutter.widgets.editableText.maxLines} + final int? maxLines; + + /// {@macro flutter.widgets.editableText.showCursor} + final bool showCursor; + + /// {@macro flutter.widgets.editableText.cursorWidth} + final double cursorWidth; + + /// {@macro flutter.widgets.editableText.cursorHeight} + final double? cursorHeight; + + /// {@macro flutter.widgets.editableText.cursorRadius} + final Radius? cursorRadius; + + /// The color of the cursor. + /// + /// The cursor indicates the current text insertion point. + /// + /// If null then [DefaultSelectionStyle.cursorColor] is used. If that is also + /// null and [ThemeData.platform] is [TargetPlatform.iOS] or + /// [TargetPlatform.macOS], then [CupertinoThemeData.primaryColor] is used. + /// Otherwise [ColorScheme.primary] of [ThemeData.colorScheme] is used. + final Color? cursorColor; + + /// The color to use when painting the selection. + /// + /// If this property is null, this widget gets the selection color from the + /// inherited [DefaultSelectionStyle] (if any); if none, the selection + /// color is derived from the [CupertinoThemeData.primaryColor] on + /// Apple platforms and [ColorScheme.primary] of [ThemeData.colorScheme] on + /// other platforms. + final Color? selectionColor; + + /// 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; + + /// {@macro flutter.widgets.editableText.enableInteractiveSelection} + final bool enableInteractiveSelection; + + /// {@macro flutter.widgets.editableText.selectionControls} + final TextSelectionControls? selectionControls; + + /// {@macro flutter.widgets.scrollable.dragStartBehavior} + final DragStartBehavior dragStartBehavior; + + /// Configuration of toolbar options. + /// + /// Paste and cut will be disabled regardless. + /// + /// If not set, select all and copy will be enabled by default. + @Deprecated( + 'Use `contextMenuBuilder` instead. ' + 'This feature was deprecated after v3.3.0-0.5.pre.', + ) + final ToolbarOptions? toolbarOptions; + + /// {@macro flutter.widgets.editableText.selectionEnabled} + bool get selectionEnabled => enableInteractiveSelection; + + /// Called when the user taps on this selectable text. + /// + /// The selectable text 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 selectable text with a competing + /// GestureDetector is problematic. + /// + /// To unconditionally handle taps, without interfering with the selectable text's + /// internal gesture detector, provide this callback. + /// + /// 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 + /// selectable text's internal gesture detector, use a [Listener]. + final GestureTapCallback? onTap; + + /// {@macro flutter.widgets.editableText.scrollPhysics} + final ScrollPhysics? scrollPhysics; + + /// {@macro flutter.widgets.editableText.scrollBehavior} + final ScrollBehavior? scrollBehavior; + + /// {@macro flutter.widgets.Text.semanticsLabel} + final String? semanticsLabel; + + /// {@macro dart.ui.textHeightBehavior} + final TextHeightBehavior? textHeightBehavior; + + /// {@macro flutter.painting.textPainter.textWidthBasis} + final TextWidthBasis? textWidthBasis; + + /// {@macro flutter.widgets.editableText.onSelectionChanged} + final SelectionChangedCallback? onSelectionChanged; + + /// {@macro flutter.widgets.EditableText.contextMenuBuilder} + final EditableTextContextMenuBuilder? contextMenuBuilder; + + static Widget _defaultContextMenuBuilder( + BuildContext context, + EditableTextState editableTextState, + ) { + return AdaptiveTextSelectionToolbar.editableText( + editableTextState: editableTextState, + ); + } + + /// The configuration for the magnifier used when the text is selected. + /// + /// 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} + final TextMagnifierConfiguration? magnifierConfiguration; + + @override + State createState() => _SelectableTextState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add( + DiagnosticsProperty('data', data, defaultValue: null), + ) + ..add( + DiagnosticsProperty( + 'semanticsLabel', + semanticsLabel, + defaultValue: null, + ), + ) + ..add( + DiagnosticsProperty( + 'focusNode', + focusNode, + defaultValue: null, + ), + ) + ..add( + DiagnosticsProperty('style', style, defaultValue: null), + ) + ..add( + DiagnosticsProperty('autofocus', autofocus, defaultValue: false), + ) + ..add( + DiagnosticsProperty( + 'showCursor', + showCursor, + defaultValue: false, + ), + ) + ..add(IntProperty('minLines', minLines, defaultValue: null)) + ..add(IntProperty('maxLines', maxLines, defaultValue: null)) + ..add( + EnumProperty('textAlign', textAlign, defaultValue: null), + ) + ..add( + EnumProperty( + 'textDirection', + textDirection, + defaultValue: null, + ), + ) + ..add( + DoubleProperty('textScaleFactor', textScaleFactor, defaultValue: null), + ) + ..add( + DiagnosticsProperty( + 'textScaler', + textScaler, + defaultValue: null, + ), + ) + ..add( + DoubleProperty('cursorWidth', cursorWidth, defaultValue: 2.0), + ) + ..add( + DoubleProperty('cursorHeight', cursorHeight, defaultValue: null), + ) + ..add( + DiagnosticsProperty( + 'cursorRadius', + cursorRadius, + defaultValue: null, + ), + ) + ..add( + DiagnosticsProperty( + 'cursorColor', + cursorColor, + defaultValue: null, + ), + ) + ..add( + DiagnosticsProperty( + 'selectionColor', + selectionColor, + defaultValue: null, + ), + ) + ..add( + FlagProperty( + 'selectionEnabled', + value: selectionEnabled, + defaultValue: true, + ifFalse: 'selection disabled', + ), + ) + ..add( + DiagnosticsProperty( + 'selectionControls', + selectionControls, + defaultValue: null, + ), + ) + ..add( + DiagnosticsProperty( + 'scrollPhysics', + scrollPhysics, + defaultValue: null, + ), + ) + ..add( + DiagnosticsProperty( + 'scrollBehavior', + scrollBehavior, + defaultValue: null, + ), + ) + ..add( + DiagnosticsProperty( + 'textHeightBehavior', + textHeightBehavior, + defaultValue: null, + ), + ); + } +} + +class _SelectableTextState extends State + implements TextSelectionGestureDetectorBuilderDelegate { + EditableTextState? get _editableText => editableTextKey.currentState; + + late _TextSpanEditingController _controller; + + FocusNode? _focusNode; + FocusNode get _effectiveFocusNode => + widget.focusNode ?? (_focusNode ??= FocusNode(skipTraversal: true)); + + bool _showSelectionHandles = false; + + late _SelectableTextSelectionGestureDetectorBuilder + _selectionGestureDetectorBuilder; + + // API for TextSelectionGestureDetectorBuilderDelegate. + @override + late bool forcePressEnabled; + + @override + final GlobalKey editableTextKey = + GlobalKey(); + + @override + bool get selectionEnabled => widget.selectionEnabled; + // End of API for TextSelectionGestureDetectorBuilderDelegate. + + @override + void initState() { + super.initState(); + _selectionGestureDetectorBuilder = + _SelectableTextSelectionGestureDetectorBuilder(state: this); + _controller = _TextSpanEditingController( + textSpan: widget.textSpan ?? TextSpan(text: widget.data), + ); + _controller.addListener(_onControllerChanged); + _effectiveFocusNode.addListener(_handleFocusChanged); + } + + @override + void didUpdateWidget(SelectableText oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.data != oldWidget.data || + widget.textSpan != oldWidget.textSpan) { + _controller.removeListener(_onControllerChanged); + _controller.dispose(); + _controller = _TextSpanEditingController( + textSpan: widget.textSpan ?? TextSpan(text: widget.data), + ); + _controller.addListener(_onControllerChanged); + } + if (widget.focusNode != oldWidget.focusNode) { + (oldWidget.focusNode ?? _focusNode)?.removeListener(_handleFocusChanged); + (widget.focusNode ?? _focusNode)?.addListener(_handleFocusChanged); + } + if (_effectiveFocusNode.hasFocus && _controller.selection.isCollapsed) { + _showSelectionHandles = false; + } else { + _showSelectionHandles = true; + } + } + + @override + void dispose() { + _effectiveFocusNode.removeListener(_handleFocusChanged); + _focusNode?.dispose(); + _controller.dispose(); + super.dispose(); + } + + void _onControllerChanged() { + final bool showSelectionHandles = + !_effectiveFocusNode.hasFocus || !_controller.selection.isCollapsed; + if (showSelectionHandles == _showSelectionHandles) { + return; + } + setState(() { + _showSelectionHandles = showSelectionHandles; + }); + } + + void _handleFocusChanged() { + if (!_effectiveFocusNode.hasFocus && + SchedulerBinding.instance.lifecycleState == AppLifecycleState.resumed) { + // We should only clear the selection when this SelectableText loses + // focus while the application is currently running. It is possible + // that the application is not currently running, for example on desktop + // platforms, clicking on a different window switches the focus to + // the new window causing the Flutter application to go inactive. In this + // case we want to retain the selection so it remains when we return to + // the Flutter application. + _controller.value = TextEditingValue(text: _controller.value.text); + } + } + + void _handleSelectionChanged( + TextSelection selection, + SelectionChangedCause? cause, + ) { + final bool willShowSelectionHandles = _shouldShowSelectionHandles(cause); + if (willShowSelectionHandles != _showSelectionHandles) { + setState(() { + _showSelectionHandles = willShowSelectionHandles; + }); + } + + widget.onSelectionChanged?.call(selection, cause); + + switch (Theme.of(context).platform) { + case TargetPlatform.iOS: + case TargetPlatform.macOS: + if (cause == SelectionChangedCause.longPress) { + _editableText?.bringIntoView(selection.base); + } + return; + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + // Do nothing. + } + } + + /// Toggle the toolbar when a selection handle is tapped. + void _handleSelectionHandleTapped() { + if (_controller.selection.isCollapsed) { + _editableText!.toggleToolbar(); + } + } + + 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 (_controller.selection.isCollapsed) { + return false; + } + + if (cause == SelectionChangedCause.keyboard) { + return false; + } + + if (cause == SelectionChangedCause.longPress) { + return true; + } + + if (_controller.text.isNotEmpty) { + return true; + } + + return false; + } + + @override + Widget build(BuildContext context) { + // TODO(garyq): Assert to block WidgetSpans from being used here are removed, + // but we still do not yet have nice handling of things like carets, clipboard, + // and other features. We should add proper support. Currently, caret handling + // is blocked on SkParagraph switch and https://github.com/flutter/engine/pull/27010 + // should be landed in SkParagraph after the switch is complete. + assert(debugCheckHasMediaQuery(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 FocusNode focusNode = _effectiveFocusNode; + + TextSelectionControls? textSelectionControls = widget.selectionControls; + final bool paintCursorAboveText; + final bool cursorOpacityAnimates; + Offset? cursorOffset; + final Color cursorColor; + final Color selectionColor; + Radius? cursorRadius = widget.cursorRadius; + + switch (theme.platform) { + case TargetPlatform.iOS: + final CupertinoThemeData cupertinoTheme = CupertinoTheme.of(context); + forcePressEnabled = true; + textSelectionControls ??= cupertinoTextSelectionHandleControls; + paintCursorAboveText = true; + cursorOpacityAnimates = true; + cursorColor = + 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, + ); + + case TargetPlatform.macOS: + final CupertinoThemeData cupertinoTheme = CupertinoTheme.of(context); + forcePressEnabled = false; + textSelectionControls ??= cupertinoDesktopTextSelectionHandleControls; + paintCursorAboveText = true; + cursorOpacityAnimates = true; + cursorColor = + 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, + ); + + case TargetPlatform.android: + case TargetPlatform.fuchsia: + forcePressEnabled = false; + textSelectionControls ??= materialTextSelectionHandleControls; + paintCursorAboveText = false; + cursorOpacityAnimates = false; + cursorColor = + widget.cursorColor ?? + selectionStyle.cursorColor ?? + theme.colorScheme.primary; + selectionColor = + selectionStyle.selectionColor ?? + theme.colorScheme.primary.withOpacity(0.40); + + case TargetPlatform.linux: + case TargetPlatform.windows: + forcePressEnabled = false; + textSelectionControls ??= desktopTextSelectionHandleControls; + paintCursorAboveText = false; + cursorOpacityAnimates = false; + cursorColor = + widget.cursorColor ?? + selectionStyle.cursorColor ?? + theme.colorScheme.primary; + selectionColor = + selectionStyle.selectionColor ?? + theme.colorScheme.primary.withOpacity(0.40); + } + + final DefaultTextStyle defaultTextStyle = DefaultTextStyle.of(context); + TextStyle? effectiveTextStyle = widget.style; + if (effectiveTextStyle == null || effectiveTextStyle.inherit) { + effectiveTextStyle = defaultTextStyle.style.merge( + widget.style ?? _controller._textSpan.style, + ); + } + final TextScaler? effectiveScaler = + widget.textScaler ?? + switch (widget.textScaleFactor) { + null => null, + final double textScaleFactor => TextScaler.linear(textScaleFactor), + }; + final Widget child = RepaintBoundary( + child: EditableText( + key: editableTextKey, + style: effectiveTextStyle, + readOnly: true, + toolbarOptions: widget.toolbarOptions, + textWidthBasis: + widget.textWidthBasis ?? defaultTextStyle.textWidthBasis, + textHeightBehavior: + widget.textHeightBehavior ?? defaultTextStyle.textHeightBehavior, + showSelectionHandles: _showSelectionHandles, + showCursor: widget.showCursor, + controller: _controller, + focusNode: focusNode, + strutStyle: widget.strutStyle ?? const StrutStyle(), + textAlign: + widget.textAlign ?? defaultTextStyle.textAlign ?? TextAlign.start, + textDirection: widget.textDirection, + textScaler: effectiveScaler, + autofocus: widget.autofocus, + forceLine: false, + minLines: widget.minLines, + maxLines: widget.maxLines ?? defaultTextStyle.maxLines, + selectionColor: widget.selectionColor ?? selectionColor, + selectionControls: widget.selectionEnabled + ? textSelectionControls + : null, + onSelectionChanged: _handleSelectionChanged, + onSelectionHandleTapped: _handleSelectionHandleTapped, + rendererIgnoresPointer: true, + 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, + enableInteractiveSelection: widget.enableInteractiveSelection, + magnifierConfiguration: + widget.magnifierConfiguration ?? + TextMagnifier.adaptiveMagnifierConfiguration, + dragStartBehavior: widget.dragStartBehavior, + scrollPhysics: widget.scrollPhysics, + scrollBehavior: widget.scrollBehavior, + autofillHints: null, + contextMenuBuilder: widget.contextMenuBuilder, + ), + ); + + return Semantics( + label: widget.semanticsLabel, + excludeSemantics: widget.semanticsLabel != null, + onLongPress: () { + _effectiveFocusNode.requestFocus(); + }, + child: _selectionGestureDetectorBuilder.buildGestureDetector( + behavior: HitTestBehavior.translucent, + child: child, + ), + ); + } +} diff --git a/lib/common/widgets/flutter/text_intro/tap_and_drag.dart b/lib/common/widgets/flutter/text_intro/tap_and_drag.dart new file mode 100644 index 000000000..c9bdd7a04 --- /dev/null +++ b/lib/common/widgets/flutter/text_intro/tap_and_drag.dart @@ -0,0 +1,1085 @@ +// 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 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; + +// Examples can assume: +// void setState(VoidCallback fn) { } +// late String _last; + +double _getGlobalDistance(PointerEvent event, OffsetPair? originPosition) { + assert(originPosition != null); + final Offset offset = event.position - originPosition!.global; + return offset.distance; +} + +// The possible states of a [BaseTapAndDragGestureRecognizer]. +// +// The recognizer advances from [ready] to [possible] when it starts tracking +// a pointer in [BaseTapAndDragGestureRecognizer.addAllowedPointer]. Where it advances +// from there depends on the sequence of pointer events that is tracked by the +// recognizer, following the initial [PointerDownEvent]: +// +// * If a [PointerUpEvent] has not been tracked, the recognizer stays in the [possible] +// state as long as it continues to track a pointer. +// * If a [PointerMoveEvent] is tracked that has moved a sufficient global distance +// from the initial [PointerDownEvent] and it came before a [PointerUpEvent], then +// this recognizer moves from the [possible] state to [accepted]. +// * If a [PointerUpEvent] is tracked before the pointer has moved a sufficient global +// distance to be considered a drag, then this recognizer moves from the [possible] +// state to [ready]. +// * If a [PointerCancelEvent] is tracked then this recognizer moves from its current +// state to [ready]. +// +// Once the recognizer has stopped tracking any remaining pointers, the recognizer +// returns to the [ready] state. +enum _DragState { + // The recognizer is ready to start recognizing a drag. + ready, + + // The sequence of pointer events seen thus far is consistent with a drag but + // it has not been accepted definitively. + possible, + + // The sequence of pointer events has been accepted definitively as a drag. + accepted, +} + +// A mixin for [OneSequenceGestureRecognizer] that tracks the number of taps +// that occur in a series of [PointerEvent]s and the most recent set of +// [LogicalKeyboardKey]s pressed on the most recent tap down. +// +// A tap is tracked as part of a series of taps if: +// +// 1. The elapsed time between when a [PointerUpEvent] and the subsequent +// [PointerDownEvent] does not exceed [kDoubleTapTimeout]. +// 2. The delta between the position tapped in the global coordinate system +// and the position that was tapped previously must be less than or equal +// to [kDoubleTapSlop]. +// +// This mixin's state, i.e. the series of taps being tracked is reset when +// a tap is tracked that does not meet any of the specifications stated above. +mixin _TapStatusTrackerMixin on OneSequenceGestureRecognizer { + // Public state available to [OneSequenceGestureRecognizer]. + + // The [PointerDownEvent] that was most recently tracked in [addAllowedPointer]. + // + // This value will be null if a [PointerDownEvent] has not been tracked yet in + // [addAllowedPointer] or the timer between two taps has elapsed. + // + // This value is only reset when the timer between a [PointerUpEvent] and the + // [PointerDownEvent] times out or when a new [PointerDownEvent] is tracked in + // [addAllowedPointer]. + PointerDownEvent? get currentDown => _down; + + // The [PointerUpEvent] that was most recently tracked in [handleEvent]. + // + // This value will be null if a [PointerUpEvent] has not been tracked yet in + // [handleEvent] or the timer between two taps has elapsed. + // + // This value is only reset when the timer between a [PointerUpEvent] and the + // [PointerDownEvent] times out or when a new [PointerDownEvent] is tracked in + // [addAllowedPointer]. + PointerUpEvent? get currentUp => _up; + + // The number of consecutive taps that the most recently tracked [PointerDownEvent] + // in [currentDown] represents. + // + // This value defaults to zero, meaning a tap series is not currently being tracked. + // + // When this value is greater than zero it means [addAllowedPointer] has run + // and at least one [PointerDownEvent] belongs to the current series of taps + // being tracked. + // + // [addAllowedPointer] will either increment this value by `1` or set the value to `1` + // depending if the new [PointerDownEvent] is determined to be in the same series as the + // tap that preceded it. If too much time has elapsed between two taps, the recognizer has lost + // in the arena, the gesture has been cancelled, or the recognizer is being disposed then + // this value will be set to `0`, and a new series will begin. + int get consecutiveTapCount => _consecutiveTapCount; + + // The upper limit for the [consecutiveTapCount]. When this limit is reached + // all tap related state is reset and a new tap series is tracked. + // + // If this value is null, [consecutiveTapCount] can grow infinitely large. + int? get maxConsecutiveTap; + + // Private tap state tracked. + PointerDownEvent? _down; + PointerUpEvent? _up; + int _consecutiveTapCount = 0; + + OffsetPair? _originPosition; + int? _previousButtons; + + // For timing taps. + Timer? _consecutiveTapTimer; + Offset? _lastTapOffset; + + /// {@macro flutter.gestures.selectionrecognizers.TextSelectionGestureDetector.onTapTrackStart} + VoidCallback? onTapTrackStart; + + /// {@macro flutter.gestures.selectionrecognizers.TextSelectionGestureDetector.onTapTrackReset} + VoidCallback? onTapTrackReset; + + // When tracking a tap, the [consecutiveTapCount] is incremented if the given tap + // falls under the tolerance specifications and reset to 1 if not. + @override + void addAllowedPointer(PointerDownEvent event) { + super.addAllowedPointer(event); + if (_consecutiveTapTimer != null && !_consecutiveTapTimer!.isActive) { + _tapTrackerReset(); + } + if (maxConsecutiveTap == _consecutiveTapCount) { + _tapTrackerReset(); + } + _up = null; + if (_down != null && !_representsSameSeries(event)) { + // The given tap does not match the specifications of the series of taps being tracked, + // reset the tap count and related state. + _consecutiveTapCount = 1; + } else { + _consecutiveTapCount += 1; + } + _consecutiveTapTimerStop(); + // `_down` must be assigned in this method instead of [handleEvent], + // because [acceptGesture] might be called before [handleEvent], + // which may rely on `_down` to initiate a callback. + _trackTap(event); + } + + @override + void handleEvent(PointerEvent event) { + if (event is PointerMoveEvent) { + final double computedSlop = computeHitSlop(event.kind, gestureSettings); + final bool isSlopPastTolerance = + _getGlobalDistance(event, _originPosition) > computedSlop; + + if (isSlopPastTolerance) { + _consecutiveTapTimerStop(); + _previousButtons = null; + _lastTapOffset = null; + } + } else if (event is PointerUpEvent) { + _up = event; + if (_down != null) { + _consecutiveTapTimerStop(); + _consecutiveTapTimerStart(); + } + } else if (event is PointerCancelEvent) { + _tapTrackerReset(); + } + } + + @override + void rejectGesture(int pointer) { + _tapTrackerReset(); + } + + @override + void dispose() { + _tapTrackerReset(); + super.dispose(); + } + + void _trackTap(PointerDownEvent event) { + _down = event; + _previousButtons = event.buttons; + _lastTapOffset = event.position; + _originPosition = OffsetPair( + local: event.localPosition, + global: event.position, + ); + onTapTrackStart?.call(); + } + + bool _hasSameButton(int buttons) { + assert(_previousButtons != null); + if (buttons == _previousButtons!) { + return true; + } else { + return false; + } + } + + bool _isWithinConsecutiveTapTolerance(Offset secondTapOffset) { + if (_lastTapOffset == null) { + return false; + } + + final Offset difference = secondTapOffset - _lastTapOffset!; + return difference.distance <= kDoubleTapSlop; + } + + bool _representsSameSeries(PointerDownEvent event) { + return _consecutiveTapTimer != null && + _isWithinConsecutiveTapTolerance(event.position) && + _hasSameButton(event.buttons); + } + + void _consecutiveTapTimerStart() { + _consecutiveTapTimer ??= Timer( + kDoubleTapTimeout, + _consecutiveTapTimerTimeout, + ); + } + + void _consecutiveTapTimerStop() { + if (_consecutiveTapTimer != null) { + _consecutiveTapTimer!.cancel(); + _consecutiveTapTimer = null; + } + } + + void _consecutiveTapTimerTimeout() { + // The consecutive tap timer may time out before a tap down/tap up event is + // fired. In this case we should not reset the tap tracker state immediately. + // Instead we should reset the tap tracker on the next call to [addAllowedPointer], + // if the timer is no longer active. + } + + void _tapTrackerReset() { + // The timer has timed out, i.e. the time between a [PointerUpEvent] and the subsequent + // [PointerDownEvent] exceeded the duration of [kDoubleTapTimeout], so the tap belonging + // to the [PointerDownEvent] cannot be considered part of the same tap series as the + // previous [PointerUpEvent]. + _consecutiveTapTimerStop(); + _previousButtons = null; + _originPosition = null; + _lastTapOffset = null; + _consecutiveTapCount = 0; + _down = null; + _up = null; + onTapTrackReset?.call(); + } +} + +/// A base class for gesture recognizers that recognize taps and movements. +/// +/// Takes on the responsibilities of [TapGestureRecognizer] and +/// [DragGestureRecognizer] in one [GestureRecognizer]. +/// +/// ### Gesture arena behavior +/// +/// [BaseTapAndDragGestureRecognizer] competes on the pointer events of +/// [kPrimaryButton] only when it has at least one non-null `onTap*` +/// or `onDrag*` callback. +/// +/// It will declare defeat if it determines that a gesture is not a +/// tap (e.g. if the pointer is dragged too far while it's contacting the +/// screen) or a drag (e.g. if the pointer was not dragged far enough to +/// be considered a drag. +/// +/// This recognizer will not immediately declare victory for every tap that it +/// recognizes, but it declares victory for every drag. +/// +/// The recognizer will declare victory when all other recognizer's in +/// the arena have lost, if the timer of [kPressTimeout] elapses and a tap +/// series greater than 1 is being tracked, or until the pointer has moved +/// a sufficient global distance from the origin to be considered a drag. +/// +/// If this recognizer loses the arena (either by declaring defeat or by +/// another recognizer declaring victory) while the pointer is contacting the +/// screen, it will fire [onCancel] instead of [onTapUp] or [onDragEnd]. +/// +/// ### When competing with `TapGestureRecognizer` and `DragGestureRecognizer` +/// +/// Similar to [TapGestureRecognizer] and [DragGestureRecognizer], +/// [BaseTapAndDragGestureRecognizer] will not aggressively declare victory when +/// it detects a tap, so when it is competing with those gesture recognizers and +/// others it has a chance of losing. Similarly, when `eagerVictoryOnDrag` is set +/// to `false`, this recognizer will not aggressively declare victory when it +/// detects a drag. By default, `eagerVictoryOnDrag` is set to `true`, so this +/// recognizer will aggressively declare victory when it detects a drag. +/// +/// When competing against [TapGestureRecognizer], if the pointer does not move past the tap +/// tolerance, then the recognizer that entered the arena first will win. In this case the +/// gesture detected is a tap. If the pointer does travel past the tap tolerance then this +/// recognizer will be declared winner by default. The gesture detected in this case is a drag. +/// +/// When competing against [DragGestureRecognizer], if the pointer does not move a sufficient +/// global distance to be considered a drag, the recognizers will tie in the arena. If the +/// pointer does travel enough distance then the recognizer that entered the arena +/// first will win. The gesture detected in this case is a drag. +/// +/// {@tool dartpad} +/// This example shows how to use the [TapAndPanGestureRecognizer] along with a +/// [RawGestureDetector] to scale a Widget. +/// +/// ** See code in examples/api/lib/gestures/tap_and_drag/tap_and_drag.0.dart ** +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// This example shows how to hook up [TapAndPanGestureRecognizer]s' to nested +/// [RawGestureDetector]s'. It assumes that the code is being used inside a [State] +/// object with a `_last` field that is then displayed as the child of the gesture detector. +/// +/// In this example, if the pointer has moved past the drag threshold, then the +/// the first [TapAndPanGestureRecognizer] instance to receive the [PointerEvent] +/// will win the arena because the recognizer will immediately declare victory. +/// +/// The first one to receive the event in the example will depend on where on both +/// containers the pointer lands first. If your pointer begins in the overlapping +/// area of both containers, then the inner-most widget will receive the event first. +/// If your pointer begins in the yellow container then it will be the first to +/// receive the event. +/// +/// If the pointer has not moved past the drag threshold, then the first recognizer +/// to enter the arena will win (i.e. they both tie and the gesture arena will call +/// [GestureArenaManager.sweep] so the first member of the arena will win). +/// +/// ```dart +/// RawGestureDetector( +/// gestures: { +/// TapAndPanGestureRecognizer: GestureRecognizerFactoryWithHandlers( +/// () => TapAndPanGestureRecognizer(), +/// (TapAndPanGestureRecognizer instance) { +/// instance +/// ..onTapDown = (TapDragDownDetails details) { setState(() { _last = 'down_a'; }); } +/// ..onDragStart = (TapDragStartDetails details) { setState(() { _last = 'drag_start_a'; }); } +/// ..onDragUpdate = (TapDragUpdateDetails details) { setState(() { _last = 'drag_update_a'; }); } +/// ..onDragEnd = (TapDragEndDetails details) { setState(() { _last = 'drag_end_a'; }); } +/// ..onTapUp = (TapDragUpDetails details) { setState(() { _last = 'up_a'; }); } +/// ..onCancel = () { setState(() { _last = 'cancel_a'; }); }; +/// }, +/// ), +/// }, +/// child: Container( +/// width: 300.0, +/// height: 300.0, +/// color: Colors.yellow, +/// alignment: Alignment.center, +/// child: RawGestureDetector( +/// gestures: { +/// TapAndPanGestureRecognizer: GestureRecognizerFactoryWithHandlers( +/// () => TapAndPanGestureRecognizer(), +/// (TapAndPanGestureRecognizer instance) { +/// instance +/// ..onTapDown = (TapDragDownDetails details) { setState(() { _last = 'down_b'; }); } +/// ..onDragStart = (TapDragStartDetails details) { setState(() { _last = 'drag_start_b'; }); } +/// ..onDragUpdate = (TapDragUpdateDetails details) { setState(() { _last = 'drag_update_b'; }); } +/// ..onDragEnd = (TapDragEndDetails details) { setState(() { _last = 'drag_end_b'; }); } +/// ..onTapUp = (TapDragUpDetails details) { setState(() { _last = 'up_b'; }); } +/// ..onCancel = () { setState(() { _last = 'cancel_b'; }); }; +/// }, +/// ), +/// }, +/// child: Container( +/// width: 150.0, +/// height: 150.0, +/// color: Colors.blue, +/// child: Text(_last), +/// ), +/// ), +/// ), +/// ) +/// ``` +/// {@end-tool} +sealed class BaseTapAndDragGestureRecognizer + extends OneSequenceGestureRecognizer + with _TapStatusTrackerMixin { + /// Creates a tap and drag gesture recognizer. + /// + /// {@macro flutter.gestures.GestureRecognizer.supportedDevices} + BaseTapAndDragGestureRecognizer({ + super.debugOwner, + super.supportedDevices, + super.allowedButtonsFilter, + this.eagerVictoryOnDrag = true, + }) : _deadline = kPressTimeout, + dragStartBehavior = DragStartBehavior.start; + + /// Configure the behavior of offsets passed to [onDragStart]. + /// + /// If set to [DragStartBehavior.start], the [onDragStart] callback will be called + /// with the position of the pointer at the time this gesture recognizer won + /// the arena. If [DragStartBehavior.down], [onDragStart] will be called with + /// the position of the first detected down event for the pointer. When there + /// are no other gestures competing with this gesture in the arena, there's + /// no difference in behavior between the two settings. + /// + /// For more information about the gesture arena: + /// https://flutter.dev/to/gesture-disambiguation + /// + /// By default, the drag start behavior is [DragStartBehavior.start]. + /// + /// See also: + /// + /// * [DragGestureRecognizer.dragStartBehavior], which includes more details and an example. + DragStartBehavior dragStartBehavior; + + /// The frequency at which the [onDragUpdate] callback is called. + /// + /// The value defaults to null, meaning there is no delay for [onDragUpdate] callback. + Duration? dragUpdateThrottleFrequency; + + /// An upper bound for the amount of taps that can belong to one tap series. + /// + /// When this limit is reached the series of taps being tracked by this + /// recognizer will be reset. + @override + int? maxConsecutiveTap; + + /// Whether this recognizer eagerly declares victory when it has detected + /// a drag. + /// + /// When this value is `false`, this recognizer will wait until it is the last + /// recognizer in the gesture arena before declaring victory on a drag. + /// + /// Defaults to `true`. + bool eagerVictoryOnDrag; + + /// {@macro flutter.gestures.tap.TapGestureRecognizer.onTapDown} + /// + /// This triggers after the down event, once a short timeout ([kPressTimeout]) has + /// elapsed, or once the gestures has won the arena, whichever comes first. + /// + /// The position of the pointer is provided in the callback's `details` + /// argument, which is a [TapDragDownDetails] object. + /// + /// {@template flutter.gestures.selectionrecognizers.BaseTapAndDragGestureRecognizer.tapStatusTrackerData} + /// The number of consecutive taps, and the keys that were pressed on tap down + /// are also provided in the callback's `details` argument. + /// {@endtemplate} + /// + /// See also: + /// + /// * [kPrimaryButton], the button this callback responds to. + /// * [TapDragDownDetails], which is passed as an argument to this callback. + GestureTapDragDownCallback? onTapDown; + + /// {@macro flutter.gestures.tap.TapGestureRecognizer.onTapUp} + /// + /// This triggers on the up event, if the recognizer wins the arena with it + /// or has previously won. + /// + /// The position of the pointer is provided in the callback's `details` + /// argument, which is a [TapDragUpDetails] object. + /// + /// {@macro flutter.gestures.selectionrecognizers.BaseTapAndDragGestureRecognizer.tapStatusTrackerData} + /// + /// See also: + /// + /// * [kPrimaryButton], the button this callback responds to. + /// * [TapDragUpDetails], which is passed as an argument to this callback. + GestureTapDragUpCallback? onTapUp; + + /// {@macro flutter.gestures.monodrag.DragGestureRecognizer.onStart} + /// + /// The position of the pointer is provided in the callback's `details` + /// argument, which is a [TapDragStartDetails] object. The [dragStartBehavior] + /// determines this position. + /// + /// {@macro flutter.gestures.selectionrecognizers.BaseTapAndDragGestureRecognizer.tapStatusTrackerData} + /// + /// See also: + /// + /// * [kPrimaryButton], the button this callback responds to. + /// * [TapDragStartDetails], which is passed as an argument to this callback. + GestureTapDragStartCallback? onDragStart; + + /// {@macro flutter.gestures.monodrag.DragGestureRecognizer.onUpdate} + /// + /// The distance traveled by the pointer since the last update is provided in + /// the callback's `details` argument, which is a [TapDragUpdateDetails] object. + /// + /// {@macro flutter.gestures.selectionrecognizers.BaseTapAndDragGestureRecognizer.tapStatusTrackerData} + /// + /// See also: + /// + /// * [kPrimaryButton], the button this callback responds to. + /// * [TapDragUpdateDetails], which is passed as an argument to this callback. + GestureTapDragUpdateCallback? onDragUpdate; + + /// {@macro flutter.gestures.monodrag.DragGestureRecognizer.onEnd} + /// + /// The velocity is provided in the callback's `details` argument, which is a + /// [TapDragEndDetails] object. + /// + /// {@macro flutter.gestures.selectionrecognizers.BaseTapAndDragGestureRecognizer.tapStatusTrackerData} + /// + /// See also: + /// + /// * [kPrimaryButton], the button this callback responds to. + /// * [TapDragEndDetails], which is passed as an argument to this callback. + GestureTapDragEndCallback? onDragEnd; + + /// The pointer that previously triggered [onTapDown] did not complete. + /// + /// This is called when a [PointerCancelEvent] is tracked when the [onTapDown] callback + /// was previously called. + /// + /// It may also be called if a [PointerUpEvent] is tracked after the pointer has moved + /// past the tap tolerance but not past the drag tolerance, and the recognizer has not + /// yet won the arena. + /// + /// See also: + /// + /// * [kPrimaryButton], the button this callback responds to. + GestureCancelCallback? onCancel; + + // Tap related state. + bool _pastSlopTolerance = false; + bool _sentTapDown = false; + bool _wonArenaForPrimaryPointer = false; + + // Primary pointer being tracked by this recognizer. + int? _primaryPointer; + Timer? _deadlineTimer; + // The recognizer will call [onTapDown] after this amount of time has elapsed + // since starting to track the primary pointer. + // + // [onTapDown] will not be called if the primary pointer is + // accepted, rejected, or all pointers are up or canceled before [_deadline]. + final Duration _deadline; + + // Drag related state. + _DragState _dragState = _DragState.ready; + PointerEvent? _start; + late OffsetPair _initialPosition; + late OffsetPair _currentPosition; + // late double _globalDistanceMoved; + late double _globalDistanceMovedAllAxes; + + // For drag update throttle. + TapDragUpdateDetails? _lastDragUpdateDetails; + Timer? _dragUpdateThrottleTimer; + + final Set _acceptedActivePointers = {}; + + // Offset _getDeltaForDetails(Offset delta); + // double? _getPrimaryValueFromOffset(Offset value); + bool _hasSufficientGlobalDistanceToAccept( + PointerDeviceKind pointerDeviceKind, + ); + + // Drag updates may require throttling to avoid excessive updating, such as for text layouts in text + // fields. The frequency of invocations is controlled by the [dragUpdateThrottleFrequency]. + // + // Once the drag gesture ends, any pending drag update will be fired + // immediately. See [_checkDragEnd]. + void _handleDragUpdateThrottled() { + assert(_lastDragUpdateDetails != null); + if (onDragUpdate != null) { + invokeCallback( + 'onDragUpdate', + () => onDragUpdate!(_lastDragUpdateDetails!), + ); + } + _dragUpdateThrottleTimer = null; + _lastDragUpdateDetails = null; + } + + @override + bool isPointerAllowed(PointerEvent event) { + if (_primaryPointer == null) { + switch (event.buttons) { + case kPrimaryButton: + if (onTapDown == null && + onDragStart == null && + onDragUpdate == null && + onDragEnd == null && + onTapUp == null && + onCancel == null) { + return false; + } + default: + return false; + } + } else { + if (event.pointer != _primaryPointer) { + return false; + } + } + + return super.isPointerAllowed(event as PointerDownEvent); + } + + @override + void addAllowedPointer(PointerDownEvent event) { + if (_dragState == _DragState.ready) { + super.addAllowedPointer(event); + _primaryPointer = event.pointer; + // _globalDistanceMoved = 0.0; + _globalDistanceMovedAllAxes = 0.0; + _dragState = _DragState.possible; + _initialPosition = OffsetPair( + global: event.position, + local: event.localPosition, + ); + _currentPosition = _initialPosition; + _deadlineTimer = Timer( + _deadline, + () => _didExceedDeadlineWithEvent(event), + ); + } + } + + @override + void handleNonAllowedPointer(PointerDownEvent event) { + // There can be multiple drags simultaneously. Their effects are combined. + if (event.buttons != kPrimaryButton) { + if (!_wonArenaForPrimaryPointer) { + super.handleNonAllowedPointer(event); + } + } + } + + @override + void acceptGesture(int pointer) { + if (pointer != _primaryPointer) { + return; + } + + _stopDeadlineTimer(); + + assert(!_acceptedActivePointers.contains(pointer)); + _acceptedActivePointers.add(pointer); + + // Called when this recognizer is accepted by the [GestureArena]. + if (currentDown != null) { + _checkTapDown(currentDown!); + } + + _wonArenaForPrimaryPointer = true; + + // resolve(GestureDisposition.accepted) will be called when the [PointerMoveEvent] + // has moved a sufficient global distance to be considered a drag and + // `eagerVictoryOnDrag` is set to `true`. + if (_start != null && eagerVictoryOnDrag) { + assert(_dragState == _DragState.accepted); + assert(currentUp == null); + _acceptDrag(_start!); + } + + // This recognizer will wait until it is the last one in the gesture arena + // before accepting a drag when `eagerVictoryOnDrag` is set to `false`. + if (_start != null && !eagerVictoryOnDrag) { + assert(_dragState == _DragState.possible); + assert(currentUp == null); + _dragState = _DragState.accepted; + _acceptDrag(_start!); + } + + if (currentUp != null) { + _checkTapUp(currentUp!); + } + } + + @override + void didStopTrackingLastPointer(int pointer) { + switch (_dragState) { + case _DragState.ready: + _checkCancel(); + resolve(GestureDisposition.rejected); + + case _DragState.possible: + if (_pastSlopTolerance) { + // This means the pointer was not accepted as a tap. + if (_wonArenaForPrimaryPointer) { + // If the recognizer has already won the arena for the primary pointer being tracked + // but the pointer has exceeded the tap tolerance, then the pointer is accepted as a + // drag gesture. + if (currentDown != null) { + if (!_acceptedActivePointers.remove(pointer)) { + resolvePointer(pointer, GestureDisposition.rejected); + } + _dragState = _DragState.accepted; + _acceptDrag(currentDown!); + _checkDragEnd(); + } + } else { + _checkCancel(); + resolve(GestureDisposition.rejected); + } + } else { + // The pointer is accepted as a tap. + if (currentUp != null) { + _checkTapUp(currentUp!); + } + } + + case _DragState.accepted: + // For the case when the pointer has been accepted as a drag. + // Meaning [_checkTapDown] and [_checkDragStart] have already ran. + _checkDragEnd(); + } + + _stopDeadlineTimer(); + _start = null; + _dragState = _DragState.ready; + _pastSlopTolerance = false; + } + + @override + void handleEvent(PointerEvent event) { + if (event.pointer != _primaryPointer) { + return; + } + super.handleEvent(event); + if (event is PointerMoveEvent) { + // Receiving a [PointerMoveEvent], does not automatically mean the pointer + // being tracked is doing a drag gesture. There is some drift that can happen + // between the initial [PointerDownEvent] and subsequent [PointerMoveEvent]s. + // Accessing [_pastSlopTolerance] lets us know if our tap has moved past the + // acceptable tolerance. If the pointer does not move past this tolerance than + // it is not considered a drag. + // + // To be recognized as a drag, the [PointerMoveEvent] must also have moved + // a sufficient global distance from the initial [PointerDownEvent] to be + // accepted as a drag. This logic is handled in [_hasSufficientGlobalDistanceToAccept]. + // + // The recognizer will also detect the gesture as a drag when the pointer + // has been accepted and it has moved past the [slopTolerance] but has not moved + // a sufficient global distance from the initial position to be considered a drag. + // In this case since the gesture cannot be a tap, it defaults to a drag. + final double computedSlop = computeHitSlop(event.kind, gestureSettings); + _pastSlopTolerance = + _pastSlopTolerance || + _getGlobalDistance(event, _initialPosition) > computedSlop; + + if (_dragState == _DragState.accepted) { + _currentPosition = OffsetPair.fromEventPosition(event); + _checkDragUpdate(event); + } else if (_dragState == _DragState.possible) { + if (_start == null) { + // Only check for a drag if the start of a drag was not already identified. + _checkDrag(event); + } + + // This can occur when the recognizer is accepted before a [PointerMoveEvent] has been + // received that moves the pointer a sufficient global distance to be considered a drag. + if (_start != null && _wonArenaForPrimaryPointer) { + _dragState = _DragState.accepted; + _acceptDrag(_start!); + } + } + } else if (event is PointerUpEvent) { + if (_dragState == _DragState.possible) { + // The drag has not been accepted before a [PointerUpEvent], therefore the recognizer + // attempts to recognize a tap. + stopTrackingIfPointerNoLongerDown(event); + } else if (_dragState == _DragState.accepted) { + _giveUpPointer(event.pointer); + } + } else if (event is PointerCancelEvent) { + _dragState = _DragState.ready; + _giveUpPointer(event.pointer); + } + } + + @override + void rejectGesture(int pointer) { + if (pointer != _primaryPointer) { + return; + } + super.rejectGesture(pointer); + + _stopDeadlineTimer(); + _giveUpPointer(pointer); + _resetTaps(); + _resetDragUpdateThrottle(); + } + + @override + void dispose() { + _stopDeadlineTimer(); + _resetDragUpdateThrottle(); + super.dispose(); + } + + @override + String get debugDescription => 'tap_and_drag'; + + void _acceptDrag(PointerEvent event) { + assert(_dragState == _DragState.accepted); + + if (!_wonArenaForPrimaryPointer) { + return; + } + + if (dragStartBehavior == DragStartBehavior.start) { + _initialPosition += OffsetPair( + global: event.delta, + local: event.localDelta, + ); + _currentPosition = _initialPosition; + } + _checkDragStart(event); + final Offset localDelta = event.localDelta; + if (localDelta != Offset.zero) { + _currentPosition = OffsetPair.fromEventPosition(event); + final Offset correctedLocalPosition = _initialPosition.local + localDelta; + final Matrix4? localToGlobalTransform = event.transform == null + ? null + : Matrix4.tryInvert(event.transform!); + final Offset globalUpdateDelta = PointerEvent.transformDeltaViaPositions( + transform: localToGlobalTransform, + untransformedDelta: localDelta, + untransformedEndPosition: correctedLocalPosition, + ); + final OffsetPair updateDelta = OffsetPair( + local: localDelta, + global: globalUpdateDelta, + ); + // Only adds delta for down behaviour + _checkDragUpdate(event, corrected: _initialPosition + updateDelta); + } + } + + void _checkDrag(PointerMoveEvent event) { + final Matrix4? localToGlobalTransform = event.transform == null + ? null + : Matrix4.tryInvert(event.transform!); + // final Offset movedLocally = _getDeltaForDetails(event.localDelta); + // _globalDistanceMoved += + // PointerEvent.transformDeltaViaPositions( + // transform: localToGlobalTransform, + // untransformedDelta: movedLocally, + // untransformedEndPosition: event.localPosition, + // ).distance * + // (_getPrimaryValueFromOffset(movedLocally) ?? 1).sign; + _globalDistanceMovedAllAxes += + PointerEvent.transformDeltaViaPositions( + transform: localToGlobalTransform, + untransformedDelta: event.localDelta, + untransformedEndPosition: event.localPosition, + ).distance * + 1.sign; + if (_hasSufficientGlobalDistanceToAccept(event.kind) || + (_wonArenaForPrimaryPointer && + _globalDistanceMovedAllAxes.abs() > + computePanSlop(event.kind, gestureSettings))) { + _start = event; + if (eagerVictoryOnDrag) { + _dragState = _DragState.accepted; + if (!_wonArenaForPrimaryPointer) { + resolve(GestureDisposition.accepted); + } + } + } + } + + void _checkTapDown(PointerDownEvent event) { + if (_sentTapDown) { + return; + } + + final TapDragDownDetails details = TapDragDownDetails( + globalPosition: event.position, + localPosition: event.localPosition, + kind: getKindForPointer(event.pointer), + consecutiveTapCount: consecutiveTapCount, + ); + + if (onTapDown != null) { + invokeCallback('onTapDown', () => onTapDown!(details)); + } + + _sentTapDown = true; + } + + void _checkTapUp(PointerUpEvent event) { + if (!_wonArenaForPrimaryPointer) { + return; + } + + final TapDragUpDetails upDetails = TapDragUpDetails( + kind: event.kind, + globalPosition: event.position, + localPosition: event.localPosition, + consecutiveTapCount: consecutiveTapCount, + ); + + if (onTapUp != null) { + invokeCallback('onTapUp', () => onTapUp!(upDetails)); + } + + _resetTaps(); + if (!_acceptedActivePointers.remove(event.pointer)) { + resolvePointer(event.pointer, GestureDisposition.rejected); + } + } + + void _checkDragStart(PointerEvent event) { + if (onDragStart != null) { + final TapDragStartDetails details = TapDragStartDetails( + sourceTimeStamp: event.timeStamp, + globalPosition: _initialPosition.global, + localPosition: _initialPosition.local, + kind: getKindForPointer(event.pointer), + consecutiveTapCount: consecutiveTapCount, + ); + + invokeCallback('onDragStart', () => onDragStart!(details)); + } + + _start = null; + } + + void _checkDragUpdate(PointerEvent event, {OffsetPair? corrected}) { + final Offset globalPosition = corrected?.global ?? event.position; + final Offset localPosition = corrected?.local ?? event.localPosition; + + final TapDragUpdateDetails details = TapDragUpdateDetails( + sourceTimeStamp: event.timeStamp, + delta: event.localDelta, + globalPosition: globalPosition, + kind: getKindForPointer(event.pointer), + localPosition: localPosition, + offsetFromOrigin: globalPosition - _initialPosition.global, + localOffsetFromOrigin: localPosition - _initialPosition.local, + consecutiveTapCount: consecutiveTapCount, + ); + + if (dragUpdateThrottleFrequency != null) { + _lastDragUpdateDetails = details; + // Only schedule a new timer if there's not one pending. + _dragUpdateThrottleTimer ??= Timer( + dragUpdateThrottleFrequency!, + _handleDragUpdateThrottled, + ); + } else { + if (onDragUpdate != null) { + invokeCallback('onDragUpdate', () => onDragUpdate!(details)); + } + } + } + + void _checkDragEnd() { + final Offset globalPosition = _currentPosition.global; + final Offset localPosition = _currentPosition.local; + + if (_dragUpdateThrottleTimer != null) { + // If there's already an update scheduled, trigger it immediately and + // cancel the timer. + _dragUpdateThrottleTimer!.cancel(); + _handleDragUpdateThrottled(); + } + + final TapDragEndDetails endDetails = TapDragEndDetails( + globalPosition: globalPosition, + localPosition: localPosition, + primaryVelocity: 0.0, + consecutiveTapCount: consecutiveTapCount, + ); + + if (onDragEnd != null) { + invokeCallback('onDragEnd', () => onDragEnd!(endDetails)); + } + + _resetTaps(); + _resetDragUpdateThrottle(); + } + + void _checkCancel() { + if (!_sentTapDown) { + // Do not fire tap cancel if [onTapDown] was never called. + return; + } + if (onCancel != null) { + invokeCallback('onCancel', onCancel!); + } + _resetDragUpdateThrottle(); + _resetTaps(); + } + + void _didExceedDeadlineWithEvent(PointerDownEvent event) { + _didExceedDeadline(); + } + + void _didExceedDeadline() { + if (currentDown != null) { + _checkTapDown(currentDown!); + + if (consecutiveTapCount > 1) { + // If our consecutive tap count is greater than 1, i.e. is a double tap or greater, + // then this recognizer declares victory to prevent the [LongPressGestureRecognizer] + // from declaring itself the winner if a double tap is held for too long. + resolve(GestureDisposition.accepted); + } + } + } + + void _giveUpPointer(int pointer) { + stopTrackingPointer(pointer); + // If the pointer was never accepted, then it is rejected since this recognizer is no longer + // interested in winning the gesture arena for it. + if (!_acceptedActivePointers.remove(pointer)) { + resolvePointer(pointer, GestureDisposition.rejected); + } + } + + void _resetTaps() { + _sentTapDown = false; + _wonArenaForPrimaryPointer = false; + _primaryPointer = null; + } + + void _resetDragUpdateThrottle() { + if (dragUpdateThrottleFrequency == null) { + return; + } + _lastDragUpdateDetails = null; + if (_dragUpdateThrottleTimer != null) { + _dragUpdateThrottleTimer!.cancel(); + _dragUpdateThrottleTimer = null; + } + } + + void _stopDeadlineTimer() { + if (_deadlineTimer != null) { + _deadlineTimer!.cancel(); + _deadlineTimer = null; + } + } +} + +/// Recognizes taps along with movement in the horizontal direction. +/// +/// Before this recognizer has won the arena for the primary pointer being tracked, +/// it will only accept a drag on the horizontal axis. If a drag is detected after +/// this recognizer has won the arena then it will accept a drag on any axis. +/// +/// See also: +/// +/// * [BaseTapAndDragGestureRecognizer], for the class that provides the main +/// implementation details of this recognizer. +/// * [TapAndPanGestureRecognizer], for a similar recognizer that accepts a drag +/// on any axis regardless if the recognizer has won the arena for the primary +/// pointer being tracked. +/// * [HorizontalDragGestureRecognizer], for a similar recognizer that only recognizes +/// horizontal movement. +class TapAndHorizontalDragGestureRecognizer + extends BaseTapAndDragGestureRecognizer { + /// Create a gesture recognizer for interactions in the horizontal axis. + /// + /// {@macro flutter.gestures.GestureRecognizer.supportedDevices} + TapAndHorizontalDragGestureRecognizer({ + super.debugOwner, + super.supportedDevices, + }); + + @override + bool _hasSufficientGlobalDistanceToAccept( + PointerDeviceKind pointerDeviceKind, + ) { + return false; + // return _globalDistanceMoved.abs() > + // computeHitSlop(pointerDeviceKind, gestureSettings); + } + + // @override + // Offset _getDeltaForDetails(Offset delta) => Offset(delta.dx, 0.0); + + // @override + // double _getPrimaryValueFromOffset(Offset value) => value.dx; + + @override + String get debugDescription => 'tap and horizontal drag'; +} diff --git a/lib/common/widgets/flutter/text_intro/text.dart b/lib/common/widgets/flutter/text_intro/text.dart new file mode 100644 index 000000000..fedca01f1 --- /dev/null +++ b/lib/common/widgets/flutter/text_intro/text.dart @@ -0,0 +1,22 @@ +import 'package:PiliPlus/common/widgets/flutter/text_intro/selectable_text.dart'; +import 'package:PiliPlus/utils/platform_utils.dart'; +import 'package:flutter/material.dart' hide SelectableText; + +Widget selectableRichText( + TextSpan textSpan, { + TextStyle? style, +}) { + if (PlatformUtils.isDesktop) { + return SelectionArea( + child: Text.rich( + style: style, + textSpan, + ), + ); + } + return SelectableText.rich( + style: style, + textSpan, + scrollPhysics: const NeverScrollableScrollPhysics(), + ); +} diff --git a/lib/common/widgets/flutter/text_intro/text_selection.dart b/lib/common/widgets/flutter/text_intro/text_selection.dart new file mode 100644 index 000000000..bca55cfe9 --- /dev/null +++ b/lib/common/widgets/flutter/text_intro/text_selection.dart @@ -0,0 +1,419 @@ +// 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 'dart:math' as math; + +import 'package:PiliPlus/common/widgets/flutter/text_intro/tap_and_drag.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart' + hide BaseTapAndDragGestureRecognizer, TapAndHorizontalDragGestureRecognizer; +import 'package:flutter/material.dart'; + +class CustomTextSelectionGestureDetectorBuilder + extends TextSelectionGestureDetectorBuilder { + CustomTextSelectionGestureDetectorBuilder({required super.delegate}); + + @override + 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, + onSingleLongTapCancel: onSingleLongTapCancel, + onDoubleTapDown: onDoubleTapDown, + onTripleTapDown: onTripleTapDown, + onDragSelectionStart: onDragSelectionStart, + onDragSelectionUpdate: onDragSelectionUpdate, + onDragSelectionEnd: onDragSelectionEnd, + onUserTapAlwaysCalled: onUserTapAlwaysCalled, + behavior: behavior, + child: child, + ); + } +} + +/// A gesture detector to respond to non-exclusive event chains for a text field. +/// +/// An ordinary [GestureDetector] configured to handle events like tap and +/// double tap will only recognize one or the other. This widget detects both: +/// the first tap and then any subsequent taps that occurs within a time limit +/// after the first. +/// +/// See also: +/// +/// * [TextField], a Material text field which uses this gesture detector. +/// * [CupertinoTextField], a Cupertino text field which uses this gesture +/// detector. +class TextSelectionGestureDetector extends StatefulWidget { + /// Create a [TextSelectionGestureDetector]. + /// + /// Multiple callbacks can be called for one sequence of input gesture. + const TextSelectionGestureDetector({ + super.key, + this.onTapTrackStart, + this.onTapTrackReset, + this.onTapDown, + this.onForcePressStart, + this.onForcePressEnd, + this.onSecondaryTap, + this.onSecondaryTapDown, + this.onSingleTapUp, + this.onSingleTapCancel, + this.onUserTap, + this.onSingleLongTapStart, + this.onSingleLongTapMoveUpdate, + this.onSingleLongTapEnd, + this.onSingleLongTapCancel, + this.onDoubleTapDown, + this.onTripleTapDown, + this.onDragSelectionStart, + this.onDragSelectionUpdate, + this.onDragSelectionEnd, + this.onUserTapAlwaysCalled = false, + this.behavior, + required this.child, + }); + + /// {@template flutter.gestures.selectionrecognizers.TextSelectionGestureDetector.onTapTrackStart} + /// Callback used to indicate that a tap tracking has started upon + /// a [PointerDownEvent]. + /// {@endtemplate} + final VoidCallback? onTapTrackStart; + + /// {@template flutter.gestures.selectionrecognizers.TextSelectionGestureDetector.onTapTrackReset} + /// Callback used to indicate that a tap tracking has been reset which + /// happens on the next [PointerDownEvent] after the timer between two taps + /// elapses, the recognizer loses the arena, the gesture is cancelled or + /// the recognizer is disposed of. + /// {@endtemplate} + final VoidCallback? onTapTrackReset; + + /// Called for every tap down including every tap down that's part of a + /// double click or a long press, except touches that include enough movement + /// to not qualify as taps (e.g. pans and flings). + final GestureTapDragDownCallback? onTapDown; + + /// Called when a pointer has tapped down and the force of the pointer has + /// just become greater than [ForcePressGestureRecognizer.startPressure]. + final GestureForcePressStartCallback? onForcePressStart; + + /// Called when a pointer that had previously triggered [onForcePressStart] is + /// lifted off the screen. + final GestureForcePressEndCallback? onForcePressEnd; + + /// Called for a tap event with the secondary mouse button. + final GestureTapCallback? onSecondaryTap; + + /// Called for a tap down event with the secondary mouse button. + final GestureTapDownCallback? onSecondaryTapDown; + + /// Called for the first tap in a series of taps, consecutive taps do not call + /// this method. + /// + /// For example, if the detector was configured with [onTapDown] and + /// [onDoubleTapDown], three quick taps would be recognized as a single tap + /// down, followed by a tap up, then a double tap down, followed by a single tap down. + final GestureTapDragUpCallback? onSingleTapUp; + + /// Called for each touch that becomes recognized as a gesture that is not a + /// short tap, such as a long tap or drag. It is called at the moment when + /// another gesture from the touch is recognized. + final GestureCancelCallback? onSingleTapCancel; + + /// Called for the first tap in a series of taps when [onUserTapAlwaysCalled] is + /// disabled, which is the default behavior. + /// + /// When [onUserTapAlwaysCalled] is enabled, this is called for every tap, + /// including consecutive taps. + final GestureTapCallback? onUserTap; + + /// Called for a single long tap that's sustained for longer than + /// [kLongPressTimeout] but not necessarily lifted. Not called for a + /// double-tap-hold, which calls [onDoubleTapDown] instead. + final GestureLongPressStartCallback? onSingleLongTapStart; + + /// Called after [onSingleLongTapStart] when the pointer is dragged. + final GestureLongPressMoveUpdateCallback? onSingleLongTapMoveUpdate; + + /// Called after [onSingleLongTapStart] when the pointer is lifted. + final GestureLongPressEndCallback? onSingleLongTapEnd; + + /// Called after [onSingleLongTapStart] when the pointer is canceled. + final GestureLongPressCancelCallback? onSingleLongTapCancel; + + /// Called after a momentary hold or a short tap that is close in space and + /// time (within [kDoubleTapTimeout]) to a previous short tap. + final GestureTapDragDownCallback? onDoubleTapDown; + + /// Called after a momentary hold or a short tap that is close in space and + /// time (within [kDoubleTapTimeout]) to a previous double-tap. + final GestureTapDragDownCallback? onTripleTapDown; + + /// Called when a mouse starts dragging to select text. + final GestureTapDragStartCallback? onDragSelectionStart; + + /// Called repeatedly as a mouse moves while dragging. + final GestureTapDragUpdateCallback? onDragSelectionUpdate; + + /// Called when a mouse that was previously dragging is released. + final GestureTapDragEndCallback? onDragSelectionEnd; + + /// Whether [onUserTap] will be called for all taps including consecutive taps. + /// + /// Defaults to false, so [onUserTap] is only called for each distinct tap. + final bool onUserTapAlwaysCalled; + + /// How this gesture detector should behave during hit testing. + /// + /// This defaults to [HitTestBehavior.deferToChild]. + final HitTestBehavior? behavior; + + /// Child below this widget. + final Widget child; + + @override + State createState() => _TextSelectionGestureDetectorState(); +} + +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) { + widget.onSingleLongTapStart?.call(details); + } + + void _handleLongPressMoveUpdate(LongPressMoveUpdateDetails details) { + widget.onSingleLongTapMoveUpdate?.call(details); + } + + void _handleLongPressEnd(LongPressEndDetails details) { + widget.onSingleLongTapEnd?.call(details); + } + + void _handleLongPressCancel() { + widget.onSingleLongTapCancel?.call(); + } + + @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 || + widget.onSingleLongTapCancel != null) { + gestures[LongPressGestureRecognizer] = + GestureRecognizerFactoryWithHandlers( + () => LongPressGestureRecognizer( + debugOwner: this, + supportedDevices: {PointerDeviceKind.touch}, + ), + (LongPressGestureRecognizer instance) { + instance + ..onLongPressStart = _handleLongPressStart + ..onLongPressMoveUpdate = _handleLongPressMoveUpdate + ..onLongPressEnd = _handleLongPressEnd + ..onLongPressCancel = _handleLongPressCancel; + }, + ); + } + + 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/pages/video/introduction/ugc/view.dart b/lib/pages/video/introduction/ugc/view.dart index 5c6c2a2c1..e2db0013e 100644 --- a/lib/pages/video/introduction/ugc/view.dart +++ b/lib/pages/video/introduction/ugc/view.dart @@ -1,5 +1,6 @@ import 'package:PiliPlus/common/constants.dart'; import 'package:PiliPlus/common/widgets/dialog/dialog.dart'; +import 'package:PiliPlus/common/widgets/flutter/text_intro/text.dart'; import 'package:PiliPlus/common/widgets/gesture/tap_gesture_recognizer.dart'; import 'package:PiliPlus/common/widgets/image/network_img_layer.dart'; import 'package:PiliPlus/common/widgets/pendant_avatar.dart'; @@ -19,7 +20,6 @@ import 'package:PiliPlus/pages/video/introduction/ugc/controller.dart'; import 'package:PiliPlus/pages/video/introduction/ugc/widgets/action_item.dart'; import 'package:PiliPlus/pages/video/introduction/ugc/widgets/page.dart'; import 'package:PiliPlus/pages/video/introduction/ugc/widgets/season.dart'; -import 'package:PiliPlus/pages/video/introduction/ugc/widgets/selectable_text.dart'; import 'package:PiliPlus/utils/app_scheme.dart'; import 'package:PiliPlus/utils/date_utils.dart'; import 'package:PiliPlus/utils/duration_utils.dart'; diff --git a/lib/pages/video/introduction/ugc/widgets/selectable_text.dart b/lib/pages/video/introduction/ugc/widgets/selectable_text.dart index 235abda4f..7c8467980 100644 --- a/lib/pages/video/introduction/ugc/widgets/selectable_text.dart +++ b/lib/pages/video/introduction/ugc/widgets/selectable_text.dart @@ -19,22 +19,3 @@ Widget selectableText( scrollPhysics: const NeverScrollableScrollPhysics(), ); } - -Widget selectableRichText( - TextSpan textSpan, { - TextStyle? style, -}) { - if (PlatformUtils.isDesktop) { - return SelectionArea( - child: Text.rich( - style: style, - textSpan, - ), - ); - } - return SelectableText.rich( - style: style, - textSpan, - scrollPhysics: const NeverScrollableScrollPhysics(), - ); -}