From bb5bbcc069baff17fbe3d12e3f0debc3a98e87f0 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Sun, 12 Jan 2025 19:09:50 +0100 Subject: [PATCH] #1378 accessibility: apply system "touch and hold delay" setting --- CHANGELOG.md | 1 + .../channel/calls/AccessibilityHandler.kt | 6 + .../streams/SettingsChangeStreamHandler.kt | 11 + lib/model/settings/settings.dart | 9 + lib/services/accessibility_service.dart | 31 +- lib/widgets/aves_app.dart | 1 + .../quick_choosers/common/button.dart | 5 +- .../basic/draggable_scrollbar/scrollbar.dart | 5 +- .../basic/gestures/gesture_detector.dart | 992 +++++++++++++++ .../common/basic/gestures/ink_well.dart | 1095 +++++++++++++++++ lib/widgets/common/grid/header.dart | 4 +- lib/widgets/common/grid/selector.dart | 8 +- lib/widgets/common/identity/aves_app_bar.dart | 6 +- .../common/identity/aves_filter_chip.dart | 6 +- lib/widgets/common/map/leaflet/map.dart | 12 +- lib/widgets/debug/settings.dart | 1 + .../quick_actions/available_actions.dart | 2 + .../common/quick_actions/quick_actions.dart | 2 + plugins/aves_model/lib/src/settings/keys.dart | 3 + pubspec.yaml | 6 + 20 files changed, 2186 insertions(+), 20 deletions(-) create mode 100644 lib/widgets/common/basic/gestures/gesture_detector.dart create mode 100644 lib/widgets/common/basic/gestures/ink_well.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a534225e..602f99961 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ All notable changes to this project will be documented in this file. - Video: use `media-kit` instead of `ffmpeg-kit` for metadata fetch - Info: show video chapters +- Accessibility: apply system "touch and hold delay" setting ### Fixed diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AccessibilityHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AccessibilityHandler.kt index 248fdcce1..0e61c1fa3 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AccessibilityHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AccessibilityHandler.kt @@ -6,6 +6,7 @@ import android.content.res.Configuration import android.os.Build import android.provider.Settings import android.util.Log +import android.view.ViewConfiguration import android.view.accessibility.AccessibilityManager import deckers.thibault.aves.channel.calls.Coresult.Companion.safe import deckers.thibault.aves.utils.LogUtils @@ -17,6 +18,7 @@ class AccessibilityHandler(private val contextWrapper: ContextWrapper) : MethodC override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { when (call.method) { "areAnimationsRemoved" -> safe(call, result, ::areAnimationsRemoved) + "getLongPressTimeout" -> safe(call, result, ::getLongPressTimeout) "hasRecommendedTimeouts" -> safe(call, result, ::hasRecommendedTimeouts) "getRecommendedTimeoutMillis" -> safe(call, result, ::getRecommendedTimeoutMillis) "shouldUseBoldFont" -> safe(call, result, ::shouldUseBoldFont) @@ -34,6 +36,10 @@ class AccessibilityHandler(private val contextWrapper: ContextWrapper) : MethodC result.success(removed) } + private fun getLongPressTimeout(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) { + result.success(ViewConfiguration.getLongPressTimeout()) + } + private fun hasRecommendedTimeouts(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) { result.success(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/SettingsChangeStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/SettingsChangeStreamHandler.kt index 915f900c5..09ed1451f 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/SettingsChangeStreamHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/SettingsChangeStreamHandler.kt @@ -7,6 +7,7 @@ import android.os.Handler import android.os.Looper import android.provider.Settings import android.util.Log +import android.view.ViewConfiguration import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.utils.LogUtils import io.flutter.plugin.common.EventChannel @@ -21,6 +22,7 @@ class SettingsChangeStreamHandler(private val context: Context) : EventChannel.S private val contentObserver = object : ContentObserver(null) { private var accelerometerRotation: Int = 0 private var transitionAnimationScale: Float = 1f + private var longPressTimeoutMillis: Int = 0 init { update() @@ -36,6 +38,7 @@ class SettingsChangeStreamHandler(private val context: Context) : EventChannel.S hashMapOf( Settings.System.ACCELEROMETER_ROTATION to accelerometerRotation, Settings.Global.TRANSITION_ANIMATION_SCALE to transitionAnimationScale, + KEY_LONG_PRESS_TIMEOUT_MILLIS to longPressTimeoutMillis, ) ) } @@ -54,6 +57,11 @@ class SettingsChangeStreamHandler(private val context: Context) : EventChannel.S transitionAnimationScale = newTransitionAnimationScale changed = true } + val newLongPressTimeout = ViewConfiguration.getLongPressTimeout() + if (longPressTimeoutMillis != newLongPressTimeout) { + longPressTimeoutMillis = newLongPressTimeout + changed = true + } } catch (e: Exception) { Log.w(LOG_TAG, "failed to get settings with error=${e.message}", null) } @@ -93,5 +101,8 @@ class SettingsChangeStreamHandler(private val context: Context) : EventChannel.S companion object { private val LOG_TAG = LogUtils.createTag() const val CHANNEL = "deckers.thibault/aves/settings_change" + + // cf `Settings.Secure.LONG_PRESS_TIMEOUT` + const val KEY_LONG_PRESS_TIMEOUT_MILLIS = "long_press_timeout" } } \ No newline at end of file diff --git a/lib/model/settings/settings.dart b/lib/model/settings/settings.dart index 87963e50c..af1b205e0 100644 --- a/lib/model/settings/settings.dart +++ b/lib/model/settings/settings.dart @@ -34,6 +34,7 @@ import 'package:aves_video/aves_video.dart'; import 'package:collection/collection.dart'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/services.dart'; import 'package:latlong2/latlong.dart'; @@ -319,6 +320,10 @@ class Settings with ChangeNotifier, SettingsAccess, AppSettings, DisplaySettings if (value is num) { areAnimationsRemoved = value == 0; } + case SettingKeys.platformLongPressTimeoutMillisKey: + if (value is num) { + longPressTimeoutMillis = value.toInt(); + } } }); } @@ -331,6 +336,10 @@ class Settings with ChangeNotifier, SettingsAccess, AppSettings, DisplaySettings set areAnimationsRemoved(bool newValue) => set(SettingKeys.platformTransitionAnimationScaleKey, newValue); + Duration get longPressTimeout => Duration(milliseconds: getInt(SettingKeys.platformLongPressTimeoutMillisKey) ?? kLongPressTimeout.inMilliseconds); + + set longPressTimeoutMillis(int newValue) => set(SettingKeys.platformLongPressTimeoutMillisKey, newValue); + // import/export Map export() => Map.fromEntries( diff --git a/lib/services/accessibility_service.dart b/lib/services/accessibility_service.dart index 42a9f81f0..398927763 100644 --- a/lib/services/accessibility_service.dart +++ b/lib/services/accessibility_service.dart @@ -1,20 +1,11 @@ import 'package:aves/services/common/services.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/services.dart'; class AccessibilityService { static const _platform = MethodChannel('deckers.thibault/aves/accessibility'); - static Future shouldUseBoldFont() async { - try { - final result = await _platform.invokeMethod('shouldUseBoldFont'); - if (result != null) return result as bool; - } on PlatformException catch (e, stack) { - await reportService.recordError(e, stack); - } - return false; - } - static Future areAnimationsRemoved() async { try { final result = await _platform.invokeMethod('areAnimationsRemoved'); @@ -25,6 +16,16 @@ class AccessibilityService { return false; } + static Future getLongPressTimeout() async { + try { + final result = await _platform.invokeMethod('getLongPressTimeout'); + if (result != null) return result as int; + } on PlatformException catch (e, stack) { + await reportService.recordError(e, stack); + } + return kLongPressTimeout.inMilliseconds; + } + static bool? _hasRecommendedTimeouts; static Future hasRecommendedTimeouts() async { @@ -65,4 +66,14 @@ class AccessibilityService { } return originalTimeoutMillis; } + + static Future shouldUseBoldFont() async { + try { + final result = await _platform.invokeMethod('shouldUseBoldFont'); + if (result != null) return result as bool; + } on PlatformException catch (e, stack) { + await reportService.recordError(e, stack); + } + return false; + } } diff --git a/lib/widgets/aves_app.dart b/lib/widgets/aves_app.dart index e3cf6328c..19d924632 100644 --- a/lib/widgets/aves_app.dart +++ b/lib/widgets/aves_app.dart @@ -494,6 +494,7 @@ class _AvesAppState extends State with WidgetsBindingObserver { await mobileServices.init(); await settings.init(monitorPlatformSettings: true); settings.isRotationLocked = await windowService.isRotationLocked(); + settings.longPressTimeoutMillis = await AccessibilityService.getLongPressTimeout(); settings.areAnimationsRemoved = await AccessibilityService.areAnimationsRemoved(); await _onTvLayoutChanged(); _monitorSettings(); diff --git a/lib/widgets/common/action_controls/quick_choosers/common/button.dart b/lib/widgets/common/action_controls/quick_choosers/common/button.dart index 8ada58a26..fadcaa8f4 100644 --- a/lib/widgets/common/action_controls/quick_choosers/common/button.dart +++ b/lib/widgets/common/action_controls/quick_choosers/common/button.dart @@ -1,7 +1,9 @@ import 'dart:async'; +import 'package:aves/model/settings/settings.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/widgets/common/action_controls/quick_choosers/common/route_layout.dart'; +import 'package:aves/widgets/common/basic/gestures/gesture_detector.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -79,7 +81,7 @@ abstract class ChooserQuickButtonState, U> exten onSurfaceVariant: colorScheme.onSurface, ), ), - child: GestureDetector( + child: AGestureDetector( behavior: HitTestBehavior.opaque, onLongPressStart: _hasChooser ? _showChooser : null, onLongPressMoveUpdate: _hasChooser ? _moveUpdateStreamController.add : null, @@ -93,6 +95,7 @@ abstract class ChooserQuickButtonState, U> exten } : null, onLongPressCancel: _clearChooserOverlayEntry, + longPressTimeout: settings.longPressTimeout, child: child, ), ); diff --git a/lib/widgets/common/basic/draggable_scrollbar/scrollbar.dart b/lib/widgets/common/basic/draggable_scrollbar/scrollbar.dart index eb42528bb..245e2a6c3 100644 --- a/lib/widgets/common/basic/draggable_scrollbar/scrollbar.dart +++ b/lib/widgets/common/basic/draggable_scrollbar/scrollbar.dart @@ -1,8 +1,10 @@ import 'dart:async'; +import 'package:aves/model/settings/settings.dart'; import 'package:aves/widgets/common/basic/draggable_scrollbar/notifications.dart'; import 'package:aves/widgets/common/basic/draggable_scrollbar/scroll_label.dart'; import 'package:aves/widgets/common/basic/draggable_scrollbar/transition.dart'; +import 'package:aves/widgets/common/basic/gestures/gesture_detector.dart'; import 'package:flutter/widgets.dart'; /* @@ -221,7 +223,7 @@ class _DraggableScrollbarState extends State with TickerProv // exclude semantics, otherwise this layer will block access to content layers below when using TalkBack ExcludeSemantics( child: RepaintBoundary( - child: GestureDetector( + child: AGestureDetector( onLongPressStart: (details) { _longPressLastGlobalPosition = details.globalPosition; _onVerticalDragStart(); @@ -235,6 +237,7 @@ class _DraggableScrollbarState extends State with TickerProv onVerticalDragStart: (_) => _onVerticalDragStart(), onVerticalDragUpdate: (details) => _onVerticalDragUpdate(details.delta.dy), onVerticalDragEnd: (_) => _onVerticalDragEnd(), + longPressTimeout: settings.longPressTimeout, child: ValueListenableBuilder( valueListenable: _thumbOffsetNotifier, builder: (context, thumbOffset, child) => Container( diff --git a/lib/widgets/common/basic/gestures/gesture_detector.dart b/lib/widgets/common/basic/gestures/gesture_detector.dart new file mode 100644 index 000000000..f6dd5548c --- /dev/null +++ b/lib/widgets/common/basic/gestures/gesture_detector.dart @@ -0,0 +1,992 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/rendering.dart'; + +// as of Flutter v3.27.1, `GestureDetector` does not allow setting long press delay +// adapted from Flutter `GestureDetector` in `/widgets/gesture_detector.dart` +class AGestureDetector extends StatelessWidget { + /// Creates a widget that detects gestures. + /// + /// Pan and scale callbacks cannot be used simultaneously because scale is a + /// superset of pan. Use the scale callbacks instead. + /// + /// Horizontal and vertical drag callbacks cannot be used simultaneously + /// because a combination of a horizontal and vertical drag is a pan. + /// Use the pan callbacks instead. + /// + /// {@youtube 560 315 https://www.youtube.com/watch?v=WhVXkCFPmK4} + /// + /// By default, gesture detectors contribute semantic information to the tree + /// that is used by assistive technology. + AGestureDetector({ + super.key, + this.child, + this.onTapDown, + this.onTapUp, + this.onTap, + this.onTapCancel, + this.onSecondaryTap, + this.onSecondaryTapDown, + this.onSecondaryTapUp, + this.onSecondaryTapCancel, + this.onTertiaryTapDown, + this.onTertiaryTapUp, + this.onTertiaryTapCancel, + this.onDoubleTapDown, + this.onDoubleTap, + this.onDoubleTapCancel, + this.onLongPressDown, + this.onLongPressCancel, + this.onLongPress, + this.onLongPressStart, + this.onLongPressMoveUpdate, + this.onLongPressUp, + this.onLongPressEnd, + this.onSecondaryLongPressDown, + this.onSecondaryLongPressCancel, + this.onSecondaryLongPress, + this.onSecondaryLongPressStart, + this.onSecondaryLongPressMoveUpdate, + this.onSecondaryLongPressUp, + this.onSecondaryLongPressEnd, + this.onTertiaryLongPressDown, + this.onTertiaryLongPressCancel, + this.onTertiaryLongPress, + this.onTertiaryLongPressStart, + this.onTertiaryLongPressMoveUpdate, + this.onTertiaryLongPressUp, + this.onTertiaryLongPressEnd, + this.onVerticalDragDown, + this.onVerticalDragStart, + this.onVerticalDragUpdate, + this.onVerticalDragEnd, + this.onVerticalDragCancel, + this.onHorizontalDragDown, + this.onHorizontalDragStart, + this.onHorizontalDragUpdate, + this.onHorizontalDragEnd, + this.onHorizontalDragCancel, + this.onForcePressStart, + this.onForcePressPeak, + this.onForcePressUpdate, + this.onForcePressEnd, + this.onPanDown, + this.onPanStart, + this.onPanUpdate, + this.onPanEnd, + this.onPanCancel, + this.onScaleStart, + this.onScaleUpdate, + this.onScaleEnd, + this.behavior, + this.excludeFromSemantics = false, + this.dragStartBehavior = DragStartBehavior.start, + this.trackpadScrollCausesScale = false, + this.trackpadScrollToScaleFactor = kDefaultTrackpadScrollToScaleFactor, + this.supportedDevices, + this.longPressTimeout = kLongPressTimeout, + }) : assert(() { + final bool haveVerticalDrag = onVerticalDragStart != null || onVerticalDragUpdate != null || onVerticalDragEnd != null; + final bool haveHorizontalDrag = onHorizontalDragStart != null || onHorizontalDragUpdate != null || onHorizontalDragEnd != null; + final bool havePan = onPanStart != null || onPanUpdate != null || onPanEnd != null; + final bool haveScale = onScaleStart != null || onScaleUpdate != null || onScaleEnd != null; + if (havePan || haveScale) { + if (havePan && haveScale) { + throw FlutterError.fromParts([ + ErrorSummary('Incorrect GestureDetector arguments.'), + ErrorDescription( + 'Having both a pan gesture recognizer and a scale gesture recognizer is redundant; scale is a superset of pan.', + ), + ErrorHint('Just use the scale gesture recognizer.'), + ]); + } + final String recognizer = havePan ? 'pan' : 'scale'; + if (haveVerticalDrag && haveHorizontalDrag) { + throw FlutterError( + 'Incorrect GestureDetector arguments.\n' + 'Simultaneously having a vertical drag gesture recognizer, a horizontal drag gesture recognizer, and a $recognizer gesture recognizer ' + 'will result in the $recognizer gesture recognizer being ignored, since the other two will catch all drags.', + ); + } + } + return true; + }()); + + /// The widget below this widget in the tree. + /// + /// {@macro flutter.widgets.ProxyWidget.child} + final Widget? child; + + /// A pointer that might cause a tap with a primary button has contacted the + /// screen at a particular location. + /// + /// This is called after a short timeout, even if the winning gesture has not + /// yet been selected. If the tap gesture wins, [onTapUp] will be called, + /// otherwise [onTapCancel] will be called. + /// + /// See also: + /// + /// * [kPrimaryButton], the button this callback responds to. + final GestureTapDownCallback? onTapDown; + + /// A pointer that will trigger a tap with a primary button has stopped + /// contacting the screen at a particular location. + /// + /// This triggers immediately before [onTap] in the case of the tap gesture + /// winning. If the tap gesture did not win, [onTapCancel] is called instead. + /// + /// See also: + /// + /// * [kPrimaryButton], the button this callback responds to. + final GestureTapUpCallback? onTapUp; + + /// A tap with a primary button has occurred. + /// + /// This triggers when the tap gesture wins. If the tap gesture did not win, + /// [onTapCancel] is called instead. + /// + /// See also: + /// + /// * [kPrimaryButton], the button this callback responds to. + /// * [onTapUp], which is called at the same time but includes details + /// regarding the pointer position. + final GestureTapCallback? onTap; + + /// The pointer that previously triggered [onTapDown] will not end up causing + /// a tap. + /// + /// This is called after [onTapDown], and instead of [onTapUp] and [onTap], if + /// the tap gesture did not win. + /// + /// See also: + /// + /// * [kPrimaryButton], the button this callback responds to. + final GestureTapCancelCallback? onTapCancel; + + /// A tap with a secondary button has occurred. + /// + /// This triggers when the tap gesture wins. If the tap gesture did not win, + /// [onSecondaryTapCancel] is called instead. + /// + /// See also: + /// + /// * [kSecondaryButton], the button this callback responds to. + /// * [onSecondaryTapUp], which is called at the same time but includes details + /// regarding the pointer position. + final GestureTapCallback? onSecondaryTap; + + /// A pointer that might cause a tap with a secondary button has contacted the + /// screen at a particular location. + /// + /// This is called after a short timeout, even if the winning gesture has not + /// yet been selected. If the tap gesture wins, [onSecondaryTapUp] will be + /// called, otherwise [onSecondaryTapCancel] will be called. + /// + /// See also: + /// + /// * [kSecondaryButton], the button this callback responds to. + final GestureTapDownCallback? onSecondaryTapDown; + + /// A pointer that will trigger a tap with a secondary button has stopped + /// contacting the screen at a particular location. + /// + /// This triggers in the case of the tap gesture winning. If the tap gesture + /// did not win, [onSecondaryTapCancel] is called instead. + /// + /// See also: + /// + /// * [onSecondaryTap], a handler triggered right after this one that doesn't + /// pass any details about the tap. + /// * [kSecondaryButton], the button this callback responds to. + final GestureTapUpCallback? onSecondaryTapUp; + + /// The pointer that previously triggered [onSecondaryTapDown] will not end up + /// causing a tap. + /// + /// This is called after [onSecondaryTapDown], and instead of + /// [onSecondaryTapUp], if the tap gesture did not win. + /// + /// See also: + /// + /// * [kSecondaryButton], the button this callback responds to. + final GestureTapCancelCallback? onSecondaryTapCancel; + + /// A pointer that might cause a tap with a tertiary button has contacted the + /// screen at a particular location. + /// + /// This is called after a short timeout, even if the winning gesture has not + /// yet been selected. If the tap gesture wins, [onTertiaryTapUp] will be + /// called, otherwise [onTertiaryTapCancel] will be called. + /// + /// See also: + /// + /// * [kTertiaryButton], the button this callback responds to. + final GestureTapDownCallback? onTertiaryTapDown; + + /// A pointer that will trigger a tap with a tertiary button has stopped + /// contacting the screen at a particular location. + /// + /// This triggers in the case of the tap gesture winning. If the tap gesture + /// did not win, [onTertiaryTapCancel] is called instead. + /// + /// See also: + /// + /// * [kTertiaryButton], the button this callback responds to. + final GestureTapUpCallback? onTertiaryTapUp; + + /// The pointer that previously triggered [onTertiaryTapDown] will not end up + /// causing a tap. + /// + /// This is called after [onTertiaryTapDown], and instead of + /// [onTertiaryTapUp], if the tap gesture did not win. + /// + /// See also: + /// + /// * [kTertiaryButton], the button this callback responds to. + final GestureTapCancelCallback? onTertiaryTapCancel; + + /// A pointer that might cause a double tap has contacted the screen at a + /// particular location. + /// + /// Triggered immediately after the down event of the second tap. + /// + /// If the user completes the double tap and the gesture wins, [onDoubleTap] + /// will be called after this callback. Otherwise, [onDoubleTapCancel] will + /// be called after this callback. + /// + /// See also: + /// + /// * [kPrimaryButton], the button this callback responds to. + final GestureTapDownCallback? onDoubleTapDown; + + /// The user has tapped the screen with a primary button at the same location + /// twice in quick succession. + /// + /// See also: + /// + /// * [kPrimaryButton], the button this callback responds to. + final GestureTapCallback? onDoubleTap; + + /// The pointer that previously triggered [onDoubleTapDown] will not end up + /// causing a double tap. + /// + /// See also: + /// + /// * [kPrimaryButton], the button this callback responds to. + final GestureTapCancelCallback? onDoubleTapCancel; + + /// The pointer has contacted the screen with a primary button, which might + /// be the start of a long-press. + /// + /// This triggers after the pointer down event. + /// + /// If the user completes the long-press, and this gesture wins, + /// [onLongPressStart] will be called after this callback. Otherwise, + /// [onLongPressCancel] will be called after this callback. + /// + /// See also: + /// + /// * [kPrimaryButton], the button this callback responds to. + /// * [onSecondaryLongPressDown], a similar callback but for a secondary button. + /// * [onTertiaryLongPressDown], a similar callback but for a tertiary button. + /// * [LongPressGestureRecognizer.onLongPressDown], which exposes this + /// callback at the gesture layer. + final GestureLongPressDownCallback? onLongPressDown; + + /// A pointer that previously triggered [onLongPressDown] will not end up + /// causing a long-press. + /// + /// This triggers once the gesture loses if [onLongPressDown] has previously + /// been triggered. + /// + /// If the user completed the long-press, and the gesture won, then + /// [onLongPressStart] and [onLongPress] are called instead. + /// + /// See also: + /// + /// * [kPrimaryButton], the button this callback responds to. + /// * [LongPressGestureRecognizer.onLongPressCancel], which exposes this + /// callback at the gesture layer. + final GestureLongPressCancelCallback? onLongPressCancel; + + /// Called when a long press gesture with a primary button has been recognized. + /// + /// Triggered when a pointer has remained in contact with the screen at the + /// same location for a long period of time. + /// + /// This is equivalent to (and is called immediately after) [onLongPressStart]. + /// The only difference between the two is that this callback does not + /// contain details of the position at which the pointer initially contacted + /// the screen. + /// + /// See also: + /// + /// * [kPrimaryButton], the button this callback responds to. + /// * [LongPressGestureRecognizer.onLongPress], which exposes this + /// callback at the gesture layer. + final GestureLongPressCallback? onLongPress; + + /// Called when a long press gesture with a primary button has been recognized. + /// + /// Triggered when a pointer has remained in contact with the screen at the + /// same location for a long period of time. + /// + /// This is equivalent to (and is called immediately before) [onLongPress]. + /// The only difference between the two is that this callback contains + /// details of the position at which the pointer initially contacted the + /// screen, whereas [onLongPress] does not. + /// + /// See also: + /// + /// * [kPrimaryButton], the button this callback responds to. + /// * [LongPressGestureRecognizer.onLongPressStart], which exposes this + /// callback at the gesture layer. + final GestureLongPressStartCallback? onLongPressStart; + + /// A pointer has been drag-moved after a long-press with a primary button. + /// + /// See also: + /// + /// * [kPrimaryButton], the button this callback responds to. + /// * [LongPressGestureRecognizer.onLongPressMoveUpdate], which exposes this + /// callback at the gesture layer. + final GestureLongPressMoveUpdateCallback? onLongPressMoveUpdate; + + /// A pointer that has triggered a long-press with a primary button has + /// stopped contacting the screen. + /// + /// This is equivalent to (and is called immediately after) [onLongPressEnd]. + /// The only difference between the two is that this callback does not + /// contain details of the state of the pointer when it stopped contacting + /// the screen. + /// + /// See also: + /// + /// * [kPrimaryButton], the button this callback responds to. + /// * [LongPressGestureRecognizer.onLongPressUp], which exposes this + /// callback at the gesture layer. + final GestureLongPressUpCallback? onLongPressUp; + + /// A pointer that has triggered a long-press with a primary button has + /// stopped contacting the screen. + /// + /// This is equivalent to (and is called immediately before) [onLongPressUp]. + /// The only difference between the two is that this callback contains + /// details of the state of the pointer when it stopped contacting the + /// screen, whereas [onLongPressUp] does not. + /// + /// See also: + /// + /// * [kPrimaryButton], the button this callback responds to. + /// * [LongPressGestureRecognizer.onLongPressEnd], which exposes this + /// callback at the gesture layer. + final GestureLongPressEndCallback? onLongPressEnd; + + /// The pointer has contacted the screen with a secondary button, which might + /// be the start of a long-press. + /// + /// This triggers after the pointer down event. + /// + /// If the user completes the long-press, and this gesture wins, + /// [onSecondaryLongPressStart] will be called after this callback. Otherwise, + /// [onSecondaryLongPressCancel] will be called after this callback. + /// + /// See also: + /// + /// * [kSecondaryButton], the button this callback responds to. + /// * [onLongPressDown], a similar callback but for a secondary button. + /// * [onTertiaryLongPressDown], a similar callback but for a tertiary button. + /// * [LongPressGestureRecognizer.onSecondaryLongPressDown], which exposes + /// this callback at the gesture layer. + final GestureLongPressDownCallback? onSecondaryLongPressDown; + + /// A pointer that previously triggered [onSecondaryLongPressDown] will not + /// end up causing a long-press. + /// + /// This triggers once the gesture loses if [onSecondaryLongPressDown] has + /// previously been triggered. + /// + /// If the user completed the long-press, and the gesture won, then + /// [onSecondaryLongPressStart] and [onSecondaryLongPress] are called instead. + /// + /// See also: + /// + /// * [kSecondaryButton], the button this callback responds to. + /// * [LongPressGestureRecognizer.onSecondaryLongPressCancel], which exposes + /// this callback at the gesture layer. + final GestureLongPressCancelCallback? onSecondaryLongPressCancel; + + /// Called when a long press gesture with a secondary button has been + /// recognized. + /// + /// Triggered when a pointer has remained in contact with the screen at the + /// same location for a long period of time. + /// + /// This is equivalent to (and is called immediately after) + /// [onSecondaryLongPressStart]. The only difference between the two is that + /// this callback does not contain details of the position at which the + /// pointer initially contacted the screen. + /// + /// See also: + /// + /// * [kSecondaryButton], the button this callback responds to. + /// * [LongPressGestureRecognizer.onSecondaryLongPress], which exposes + /// this callback at the gesture layer. + final GestureLongPressCallback? onSecondaryLongPress; + + /// Called when a long press gesture with a secondary button has been + /// recognized. + /// + /// Triggered when a pointer has remained in contact with the screen at the + /// same location for a long period of time. + /// + /// This is equivalent to (and is called immediately before) + /// [onSecondaryLongPress]. The only difference between the two is that this + /// callback contains details of the position at which the pointer initially + /// contacted the screen, whereas [onSecondaryLongPress] does not. + /// + /// See also: + /// + /// * [kSecondaryButton], the button this callback responds to. + /// * [LongPressGestureRecognizer.onSecondaryLongPressStart], which exposes + /// this callback at the gesture layer. + final GestureLongPressStartCallback? onSecondaryLongPressStart; + + /// A pointer has been drag-moved after a long press with a secondary button. + /// + /// See also: + /// + /// * [kSecondaryButton], the button this callback responds to. + /// * [LongPressGestureRecognizer.onSecondaryLongPressMoveUpdate], which exposes + /// this callback at the gesture layer. + final GestureLongPressMoveUpdateCallback? onSecondaryLongPressMoveUpdate; + + /// A pointer that has triggered a long-press with a secondary button has + /// stopped contacting the screen. + /// + /// This is equivalent to (and is called immediately after) + /// [onSecondaryLongPressEnd]. The only difference between the two is that + /// this callback does not contain details of the state of the pointer when + /// it stopped contacting the screen. + /// + /// See also: + /// + /// * [kSecondaryButton], the button this callback responds to. + /// * [LongPressGestureRecognizer.onSecondaryLongPressUp], which exposes + /// this callback at the gesture layer. + final GestureLongPressUpCallback? onSecondaryLongPressUp; + + /// A pointer that has triggered a long-press with a secondary button has + /// stopped contacting the screen. + /// + /// This is equivalent to (and is called immediately before) + /// [onSecondaryLongPressUp]. The only difference between the two is that + /// this callback contains details of the state of the pointer when it + /// stopped contacting the screen, whereas [onSecondaryLongPressUp] does not. + /// + /// See also: + /// + /// * [kSecondaryButton], the button this callback responds to. + /// * [LongPressGestureRecognizer.onSecondaryLongPressEnd], which exposes + /// this callback at the gesture layer. + final GestureLongPressEndCallback? onSecondaryLongPressEnd; + + /// The pointer has contacted the screen with a tertiary button, which might + /// be the start of a long-press. + /// + /// This triggers after the pointer down event. + /// + /// If the user completes the long-press, and this gesture wins, + /// [onTertiaryLongPressStart] will be called after this callback. Otherwise, + /// [onTertiaryLongPressCancel] will be called after this callback. + /// + /// See also: + /// + /// * [kTertiaryButton], the button this callback responds to. + /// * [onLongPressDown], a similar callback but for a primary button. + /// * [onSecondaryLongPressDown], a similar callback but for a secondary button. + /// * [LongPressGestureRecognizer.onTertiaryLongPressDown], which exposes + /// this callback at the gesture layer. + final GestureLongPressDownCallback? onTertiaryLongPressDown; + + /// A pointer that previously triggered [onTertiaryLongPressDown] will not + /// end up causing a long-press. + /// + /// This triggers once the gesture loses if [onTertiaryLongPressDown] has + /// previously been triggered. + /// + /// If the user completed the long-press, and the gesture won, then + /// [onTertiaryLongPressStart] and [onTertiaryLongPress] are called instead. + /// + /// See also: + /// + /// * [kTertiaryButton], the button this callback responds to. + /// * [LongPressGestureRecognizer.onTertiaryLongPressCancel], which exposes + /// this callback at the gesture layer. + final GestureLongPressCancelCallback? onTertiaryLongPressCancel; + + /// Called when a long press gesture with a tertiary button has been + /// recognized. + /// + /// Triggered when a pointer has remained in contact with the screen at the + /// same location for a long period of time. + /// + /// This is equivalent to (and is called immediately after) + /// [onTertiaryLongPressStart]. The only difference between the two is that + /// this callback does not contain details of the position at which the + /// pointer initially contacted the screen. + /// + /// See also: + /// + /// * [kTertiaryButton], the button this callback responds to. + /// * [LongPressGestureRecognizer.onTertiaryLongPress], which exposes + /// this callback at the gesture layer. + final GestureLongPressCallback? onTertiaryLongPress; + + /// Called when a long press gesture with a tertiary button has been + /// recognized. + /// + /// Triggered when a pointer has remained in contact with the screen at the + /// same location for a long period of time. + /// + /// This is equivalent to (and is called immediately before) + /// [onTertiaryLongPress]. The only difference between the two is that this + /// callback contains details of the position at which the pointer initially + /// contacted the screen, whereas [onTertiaryLongPress] does not. + /// + /// See also: + /// + /// * [kTertiaryButton], the button this callback responds to. + /// * [LongPressGestureRecognizer.onTertiaryLongPressStart], which exposes + /// this callback at the gesture layer. + final GestureLongPressStartCallback? onTertiaryLongPressStart; + + /// A pointer has been drag-moved after a long press with a tertiary button. + /// + /// See also: + /// + /// * [kTertiaryButton], the button this callback responds to. + /// * [LongPressGestureRecognizer.onTertiaryLongPressMoveUpdate], which exposes + /// this callback at the gesture layer. + final GestureLongPressMoveUpdateCallback? onTertiaryLongPressMoveUpdate; + + /// A pointer that has triggered a long-press with a tertiary button has + /// stopped contacting the screen. + /// + /// This is equivalent to (and is called immediately after) + /// [onTertiaryLongPressEnd]. The only difference between the two is that + /// this callback does not contain details of the state of the pointer when + /// it stopped contacting the screen. + /// + /// See also: + /// + /// * [kTertiaryButton], the button this callback responds to. + /// * [LongPressGestureRecognizer.onTertiaryLongPressUp], which exposes + /// this callback at the gesture layer. + final GestureLongPressUpCallback? onTertiaryLongPressUp; + + /// A pointer that has triggered a long-press with a tertiary button has + /// stopped contacting the screen. + /// + /// This is equivalent to (and is called immediately before) + /// [onTertiaryLongPressUp]. The only difference between the two is that + /// this callback contains details of the state of the pointer when it + /// stopped contacting the screen, whereas [onTertiaryLongPressUp] does not. + /// + /// See also: + /// + /// * [kTertiaryButton], the button this callback responds to. + /// * [LongPressGestureRecognizer.onTertiaryLongPressEnd], which exposes + /// this callback at the gesture layer. + final GestureLongPressEndCallback? onTertiaryLongPressEnd; + + /// A pointer has contacted the screen with a primary button and might begin + /// to move vertically. + /// + /// See also: + /// + /// * [kPrimaryButton], the button this callback responds to. + final GestureDragDownCallback? onVerticalDragDown; + + /// A pointer has contacted the screen with a primary button and has begun to + /// move vertically. + /// + /// See also: + /// + /// * [kPrimaryButton], the button this callback responds to. + final GestureDragStartCallback? onVerticalDragStart; + + /// A pointer that is in contact with the screen with a primary button and + /// moving vertically has moved in the vertical direction. + /// + /// See also: + /// + /// * [kPrimaryButton], the button this callback responds to. + final GestureDragUpdateCallback? onVerticalDragUpdate; + + /// A pointer that was previously in contact with the screen with a primary + /// button and moving vertically is no longer in contact with the screen and + /// was moving at a specific velocity when it stopped contacting the screen. + /// + /// See also: + /// + /// * [kPrimaryButton], the button this callback responds to. + final GestureDragEndCallback? onVerticalDragEnd; + + /// The pointer that previously triggered [onVerticalDragDown] did not + /// complete. + /// + /// See also: + /// + /// * [kPrimaryButton], the button this callback responds to. + final GestureDragCancelCallback? onVerticalDragCancel; + + /// A pointer has contacted the screen with a primary button and might begin + /// to move horizontally. + /// + /// See also: + /// + /// * [kPrimaryButton], the button this callback responds to. + final GestureDragDownCallback? onHorizontalDragDown; + + /// A pointer has contacted the screen with a primary button and has begun to + /// move horizontally. + /// + /// See also: + /// + /// * [kPrimaryButton], the button this callback responds to. + final GestureDragStartCallback? onHorizontalDragStart; + + /// A pointer that is in contact with the screen with a primary button and + /// moving horizontally has moved in the horizontal direction. + /// + /// See also: + /// + /// * [kPrimaryButton], the button this callback responds to. + final GestureDragUpdateCallback? onHorizontalDragUpdate; + + /// A pointer that was previously in contact with the screen with a primary + /// button and moving horizontally is no longer in contact with the screen and + /// was moving at a specific velocity when it stopped contacting the screen. + /// + /// See also: + /// + /// * [kPrimaryButton], the button this callback responds to. + final GestureDragEndCallback? onHorizontalDragEnd; + + /// The pointer that previously triggered [onHorizontalDragDown] did not + /// complete. + /// + /// See also: + /// + /// * [kPrimaryButton], the button this callback responds to. + final GestureDragCancelCallback? onHorizontalDragCancel; + + /// A pointer has contacted the screen with a primary button and might begin + /// to move. + /// + /// See also: + /// + /// * [kPrimaryButton], the button this callback responds to. + final GestureDragDownCallback? onPanDown; + + /// A pointer has contacted the screen with a primary button and has begun to + /// move. + /// + /// See also: + /// + /// * [kPrimaryButton], the button this callback responds to. + final GestureDragStartCallback? onPanStart; + + /// A pointer that is in contact with the screen with a primary button and + /// moving has moved again. + /// + /// See also: + /// + /// * [kPrimaryButton], the button this callback responds to. + final GestureDragUpdateCallback? onPanUpdate; + + /// A pointer that was previously in contact with the screen with a primary + /// button and moving is no longer in contact with the screen and was moving + /// at a specific velocity when it stopped contacting the screen. + /// + /// See also: + /// + /// * [kPrimaryButton], the button this callback responds to. + final GestureDragEndCallback? onPanEnd; + + /// The pointer that previously triggered [onPanDown] did not complete. + /// + /// See also: + /// + /// * [kPrimaryButton], the button this callback responds to. + final GestureDragCancelCallback? onPanCancel; + + /// The pointers in contact with the screen have established a focal point and + /// initial scale of 1.0. + final GestureScaleStartCallback? onScaleStart; + + /// The pointers in contact with the screen have indicated a new focal point + /// and/or scale. + final GestureScaleUpdateCallback? onScaleUpdate; + + /// The pointers are no longer in contact with the screen. + final GestureScaleEndCallback? onScaleEnd; + + /// The pointer is in contact with the screen and has pressed with sufficient + /// force to initiate a force press. The amount of force is at least + /// [ForcePressGestureRecognizer.startPressure]. + /// + /// This callback will only be fired on devices with pressure + /// detecting screens. + final GestureForcePressStartCallback? onForcePressStart; + + /// The pointer is in contact with the screen and has pressed with the maximum + /// force. The amount of force is at least + /// [ForcePressGestureRecognizer.peakPressure]. + /// + /// This callback will only be fired on devices with pressure + /// detecting screens. + final GestureForcePressPeakCallback? onForcePressPeak; + + /// A pointer is in contact with the screen, has previously passed the + /// [ForcePressGestureRecognizer.startPressure] and is either moving on the + /// plane of the screen, pressing the screen with varying forces or both + /// simultaneously. + /// + /// This callback will only be fired on devices with pressure + /// detecting screens. + final GestureForcePressUpdateCallback? onForcePressUpdate; + + /// The pointer tracked by [onForcePressStart] is no longer in contact with the screen. + /// + /// This callback will only be fired on devices with pressure + /// detecting screens. + final GestureForcePressEndCallback? onForcePressEnd; + + /// How this gesture detector should behave during hit testing when deciding + /// how the hit test propagates to children and whether to consider targets + /// behind this one. + /// + /// This defaults to [HitTestBehavior.deferToChild] if [child] is not null and + /// [HitTestBehavior.translucent] if child is null. + /// + /// See [HitTestBehavior] for the allowed values and their meanings. + final HitTestBehavior? behavior; + + /// Whether to exclude these gestures from the semantics tree. For + /// example, the long-press gesture for showing a tooltip is + /// excluded because the tooltip itself is included in the semantics + /// tree directly and so having a gesture to show it would result in + /// duplication of information. + final bool excludeFromSemantics; + + /// Determines the way that drag start behavior is handled. + /// + /// If set to [DragStartBehavior.start], gesture drag behavior will + /// begin at the position where the drag gesture won the arena. If set to + /// [DragStartBehavior.down] it will begin at the position where a down event + /// is first detected. + /// + /// In general, setting this to [DragStartBehavior.start] will make drag + /// animation smoother and setting it to [DragStartBehavior.down] will make + /// drag behavior feel slightly more reactive. + /// + /// By default, the drag start behavior is [DragStartBehavior.start]. + /// + /// Only the [DragGestureRecognizer.onStart] callbacks for the + /// [VerticalDragGestureRecognizer], [HorizontalDragGestureRecognizer] and + /// [PanGestureRecognizer] are affected by this setting. + /// + /// See also: + /// + /// * [DragGestureRecognizer.dragStartBehavior], which gives an example for the different behaviors. + final DragStartBehavior dragStartBehavior; + + /// The kind of devices that are allowed to be recognized. + /// + /// If set to null, events from all device types will be recognized. Defaults to null. + final Set? supportedDevices; + + /// {@macro flutter.gestures.scale.trackpadScrollCausesScale} + final bool trackpadScrollCausesScale; + + /// {@macro flutter.gestures.scale.trackpadScrollToScaleFactor} + final Offset trackpadScrollToScaleFactor; + + final Duration longPressTimeout; + + @override + Widget build(BuildContext context) { + final Map gestures = {}; + final DeviceGestureSettings? gestureSettings = MediaQuery.maybeGestureSettingsOf(context); + final ScrollBehavior configuration = ScrollConfiguration.of(context); + + if (onTapDown != null || onTapUp != null || onTap != null || onTapCancel != null || onSecondaryTap != null || onSecondaryTapDown != null || onSecondaryTapUp != null || onSecondaryTapCancel != null || onTertiaryTapDown != null || onTertiaryTapUp != null || onTertiaryTapCancel != null) { + gestures[TapGestureRecognizer] = GestureRecognizerFactoryWithHandlers( + () => TapGestureRecognizer(debugOwner: this, supportedDevices: supportedDevices), + (instance) { + instance + ..onTapDown = onTapDown + ..onTapUp = onTapUp + ..onTap = onTap + ..onTapCancel = onTapCancel + ..onSecondaryTap = onSecondaryTap + ..onSecondaryTapDown = onSecondaryTapDown + ..onSecondaryTapUp = onSecondaryTapUp + ..onSecondaryTapCancel = onSecondaryTapCancel + ..onTertiaryTapDown = onTertiaryTapDown + ..onTertiaryTapUp = onTertiaryTapUp + ..onTertiaryTapCancel = onTertiaryTapCancel + ..gestureSettings = gestureSettings + ..supportedDevices = supportedDevices; + }, + ); + } + + if (onDoubleTap != null || onDoubleTapDown != null || onDoubleTapCancel != null) { + gestures[DoubleTapGestureRecognizer] = GestureRecognizerFactoryWithHandlers( + () => DoubleTapGestureRecognizer(debugOwner: this, supportedDevices: supportedDevices), + (instance) { + instance + ..onDoubleTapDown = onDoubleTapDown + ..onDoubleTap = onDoubleTap + ..onDoubleTapCancel = onDoubleTapCancel + ..gestureSettings = gestureSettings + ..supportedDevices = supportedDevices; + }, + ); + } + + if (onLongPressDown != null || onLongPressCancel != null || onLongPress != null || onLongPressStart != null || onLongPressMoveUpdate != null || onLongPressUp != null || onLongPressEnd != null || onSecondaryLongPressDown != null || onSecondaryLongPressCancel != null || onSecondaryLongPress != null || onSecondaryLongPressStart != null || onSecondaryLongPressMoveUpdate != null || onSecondaryLongPressUp != null || onSecondaryLongPressEnd != null || onTertiaryLongPressDown != null || onTertiaryLongPressCancel != null || onTertiaryLongPress != null || onTertiaryLongPressStart != null || onTertiaryLongPressMoveUpdate != null || onTertiaryLongPressUp != null || onTertiaryLongPressEnd != null) { + gestures[LongPressGestureRecognizer] = GestureRecognizerFactoryWithHandlers( + () => LongPressGestureRecognizer(duration: longPressTimeout, debugOwner: this, supportedDevices: supportedDevices), + (instance) { + instance + ..onLongPressDown = onLongPressDown + ..onLongPressCancel = onLongPressCancel + ..onLongPress = onLongPress + ..onLongPressStart = onLongPressStart + ..onLongPressMoveUpdate = onLongPressMoveUpdate + ..onLongPressUp = onLongPressUp + ..onLongPressEnd = onLongPressEnd + ..onSecondaryLongPressDown = onSecondaryLongPressDown + ..onSecondaryLongPressCancel = onSecondaryLongPressCancel + ..onSecondaryLongPress = onSecondaryLongPress + ..onSecondaryLongPressStart = onSecondaryLongPressStart + ..onSecondaryLongPressMoveUpdate = onSecondaryLongPressMoveUpdate + ..onSecondaryLongPressUp = onSecondaryLongPressUp + ..onSecondaryLongPressEnd = onSecondaryLongPressEnd + ..onTertiaryLongPressDown = onTertiaryLongPressDown + ..onTertiaryLongPressCancel = onTertiaryLongPressCancel + ..onTertiaryLongPress = onTertiaryLongPress + ..onTertiaryLongPressStart = onTertiaryLongPressStart + ..onTertiaryLongPressMoveUpdate = onTertiaryLongPressMoveUpdate + ..onTertiaryLongPressUp = onTertiaryLongPressUp + ..onTertiaryLongPressEnd = onTertiaryLongPressEnd + ..gestureSettings = gestureSettings + ..supportedDevices = supportedDevices; + }, + ); + } + + if (onVerticalDragDown != null || onVerticalDragStart != null || onVerticalDragUpdate != null || onVerticalDragEnd != null || onVerticalDragCancel != null) { + gestures[VerticalDragGestureRecognizer] = GestureRecognizerFactoryWithHandlers( + () => VerticalDragGestureRecognizer(debugOwner: this, supportedDevices: supportedDevices), + (instance) { + instance + ..onDown = onVerticalDragDown + ..onStart = onVerticalDragStart + ..onUpdate = onVerticalDragUpdate + ..onEnd = onVerticalDragEnd + ..onCancel = onVerticalDragCancel + ..dragStartBehavior = dragStartBehavior + ..multitouchDragStrategy = configuration.getMultitouchDragStrategy(context) + ..gestureSettings = gestureSettings + ..supportedDevices = supportedDevices; + }, + ); + } + + if (onHorizontalDragDown != null || onHorizontalDragStart != null || onHorizontalDragUpdate != null || onHorizontalDragEnd != null || onHorizontalDragCancel != null) { + gestures[HorizontalDragGestureRecognizer] = GestureRecognizerFactoryWithHandlers( + () => HorizontalDragGestureRecognizer(debugOwner: this, supportedDevices: supportedDevices), + (instance) { + instance + ..onDown = onHorizontalDragDown + ..onStart = onHorizontalDragStart + ..onUpdate = onHorizontalDragUpdate + ..onEnd = onHorizontalDragEnd + ..onCancel = onHorizontalDragCancel + ..dragStartBehavior = dragStartBehavior + ..multitouchDragStrategy = configuration.getMultitouchDragStrategy(context) + ..gestureSettings = gestureSettings + ..supportedDevices = supportedDevices; + }, + ); + } + + if (onPanDown != null || onPanStart != null || onPanUpdate != null || onPanEnd != null || onPanCancel != null) { + gestures[PanGestureRecognizer] = GestureRecognizerFactoryWithHandlers( + () => PanGestureRecognizer(debugOwner: this, supportedDevices: supportedDevices), + (instance) { + instance + ..onDown = onPanDown + ..onStart = onPanStart + ..onUpdate = onPanUpdate + ..onEnd = onPanEnd + ..onCancel = onPanCancel + ..dragStartBehavior = dragStartBehavior + ..multitouchDragStrategy = configuration.getMultitouchDragStrategy(context) + ..gestureSettings = gestureSettings + ..supportedDevices = supportedDevices; + }, + ); + } + + if (onScaleStart != null || onScaleUpdate != null || onScaleEnd != null) { + gestures[ScaleGestureRecognizer] = GestureRecognizerFactoryWithHandlers( + () => ScaleGestureRecognizer(debugOwner: this, supportedDevices: supportedDevices), + (instance) { + instance + ..onStart = onScaleStart + ..onUpdate = onScaleUpdate + ..onEnd = onScaleEnd + ..dragStartBehavior = dragStartBehavior + ..gestureSettings = gestureSettings + ..trackpadScrollCausesScale = trackpadScrollCausesScale + ..trackpadScrollToScaleFactor = trackpadScrollToScaleFactor + ..supportedDevices = supportedDevices; + }, + ); + } + + if (onForcePressStart != null || onForcePressPeak != null || onForcePressUpdate != null || onForcePressEnd != null) { + gestures[ForcePressGestureRecognizer] = GestureRecognizerFactoryWithHandlers( + () => ForcePressGestureRecognizer(debugOwner: this, supportedDevices: supportedDevices), + (instance) { + instance + ..onStart = onForcePressStart + ..onPeak = onForcePressPeak + ..onUpdate = onForcePressUpdate + ..onEnd = onForcePressEnd + ..gestureSettings = gestureSettings + ..supportedDevices = supportedDevices; + }, + ); + } + + return RawGestureDetector( + gestures: gestures, + behavior: behavior, + excludeFromSemantics: excludeFromSemantics, + child: child, + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(EnumProperty('startBehavior', dragStartBehavior)); + } +} diff --git a/lib/widgets/common/basic/gestures/ink_well.dart b/lib/widgets/common/basic/gestures/ink_well.dart new file mode 100644 index 000000000..ffc21b4cc --- /dev/null +++ b/lib/widgets/common/basic/gestures/ink_well.dart @@ -0,0 +1,1095 @@ +import 'dart:async'; +import 'dart:collection'; + +import 'package:aves/widgets/common/basic/gestures/gesture_detector.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +// as of Flutter v3.27.1, `InkResponse` does not allow setting long press delay +// adapted from Flutter `InkResponse` and related classes in `/material/ink_well.dart` +class AInkResponse extends StatelessWidget { + /// Creates an area of a [Material] that responds to touch. + /// + /// Must have an ancestor [Material] widget in which to cause ink reactions. + const AInkResponse({ + super.key, + this.child, + this.onTap, + this.onTapDown, + this.onTapUp, + this.onTapCancel, + this.onDoubleTap, + this.onLongPress, + this.onSecondaryTap, + this.onSecondaryTapUp, + this.onSecondaryTapDown, + this.onSecondaryTapCancel, + this.onHighlightChanged, + this.onHover, + this.mouseCursor, + this.containedInkWell = false, + this.highlightShape = BoxShape.circle, + this.radius, + this.borderRadius, + this.customBorder, + this.focusColor, + this.hoverColor, + this.highlightColor, + this.overlayColor, + this.splashColor, + this.splashFactory, + this.enableFeedback = true, + this.excludeFromSemantics = false, + this.focusNode, + this.canRequestFocus = true, + this.onFocusChange, + this.autofocus = false, + this.statesController, + this.hoverDuration, + this.longPressTimeout = kLongPressTimeout, + }); + + /// The widget below this widget in the tree. + /// + /// {@macro flutter.widgets.ProxyWidget.child} + final Widget? child; + + /// Called when the user taps this part of the material. + final GestureTapCallback? onTap; + + /// Called when the user taps down this part of the material. + final GestureTapDownCallback? onTapDown; + + /// Called when the user releases a tap that was started on this part of the + /// material. [onTap] is called immediately after. + final GestureTapUpCallback? onTapUp; + + /// Called when the user cancels a tap that was started on this part of the + /// material. + final GestureTapCallback? onTapCancel; + + /// Called when the user double taps this part of the material. + final GestureTapCallback? onDoubleTap; + + /// Called when the user long-presses on this part of the material. + final GestureLongPressCallback? onLongPress; + + /// Called when the user taps this part of the material with a secondary button. + final GestureTapCallback? onSecondaryTap; + + /// Called when the user taps down on this part of the material with a + /// secondary button. + final GestureTapDownCallback? onSecondaryTapDown; + + /// Called when the user releases a secondary button tap that was started on + /// this part of the material. [onSecondaryTap] is called immediately after. + final GestureTapUpCallback? onSecondaryTapUp; + + /// Called when the user cancels a secondary button tap that was started on + /// this part of the material. + final GestureTapCallback? onSecondaryTapCancel; + + /// Called when this part of the material either becomes highlighted or stops + /// being highlighted. + /// + /// The value passed to the callback is true if this part of the material has + /// become highlighted and false if this part of the material has stopped + /// being highlighted. + /// + /// If all of [onTap], [onDoubleTap], and [onLongPress] become null while a + /// gesture is ongoing, then [onTapCancel] will be fired and + /// [onHighlightChanged] will be fired with the value false _during the + /// build_. This means, for instance, that in that scenario [State.setState] + /// cannot be called. + final ValueChanged? onHighlightChanged; + + /// Called when a pointer enters or exits the ink response area. + /// + /// The value passed to the callback is true if a pointer has entered this + /// part of the material and false if a pointer has exited this part of the + /// material. + final ValueChanged? onHover; + + /// The cursor for a mouse pointer when it enters or is hovering over the + /// widget. + /// + /// If [mouseCursor] is a [WidgetStateProperty], + /// [WidgetStateProperty.resolve] is used for the following [WidgetState]s: + /// + /// * [WidgetState.hovered]. + /// * [WidgetState.focused]. + /// * [WidgetState.disabled]. + /// + /// If this property is null, [WidgetStateMouseCursor.clickable] will be used. + final MouseCursor? mouseCursor; + + /// Whether this ink response should be clipped its bounds. + /// + /// This flag also controls whether the splash migrates to the center of the + /// [InkResponse] or not. If [containedInkWell] is true, the splash remains + /// centered around the tap location. If it is false, the splash migrates to + /// the center of the [InkResponse] as it grows. + /// + /// See also: + /// + /// * [highlightShape], the shape of the focus, hover, and pressed + /// highlights. + /// * [borderRadius], which controls the corners when the box is a rectangle. + /// * [getRectCallback], which controls the size and position of the box when + /// it is a rectangle. + final bool containedInkWell; + + /// The shape (e.g., circle, rectangle) to use for the highlight drawn around + /// this part of the material when pressed, hovered over, or focused. + /// + /// The same shape is used for the pressed highlight (see [highlightColor]), + /// the focus highlight (see [focusColor]), and the hover highlight (see + /// [hoverColor]). + /// + /// If the shape is [BoxShape.circle], then the highlight is centered on the + /// [InkResponse]. If the shape is [BoxShape.rectangle], then the highlight + /// fills the [InkResponse], or the rectangle provided by [getRectCallback] if + /// the callback is specified. + /// + /// See also: + /// + /// * [containedInkWell], which controls clipping behavior. + /// * [borderRadius], which controls the corners when the box is a rectangle. + /// * [highlightColor], the color of the highlight. + /// * [getRectCallback], which controls the size and position of the box when + /// it is a rectangle. + final BoxShape highlightShape; + + /// The radius of the ink splash. + /// + /// Splashes grow up to this size. By default, this size is determined from + /// the size of the rectangle provided by [getRectCallback], or the size of + /// the [InkResponse] itself. + /// + /// See also: + /// + /// * [splashColor], the color of the splash. + /// * [splashFactory], which defines the appearance of the splash. + final double? radius; + + /// The border radius of the containing rectangle. This is effective only if + /// [highlightShape] is [BoxShape.rectangle]. + /// + /// If this is null, it is interpreted as [BorderRadius.zero]. + final BorderRadius? borderRadius; + + /// The custom clip border. + /// + /// If this is null, the ink response will not clip its content. + final ShapeBorder? customBorder; + + /// The color of the ink response when the parent widget is focused. If this + /// property is null then the focus color of the theme, + /// [ThemeData.focusColor], will be used. + /// + /// See also: + /// + /// * [highlightShape], the shape of the focus, hover, and pressed + /// highlights. + /// * [hoverColor], the color of the hover highlight. + /// * [splashColor], the color of the splash. + /// * [splashFactory], which defines the appearance of the splash. + final Color? focusColor; + + /// The color of the ink response when a pointer is hovering over it. If this + /// property is null then the hover color of the theme, + /// [ThemeData.hoverColor], will be used. + /// + /// See also: + /// + /// * [highlightShape], the shape of the focus, hover, and pressed + /// highlights. + /// * [highlightColor], the color of the pressed highlight. + /// * [focusColor], the color of the focus highlight. + /// * [splashColor], the color of the splash. + /// * [splashFactory], which defines the appearance of the splash. + final Color? hoverColor; + + /// The highlight color of the ink response when pressed. If this property is + /// null then the highlight color of the theme, [ThemeData.highlightColor], + /// will be used. + /// + /// See also: + /// + /// * [hoverColor], the color of the hover highlight. + /// * [focusColor], the color of the focus highlight. + /// * [highlightShape], the shape of the focus, hover, and pressed + /// highlights. + /// * [splashColor], the color of the splash. + /// * [splashFactory], which defines the appearance of the splash. + final Color? highlightColor; + + /// Defines the ink response focus, hover, and splash colors. + /// + /// This default null property can be used as an alternative to + /// [focusColor], [hoverColor], [highlightColor], and + /// [splashColor]. If non-null, it is resolved against one of + /// [WidgetState.focused], [WidgetState.hovered], and + /// [WidgetState.pressed]. It's convenient to use when the parent + /// widget can pass along its own WidgetStateProperty value for + /// the overlay color. + /// + /// [WidgetState.pressed] triggers a ripple (an ink splash), per + /// the current Material Design spec. The [overlayColor] doesn't map + /// a state to [highlightColor] because a separate highlight is not + /// used by the current design guidelines. See + /// https://material.io/design/interaction/states.html#pressed + /// + /// If the overlay color is null or resolves to null, then [focusColor], + /// [hoverColor], [splashColor] and their defaults are used instead. + /// + /// See also: + /// + /// * The Material Design specification for overlay colors and how they + /// match a component's state: + /// . + final WidgetStateProperty? overlayColor; + + /// The splash color of the ink response. If this property is null then the + /// splash color of the theme, [ThemeData.splashColor], will be used. + /// + /// See also: + /// + /// * [splashFactory], which defines the appearance of the splash. + /// * [radius], the (maximum) size of the ink splash. + /// * [highlightColor], the color of the highlight. + final Color? splashColor; + + /// Defines the appearance of the splash. + /// + /// Defaults to the value of the theme's splash factory: [ThemeData.splashFactory]. + /// + /// See also: + /// + /// * [radius], the (maximum) size of the ink splash. + /// * [splashColor], the color of the splash. + /// * [highlightColor], the color of the highlight. + /// * [InkSplash.splashFactory], which defines the default splash. + /// * [InkRipple.splashFactory], which defines a splash that spreads out + /// more aggressively than the default. + final InteractiveInkFeatureFactory? splashFactory; + + /// Whether detected gestures should provide acoustic and/or haptic feedback. + /// + /// For example, on Android a tap will produce a clicking sound and a + /// long-press will produce a short vibration, when feedback is enabled. + /// + /// See also: + /// + /// * [Feedback] for providing platform-specific feedback to certain actions. + final bool enableFeedback; + + /// Whether to exclude the gestures introduced by this widget from the + /// semantics tree. + /// + /// For example, a long-press gesture for showing a tooltip is usually + /// excluded because the tooltip itself is included in the semantics + /// tree directly and so having a gesture to show it would result in + /// duplication of information. + final bool excludeFromSemantics; + + /// {@template flutter.material.inkwell.onFocusChange} + /// Handler called when the focus changes. + /// + /// Called with true if this widget's node gains focus, and false if it loses + /// focus. + /// {@endtemplate} + final ValueChanged? onFocusChange; + + /// {@macro flutter.widgets.Focus.autofocus} + final bool autofocus; + + /// {@macro flutter.widgets.Focus.focusNode} + final FocusNode? focusNode; + + /// {@macro flutter.widgets.Focus.canRequestFocus} + final bool canRequestFocus; + + /// The rectangle to use for the highlight effect and for clipping + /// the splash effects if [containedInkWell] is true. + /// + /// This method is intended to be overridden by descendants that + /// specialize [InkResponse] for unusual cases. For example, + /// [TableRowInkWell] implements this method to return the rectangle + /// corresponding to the row that the widget is in. + /// + /// The default behavior returns null, which is equivalent to + /// returning the referenceBox argument's bounding box (though + /// slightly more efficient). + RectCallback? getRectCallback(RenderBox referenceBox) => null; + + /// {@template flutter.material.inkwell.statesController} + /// Represents the interactive "state" of this widget in terms of + /// a set of [WidgetState]s, like [WidgetState.pressed] 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. + /// {@endtemplate} + final WidgetStatesController? statesController; + + /// The duration of the animation that animates the hover effect. + /// + /// The default is 50ms. + final Duration? hoverDuration; + + final Duration longPressTimeout; + + @override + Widget build(BuildContext context) { + final _ParentInkResponseState? parentState = _ParentInkResponseProvider.maybeOf(context); + return _InkResponseStateWidget( + onTap: onTap, + onTapDown: onTapDown, + onTapUp: onTapUp, + onTapCancel: onTapCancel, + onDoubleTap: onDoubleTap, + onLongPress: onLongPress, + onSecondaryTap: onSecondaryTap, + onSecondaryTapUp: onSecondaryTapUp, + onSecondaryTapDown: onSecondaryTapDown, + onSecondaryTapCancel: onSecondaryTapCancel, + onHighlightChanged: onHighlightChanged, + onHover: onHover, + mouseCursor: mouseCursor, + containedInkWell: containedInkWell, + highlightShape: highlightShape, + radius: radius, + borderRadius: borderRadius, + customBorder: customBorder, + focusColor: focusColor, + hoverColor: hoverColor, + highlightColor: highlightColor, + overlayColor: overlayColor, + splashColor: splashColor, + splashFactory: splashFactory, + enableFeedback: enableFeedback, + excludeFromSemantics: excludeFromSemantics, + focusNode: focusNode, + canRequestFocus: canRequestFocus, + onFocusChange: onFocusChange, + autofocus: autofocus, + parentState: parentState, + getRectCallback: getRectCallback, + debugCheckContext: debugCheckContext, + statesController: statesController, + hoverDuration: hoverDuration, + longPressTimeout: longPressTimeout, + child: child, + ); + } + + /// Asserts that the given context satisfies the prerequisites for + /// this class. + /// + /// This method is intended to be overridden by descendants that + /// specialize [InkResponse] for unusual cases. For example, + /// [TableRowInkWell] implements this method to verify that the widget is + /// in a table. + @mustCallSuper + bool debugCheckContext(BuildContext context) { + assert(debugCheckHasMaterial(context)); + assert(debugCheckHasDirectionality(context)); + return true; + } +} + +abstract class _ParentInkResponseState { + void markChildInkResponsePressed(_ParentInkResponseState childState, bool value); +} + +class _ParentInkResponseProvider extends InheritedWidget { + const _ParentInkResponseProvider({ + required this.state, + required super.child, + }); + + final _ParentInkResponseState state; + + @override + bool updateShouldNotify(_ParentInkResponseProvider oldWidget) => state != oldWidget.state; + + static _ParentInkResponseState? maybeOf(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType<_ParentInkResponseProvider>()?.state; + } +} + +typedef _GetRectCallback = RectCallback? Function(RenderBox referenceBox); +typedef _CheckContext = bool Function(BuildContext context); + +class _InkResponseStateWidget extends StatefulWidget { + const _InkResponseStateWidget({ + this.child, + this.onTap, + this.onTapDown, + this.onTapUp, + this.onTapCancel, + this.onDoubleTap, + this.onLongPress, + this.onSecondaryTap, + this.onSecondaryTapUp, + this.onSecondaryTapDown, + this.onSecondaryTapCancel, + this.onHighlightChanged, + this.onHover, + this.mouseCursor, + this.containedInkWell = false, + this.highlightShape = BoxShape.circle, + this.radius, + this.borderRadius, + this.customBorder, + this.focusColor, + this.hoverColor, + this.highlightColor, + this.overlayColor, + this.splashColor, + this.splashFactory, + this.enableFeedback = true, + this.excludeFromSemantics = false, + this.focusNode, + this.canRequestFocus = true, + this.onFocusChange, + this.autofocus = false, + this.parentState, + this.getRectCallback, + required this.debugCheckContext, + this.statesController, + this.hoverDuration, + required this.longPressTimeout, + }); + + final Widget? child; + final GestureTapCallback? onTap; + final GestureTapDownCallback? onTapDown; + final GestureTapUpCallback? onTapUp; + final GestureTapCallback? onTapCancel; + final GestureTapCallback? onDoubleTap; + final GestureLongPressCallback? onLongPress; + final GestureTapCallback? onSecondaryTap; + final GestureTapUpCallback? onSecondaryTapUp; + final GestureTapDownCallback? onSecondaryTapDown; + final GestureTapCallback? onSecondaryTapCancel; + final ValueChanged? onHighlightChanged; + final ValueChanged? onHover; + final MouseCursor? mouseCursor; + final bool containedInkWell; + final BoxShape highlightShape; + final double? radius; + final BorderRadius? borderRadius; + final ShapeBorder? customBorder; + final Color? focusColor; + final Color? hoverColor; + final Color? highlightColor; + final WidgetStateProperty? overlayColor; + final Color? splashColor; + final InteractiveInkFeatureFactory? splashFactory; + final bool enableFeedback; + final bool excludeFromSemantics; + final ValueChanged? onFocusChange; + final bool autofocus; + final FocusNode? focusNode; + final bool canRequestFocus; + final _ParentInkResponseState? parentState; + final _GetRectCallback? getRectCallback; + final _CheckContext debugCheckContext; + final WidgetStatesController? statesController; + final Duration? hoverDuration; + final Duration longPressTimeout; + + @override + _InkResponseState createState() => _InkResponseState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + final List gestures = [ + if (onTap != null) 'tap', + if (onDoubleTap != null) 'double tap', + if (onLongPress != null) 'long press', + if (onTapDown != null) 'tap down', + if (onTapUp != null) 'tap up', + if (onTapCancel != null) 'tap cancel', + if (onSecondaryTap != null) 'secondary tap', + if (onSecondaryTapUp != null) 'secondary tap up', + if (onSecondaryTapDown != null) 'secondary tap down', + if (onSecondaryTapCancel != null) 'secondary tap cancel' + ]; + properties.add(IterableProperty('gestures', gestures, ifEmpty: '')); + properties.add(DiagnosticsProperty('mouseCursor', mouseCursor)); + properties.add(DiagnosticsProperty('containedInkWell', containedInkWell, level: DiagnosticLevel.fine)); + properties.add(DiagnosticsProperty( + 'highlightShape', + highlightShape, + description: '${containedInkWell ? "clipped to " : ""}$highlightShape', + showName: false, + )); + } +} + +/// Used to index the allocated highlights for the different types of highlights +/// in [_InkResponseState]. +enum _HighlightType { + pressed, + hover, + focus, +} + +class _InkResponseState extends State<_InkResponseStateWidget> + with AutomaticKeepAliveClientMixin<_InkResponseStateWidget> + implements _ParentInkResponseState +{ + Set? _splashes; + InteractiveInkFeature? _currentSplash; + bool _hovering = false; + final Map<_HighlightType, InkHighlight?> _highlights = <_HighlightType, InkHighlight?>{}; + late final Map> _actionMap = >{ + ActivateIntent: CallbackAction(onInvoke: activateOnIntent), + ButtonActivateIntent: CallbackAction(onInvoke: activateOnIntent), + }; + WidgetStatesController? internalStatesController; + + bool get highlightsExist => _highlights.values.where((highlight) => highlight != null).isNotEmpty; + + final ObserverList<_ParentInkResponseState> _activeChildren = ObserverList<_ParentInkResponseState>(); + + static const Duration _activationDuration = Duration(milliseconds: 100); + Timer? _activationTimer; + + @override + void markChildInkResponsePressed(_ParentInkResponseState childState, bool value) { + final bool lastAnyPressed = _anyChildInkResponsePressed; + if (value) { + _activeChildren.add(childState); + } else { + _activeChildren.remove(childState); + } + final bool nowAnyPressed = _anyChildInkResponsePressed; + if (nowAnyPressed != lastAnyPressed) { + widget.parentState?.markChildInkResponsePressed(this, nowAnyPressed); + } + } + bool get _anyChildInkResponsePressed => _activeChildren.isNotEmpty; + + void activateOnIntent(Intent? intent) { + _activationTimer?.cancel(); + _activationTimer = null; + _startNewSplash(context: context); + _currentSplash?.confirm(); + _currentSplash = null; + if (widget.onTap != null) { + if (widget.enableFeedback) { + Feedback.forTap(context); + } + widget.onTap?.call(); + } + // Delay the call to `updateHighlight` to simulate a pressed delay + // and give MaterialStatesController listeners a chance to react. + _activationTimer = Timer(_activationDuration, () { + updateHighlight(_HighlightType.pressed, value: false); + }); + } + + void simulateTap([Intent? intent]) { + _startNewSplash(context: context); + handleTap(); + } + + void simulateLongPress() { + _startNewSplash(context: context); + handleLongPress(); + } + + void handleStatesControllerChange() { + // Force a rebuild to resolve widget.overlayColor, widget.mouseCursor + setState(() { }); + } + + WidgetStatesController get statesController => widget.statesController ?? internalStatesController!; + + void initStatesController() { + if (widget.statesController == null) { + internalStatesController = WidgetStatesController(); + } + statesController.update(WidgetState.disabled, !enabled); + statesController.addListener(handleStatesControllerChange); + } + + @override + void initState() { + super.initState(); + initStatesController(); + FocusManager.instance.addHighlightModeListener(handleFocusHighlightModeChange); + } + + @override + void didUpdateWidget(_InkResponseStateWidget oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.statesController != oldWidget.statesController) { + oldWidget.statesController?.removeListener(handleStatesControllerChange); + if (widget.statesController != null) { + internalStatesController?.dispose(); + internalStatesController = null; + } + initStatesController(); + } + if (widget.radius != oldWidget.radius || + widget.highlightShape != oldWidget.highlightShape || + widget.borderRadius != oldWidget.borderRadius) { + final InkHighlight? hoverHighlight = _highlights[_HighlightType.hover]; + if (hoverHighlight != null) { + hoverHighlight.dispose(); + updateHighlight(_HighlightType.hover, value: _hovering, callOnHover: false); + } + final InkHighlight? focusHighlight = _highlights[_HighlightType.focus]; + if (focusHighlight != null) { + focusHighlight.dispose(); + // Do not call updateFocusHighlights() here because it is called below + } + } + if (widget.customBorder != oldWidget.customBorder) { + _updateHighlightsAndSplashes(); + } + if (enabled != isWidgetEnabled(oldWidget)) { + statesController.update(WidgetState.disabled, !enabled); + if (!enabled) { + statesController.update(WidgetState.pressed, false); + // Remove the existing hover highlight immediately when enabled is false. + // Do not rely on updateHighlight or InkHighlight.deactivate to not break + // the expected lifecycle which is updating _hovering when the mouse exit. + // Manually updating _hovering here or calling InkHighlight.deactivate + // will lead to onHover not being called or call when it is not allowed. + final InkHighlight? hoverHighlight = _highlights[_HighlightType.hover]; + hoverHighlight?.dispose(); + } + // Don't call widget.onHover because many widgets, including the button + // widgets, apply setState to an ancestor context from onHover. + updateHighlight(_HighlightType.hover, value: _hovering, callOnHover: false); + } + updateFocusHighlights(); + } + + @override + void dispose() { + FocusManager.instance.removeHighlightModeListener(handleFocusHighlightModeChange); + statesController.removeListener(handleStatesControllerChange); + internalStatesController?.dispose(); + _activationTimer?.cancel(); + _activationTimer = null; + super.dispose(); + } + + @override + bool get wantKeepAlive => highlightsExist || (_splashes != null && _splashes!.isNotEmpty); + + Duration getFadeDurationForType(_HighlightType type) { + switch (type) { + case _HighlightType.pressed: + return const Duration(milliseconds: 200); + case _HighlightType.hover: + case _HighlightType.focus: + return widget.hoverDuration ?? const Duration(milliseconds: 50); + } + } + + void updateHighlight(_HighlightType type, { required bool value, bool callOnHover = true }) { + final InkHighlight? highlight = _highlights[type]; + void handleInkRemoval() { + assert(_highlights[type] != null); + _highlights[type] = null; + updateKeepAlive(); + } + + switch (type) { + case _HighlightType.pressed: + statesController.update(WidgetState.pressed, value); + case _HighlightType.hover: + if (callOnHover) { + statesController.update(WidgetState.hovered, value); + } + case _HighlightType.focus: + // see handleFocusUpdate() + break; + } + + if (type == _HighlightType.pressed) { + widget.parentState?.markChildInkResponsePressed(this, value); + } + if (value == (highlight != null && highlight.active)) { + return; + } + + if (value) { + if (highlight == null) { + final Color resolvedOverlayColor = widget.overlayColor?.resolve(statesController.value) + ?? switch (type) { + // Use the backwards compatible defaults + _HighlightType.pressed => widget.highlightColor ?? Theme.of(context).highlightColor, + _HighlightType.focus => widget.focusColor ?? Theme.of(context).focusColor, + _HighlightType.hover => widget.hoverColor ?? Theme.of(context).hoverColor, + }; + final RenderBox referenceBox = context.findRenderObject()! as RenderBox; + _highlights[type] = InkHighlight( + controller: Material.of(context), + referenceBox: referenceBox, + color: enabled ? resolvedOverlayColor : resolvedOverlayColor.withAlpha(0), + shape: widget.highlightShape, + radius: widget.radius, + borderRadius: widget.borderRadius, + customBorder: widget.customBorder, + rectCallback: widget.getRectCallback!(referenceBox), + onRemoved: handleInkRemoval, + textDirection: Directionality.of(context), + fadeDuration: getFadeDurationForType(type), + ); + updateKeepAlive(); + } else { + highlight.activate(); + } + } else { + highlight!.deactivate(); + } + assert(value == (_highlights[type] != null && _highlights[type]!.active)); + + switch (type) { + case _HighlightType.pressed: + widget.onHighlightChanged?.call(value); + case _HighlightType.hover: + if (callOnHover) { + widget.onHover?.call(value); + } + case _HighlightType.focus: + break; + } + } + + void _updateHighlightsAndSplashes() { + for (final InkHighlight? highlight in _highlights.values) { + highlight?.customBorder = widget.customBorder; + } + _currentSplash?.customBorder = widget.customBorder; + + if (_splashes != null && _splashes!.isNotEmpty) { + for (final InteractiveInkFeature inkFeature in _splashes!) { + inkFeature.customBorder = widget.customBorder; + } + } + } + + InteractiveInkFeature _createSplash(Offset globalPosition) { + final MaterialInkController inkController = Material.of(context); + final RenderBox referenceBox = context.findRenderObject()! as RenderBox; + final Offset position = referenceBox.globalToLocal(globalPosition); + final Color color = widget.overlayColor?.resolve(statesController.value) ?? widget.splashColor ?? Theme.of(context).splashColor; + final RectCallback? rectCallback = widget.containedInkWell ? widget.getRectCallback!(referenceBox) : null; + final BorderRadius? borderRadius = widget.borderRadius; + final ShapeBorder? customBorder = widget.customBorder; + + InteractiveInkFeature? splash; + void onRemoved() { + if (_splashes != null) { + assert(_splashes!.contains(splash)); + _splashes!.remove(splash); + if (_currentSplash == splash) { + _currentSplash = null; + } + updateKeepAlive(); + } // else we're probably in deactivate() + } + + splash = (widget.splashFactory ?? Theme.of(context).splashFactory).create( + controller: inkController, + referenceBox: referenceBox, + position: position, + color: color, + containedInkWell: widget.containedInkWell, + rectCallback: rectCallback, + radius: widget.radius, + borderRadius: borderRadius, + customBorder: customBorder, + onRemoved: onRemoved, + textDirection: Directionality.of(context), + ); + + return splash; + } + + void handleFocusHighlightModeChange(FocusHighlightMode mode) { + if (!mounted) { + return; + } + setState(updateFocusHighlights); + } + + bool get _shouldShowFocus { + return switch (MediaQuery.maybeNavigationModeOf(context)) { + NavigationMode.traditional || null => enabled && _hasFocus, + NavigationMode.directional => _hasFocus, + }; + } + + void updateFocusHighlights() { + final bool showFocus = switch (FocusManager.instance.highlightMode) { + FocusHighlightMode.touch => false, + FocusHighlightMode.traditional => _shouldShowFocus, + }; + updateHighlight(_HighlightType.focus, value: showFocus); + } + + bool _hasFocus = false; + void handleFocusUpdate(bool hasFocus) { + _hasFocus = hasFocus; + // Set here rather than updateHighlight because this widget's + // (MaterialState) states include MaterialState.focused if + // the InkWell _has_ the focus, rather than if it's showing + // the focus per FocusManager.instance.highlightMode. + statesController.update(WidgetState.focused, hasFocus); + updateFocusHighlights(); + widget.onFocusChange?.call(hasFocus); + } + + void handleAnyTapDown(TapDownDetails details) { + if (_anyChildInkResponsePressed) { + return; + } + _startNewSplash(details: details); + } + + void handleTapDown(TapDownDetails details) { + handleAnyTapDown(details); + widget.onTapDown?.call(details); + } + + void handleTapUp(TapUpDetails details) { + widget.onTapUp?.call(details); + } + + void handleSecondaryTapDown(TapDownDetails details) { + handleAnyTapDown(details); + widget.onSecondaryTapDown?.call(details); + } + + void handleSecondaryTapUp(TapUpDetails details) { + widget.onSecondaryTapUp?.call(details); + } + + void _startNewSplash({TapDownDetails? details, BuildContext? context}) { + assert(details != null || context != null); + + final Offset globalPosition; + if (context != null) { + final RenderBox referenceBox = context.findRenderObject()! as RenderBox; + assert(referenceBox.hasSize, 'InkResponse must be done with layout before starting a splash.'); + globalPosition = referenceBox.localToGlobal(referenceBox.paintBounds.center); + } else { + globalPosition = details!.globalPosition; + } + statesController.update(WidgetState.pressed, true); // ... before creating the splash + final InteractiveInkFeature splash = _createSplash(globalPosition); + _splashes ??= HashSet(); + _splashes!.add(splash); + _currentSplash?.cancel(); + _currentSplash = splash; + updateKeepAlive(); + updateHighlight(_HighlightType.pressed, value: true); + } + + void handleTap() { + _currentSplash?.confirm(); + _currentSplash = null; + updateHighlight(_HighlightType.pressed, value: false); + if (widget.onTap != null) { + if (widget.enableFeedback) { + Feedback.forTap(context); + } + widget.onTap?.call(); + } + } + + void handleTapCancel() { + _currentSplash?.cancel(); + _currentSplash = null; + widget.onTapCancel?.call(); + updateHighlight(_HighlightType.pressed, value: false); + } + + void handleDoubleTap() { + _currentSplash?.confirm(); + _currentSplash = null; + updateHighlight(_HighlightType.pressed, value: false); + widget.onDoubleTap?.call(); + } + + void handleLongPress() { + _currentSplash?.confirm(); + _currentSplash = null; + if (widget.onLongPress != null) { + if (widget.enableFeedback) { + Feedback.forLongPress(context); + } + widget.onLongPress!(); + } + } + + void handleSecondaryTap() { + _currentSplash?.confirm(); + _currentSplash = null; + updateHighlight(_HighlightType.pressed, value: false); + widget.onSecondaryTap?.call(); + } + + void handleSecondaryTapCancel() { + _currentSplash?.cancel(); + _currentSplash = null; + widget.onSecondaryTapCancel?.call(); + updateHighlight(_HighlightType.pressed, value: false); + } + + @override + void deactivate() { + if (_splashes != null) { + final Set splashes = _splashes!; + _splashes = null; + for (final InteractiveInkFeature splash in splashes) { + splash.dispose(); + } + _currentSplash = null; + } + assert(_currentSplash == null); + for (final _HighlightType highlight in _highlights.keys) { + _highlights[highlight]?.dispose(); + _highlights[highlight] = null; + } + widget.parentState?.markChildInkResponsePressed(this, false); + super.deactivate(); + } + + bool isWidgetEnabled(_InkResponseStateWidget widget) { + return _primaryButtonEnabled(widget) || _secondaryButtonEnabled(widget); + } + + bool _primaryButtonEnabled(_InkResponseStateWidget widget) { + return widget.onTap != null + || widget.onDoubleTap != null + || widget.onLongPress != null + || widget.onTapUp != null + || widget.onTapDown != null; + } + + bool _secondaryButtonEnabled(_InkResponseStateWidget widget) { + return widget.onSecondaryTap != null + || widget.onSecondaryTapUp != null + || widget.onSecondaryTapDown != null; + } + + bool get enabled => isWidgetEnabled(widget); + bool get _primaryEnabled => _primaryButtonEnabled(widget); + bool get _secondaryEnabled => _secondaryButtonEnabled(widget); + + void handleMouseEnter(PointerEnterEvent event) { + _hovering = true; + if (enabled) { + handleHoverChange(); + } + } + + void handleMouseExit(PointerExitEvent event) { + _hovering = false; + // If the exit occurs after we've been disabled, we still + // want to take down the highlights and run widget.onHover. + handleHoverChange(); + } + + void handleHoverChange() { + updateHighlight(_HighlightType.hover, value: _hovering); + } + + bool get _canRequestFocus { + return switch (MediaQuery.maybeNavigationModeOf(context)) { + NavigationMode.traditional || null => enabled && widget.canRequestFocus, + NavigationMode.directional => true, + }; + } + + @override + Widget build(BuildContext context) { + assert(widget.debugCheckContext(context)); + super.build(context); // See AutomaticKeepAliveClientMixin. + + Color getHighlightColorForType(_HighlightType type) { + const Set pressed = {WidgetState.pressed}; + const Set focused = {WidgetState.focused}; + const Set hovered = {WidgetState.hovered}; + + final ThemeData theme = Theme.of(context); + return switch (type) { + // The pressed state triggers a ripple (ink splash), per the current + // Material Design spec. A separate highlight is no longer used. + // See https://material.io/design/interaction/states.html#pressed + _HighlightType.pressed => widget.overlayColor?.resolve(pressed) ?? widget.highlightColor ?? theme.highlightColor, + _HighlightType.focus => widget.overlayColor?.resolve(focused) ?? widget.focusColor ?? theme.focusColor, + _HighlightType.hover => widget.overlayColor?.resolve(hovered) ?? widget.hoverColor ?? theme.hoverColor, + }; + } + for (final _HighlightType type in _highlights.keys) { + _highlights[type]?.color = getHighlightColorForType(type); + } + + _currentSplash?.color = widget.overlayColor?.resolve(statesController.value) ?? widget.splashColor ?? Theme.of(context).splashColor; + + final MouseCursor effectiveMouseCursor = WidgetStateProperty.resolveAs( + widget.mouseCursor ?? WidgetStateMouseCursor.clickable, + statesController.value, + ); + + return _ParentInkResponseProvider( + state: this, + child: Actions( + actions: _actionMap, + child: Focus( + focusNode: widget.focusNode, + canRequestFocus: _canRequestFocus, + onFocusChange: handleFocusUpdate, + autofocus: widget.autofocus, + child: MouseRegion( + cursor: effectiveMouseCursor, + onEnter: handleMouseEnter, + onExit: handleMouseExit, + child: DefaultSelectionStyle.merge( + mouseCursor: effectiveMouseCursor, + child: Semantics( + onTap: widget.excludeFromSemantics || widget.onTap == null ? null : simulateTap, + onLongPress: widget.excludeFromSemantics || widget.onLongPress == null ? null : simulateLongPress, + child: AGestureDetector( + onTapDown: _primaryEnabled ? handleTapDown : null, + onTapUp: _primaryEnabled ? handleTapUp : null, + onTap: _primaryEnabled ? handleTap : null, + onTapCancel: _primaryEnabled ? handleTapCancel : null, + onDoubleTap: widget.onDoubleTap != null ? handleDoubleTap : null, + onLongPress: widget.onLongPress != null ? handleLongPress : null, + onSecondaryTapDown: _secondaryEnabled ? handleSecondaryTapDown : null, + onSecondaryTapUp: _secondaryEnabled ? handleSecondaryTapUp: null, + onSecondaryTap: _secondaryEnabled ? handleSecondaryTap : null, + onSecondaryTapCancel: _secondaryEnabled ? handleSecondaryTapCancel : null, + behavior: HitTestBehavior.opaque, + excludeFromSemantics: true, + longPressTimeout: widget.longPressTimeout, + child: widget.child, + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/common/grid/header.dart b/lib/widgets/common/grid/header.dart index 6d30a9352..ed70d0e5f 100644 --- a/lib/widgets/common/grid/header.dart +++ b/lib/widgets/common/grid/header.dart @@ -4,6 +4,7 @@ import 'package:aves/model/source/section_keys.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/theme/styles.dart'; +import 'package:aves/widgets/common/basic/gestures/gesture_detector.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/grid/sections/list_layout.dart'; import 'package:flutter/material.dart'; @@ -42,7 +43,7 @@ class SectionHeader extends StatelessWidget { Widget child = Container( padding: padding, constraints: BoxConstraints(minHeight: leadingSize.height), - child: GestureDetector( + child: AGestureDetector( onTap: onTap, onLongPress: selectable ? Feedback.wrapForLongPress(() { @@ -55,6 +56,7 @@ class SectionHeader extends StatelessWidget { } }, context) : null, + longPressTimeout: settings.longPressTimeout, child: Text.rich( TextSpan( children: [ diff --git a/lib/widgets/common/grid/selector.dart b/lib/widgets/common/grid/selector.dart index 45aa02cab..8f922174c 100644 --- a/lib/widgets/common/grid/selector.dart +++ b/lib/widgets/common/grid/selector.dart @@ -2,11 +2,12 @@ import 'dart:async'; import 'dart:math'; import 'package:aves/model/selection.dart'; +import 'package:aves/model/settings/settings.dart'; import 'package:aves/utils/math_utils.dart'; +import 'package:aves/widgets/common/basic/gestures/gesture_detector.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/media_query.dart'; import 'package:aves/widgets/common/grid/sections/list_layout.dart'; -import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -90,7 +91,7 @@ class _GridSelectionGestureDetectorState extends State extends State extends State Material( color: backgroundColor, - child: InkWell( + child: AInkResponse( // absorb taps while providing visual feedback onTap: () {}, onLongPress: () {}, + containedInkWell: true, + highlightShape: BoxShape.rectangle, + longPressTimeout: settings.longPressTimeout, child: child, ), ), diff --git a/lib/widgets/common/identity/aves_filter_chip.dart b/lib/widgets/common/identity/aves_filter_chip.dart index e9c9ddd35..a16922c1f 100644 --- a/lib/widgets/common/identity/aves_filter_chip.dart +++ b/lib/widgets/common/identity/aves_filter_chip.dart @@ -13,6 +13,7 @@ import 'package:aves/theme/themes.dart'; import 'package:aves/view/view.dart'; import 'package:aves/widgets/collection/filter_bar.dart'; import 'package:aves/widgets/common/basic/font_size_icon_theme.dart'; +import 'package:aves/widgets/common/basic/gestures/ink_well.dart'; import 'package:aves/widgets/common/basic/popup/menu_row.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; @@ -347,13 +348,16 @@ class _AvesFilterChipState extends State { shape: RoundedRectangleBorder( borderRadius: borderRadius, ), - child: InkWell( + child: AInkResponse( // as of Flutter v2.8.0, `InkWell` does not have `onLongPressStart` like `GestureDetector`, // so we get the long press details from the tap instead onTapDown: onLongPress != null ? (details) => _tapPosition = details.globalPosition : null, onTap: onTap, onLongPress: onLongPress, + containedInkWell: true, + highlightShape: BoxShape.rectangle, borderRadius: borderRadius, + longPressTimeout: settings.longPressTimeout, child: FutureBuilder( future: _colorFuture, builder: (context, snapshot) { diff --git a/lib/widgets/common/map/leaflet/map.dart b/lib/widgets/common/map/leaflet/map.dart index 10800040f..d48d1e00f 100644 --- a/lib/widgets/common/map/leaflet/map.dart +++ b/lib/widgets/common/map/leaflet/map.dart @@ -1,13 +1,16 @@ import 'dart:async'; +import 'dart:math'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/utils/debouncer.dart'; +import 'package:aves/widgets/common/basic/gestures/gesture_detector.dart'; import 'package:aves/widgets/common/map/leaflet/latlng_tween.dart' as llt; import 'package:aves/widgets/common/map/leaflet/scale_layer.dart'; import 'package:aves/widgets/common/map/leaflet/tile_layers.dart'; import 'package:aves_map/aves_map.dart'; import 'package:aves_utils/aves_utils.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart'; @@ -130,14 +133,19 @@ class _EntryLeafletMapState extends State> with TickerProv final markerKey = kv.key; final geoEntry = kv.value; final latLng = LatLng(geoEntry.latitude!, geoEntry.longitude!); + final onMarkerLongPress = widget.onMarkerLongPress; + final onLongPress = onMarkerLongPress != null ? Feedback.wrapForLongPress(() => onMarkerLongPress.call(geoEntry, LatLng(geoEntry.latitude!, geoEntry.longitude!)), context) : null; return Marker( point: latLng, - child: GestureDetector( + child: AGestureDetector( onTap: () => widget.onMarkerTap?.call(geoEntry), // marker tap handling prevents the default handling of focal zoom on double tap, // so we reimplement the double tap gesture here onDoubleTap: interactive ? () => _zoomBy(1, focalPoint: latLng) : null, - onLongPress: Feedback.wrapForLongPress(() => widget.onMarkerLongPress?.call(geoEntry, LatLng(geoEntry.latitude!, geoEntry.longitude!)), context), + onLongPress: onLongPress, + // `MapInteractiveViewer` already declares a `LongPressGestureRecognizer` with the default delay (`kLongPressTimeout`), + // so this one should have a shorter delay to win in the gesture arena + longPressTimeout: Duration(milliseconds: min(settings.longPressTimeout.inMilliseconds, kLongPressTimeout.inMilliseconds)), child: widget.markerWidgetBuilder(markerKey), ), width: markerSize.width, diff --git a/lib/widgets/debug/settings.dart b/lib/widgets/debug/settings.dart index b8976059a..4c7bf2a81 100644 --- a/lib/widgets/debug/settings.dart +++ b/lib/widgets/debug/settings.dart @@ -76,6 +76,7 @@ class _DebugSettingsSectionState extends State with Automa 'locale': '${settings.locale}', 'systemLocales': '${WidgetsBinding.instance.platformDispatcher.locales}', 'topEntryIds': '${settings.topEntryIds}', + 'longPressTimeout': '${settings.longPressTimeout}', }, ), ), diff --git a/lib/widgets/settings/common/quick_actions/available_actions.dart b/lib/widgets/settings/common/quick_actions/available_actions.dart index 5e9d0fc65..6f0aff2eb 100644 --- a/lib/widgets/settings/common/quick_actions/available_actions.dart +++ b/lib/widgets/settings/common/quick_actions/available_actions.dart @@ -1,3 +1,4 @@ +import 'package:aves/model/settings/settings.dart'; import 'package:aves/widgets/common/identity/buttons/captioned_button.dart'; import 'package:aves/widgets/common/identity/buttons/overlay_button.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; @@ -98,6 +99,7 @@ class AvailableActionPanel extends StatelessWidget { maxSimultaneousDrags: 1, onDragStarted: () => _setDraggedAvailableAction(action), onDragEnd: (details) => _setDraggedAvailableAction(null), + delay: settings.longPressTimeout, childWhenDragging: child, child: child, ); diff --git a/lib/widgets/settings/common/quick_actions/quick_actions.dart b/lib/widgets/settings/common/quick_actions/quick_actions.dart index be76d2cdd..73eb14a22 100644 --- a/lib/widgets/settings/common/quick_actions/quick_actions.dart +++ b/lib/widgets/settings/common/quick_actions/quick_actions.dart @@ -1,3 +1,4 @@ +import 'package:aves/model/settings/settings.dart'; import 'package:aves/widgets/common/identity/buttons/overlay_button.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:flutter/widgets.dart'; @@ -72,6 +73,7 @@ class QuickActionButton extends StatelessWidget { // so we rely on `onDraggableCanceled` and `onDragCompleted` instead onDraggableCanceled: (velocity, offset) => _setDraggedQuickAction(null), onDragCompleted: () => _setDraggedQuickAction(null), + delay: settings.longPressTimeout, childWhenDragging: child, child: child, ); diff --git a/plugins/aves_model/lib/src/settings/keys.dart b/plugins/aves_model/lib/src/settings/keys.dart index e74f63f9c..c1ed5ecd6 100644 --- a/plugins/aves_model/lib/src/settings/keys.dart +++ b/plugins/aves_model/lib/src/settings/keys.dart @@ -188,4 +188,7 @@ class SettingKeys { // cf Android `Settings.Global.TRANSITION_ANIMATION_SCALE` static const platformTransitionAnimationScaleKey = 'transition_animation_scale'; + + // cf Android `Settings.Secure.LONG_PRESS_TIMEOUT` + static const platformLongPressTimeoutMillisKey = 'long_press_timeout'; } diff --git a/pubspec.yaml b/pubspec.yaml index 524ae7def..1b24295fe 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -199,6 +199,12 @@ flutter: # `OverlaySnackBar` in `/widgets/common/action_mixins/overlay_snack_bar.dart` # adapts from Flutter v3.23.0 `SnackBar` in `/material/snack_bar.dart` # +# `AGestureDetector` in `/widgets/common/basic/gestures/gesture_detector.dart` +# adapts from Flutter v3.21.1 `GestureDetector` in `/widgets/gesture_detector.dart` +# +# `AInkResponse` in `/widgets/common/basic/gestures/ink_well.dart` +# adapts from Flutter v3.21.1 `InkResponse` and related classes in `/material/ink_well.dart` +# # `EagerScaleGestureRecognizer` in `/widgets/common/behaviour/eager_scale_gesture_recognizer.dart` # adapts from Flutter v3.16.0 `ScaleGestureRecognizer` in `/gestures/scale.dart` #