diff --git a/lib/mix.dart b/lib/mix.dart index ff7d6de7a..9cc504c5d 100644 --- a/lib/mix.dart +++ b/lib/mix.dart @@ -1,10 +1,7 @@ -library mix; - -// This file is generated by the update_exports.dart script. -// DO NOT MODIFY MANUALLY +/// This file is generated by the update_exports.dart script. +/// DO NOT MODIFY MANUALLY -// Some default exports -export 'src/deprecations.dart'; +library mix; // Automated file exports export 'src/attributes/border/border_dto.dart'; @@ -50,6 +47,7 @@ export 'src/core/styled_widget.dart'; export 'src/decorators/widget_decorator_widget.dart'; export 'src/decorators/widget_decorators.dart'; export 'src/decorators/widget_decorators_util.dart'; +export 'src/deprecations.dart'; export 'src/factory/mix_provider.dart'; export 'src/factory/mix_provider_data.dart'; export 'src/factory/style_mix.dart'; @@ -94,9 +92,10 @@ export 'src/utils/context_variant_util/on_brightness_util.dart'; export 'src/utils/context_variant_util/on_directionality_util.dart'; export 'src/utils/context_variant_util/on_helper_util.dart'; export 'src/utils/context_variant_util/on_orientation_util.dart'; +export 'src/utils/custom_focusable_action_detector.dart'; export 'src/utils/helper_util.dart'; export 'src/utils/style_recipe.dart'; export 'src/variants/variant.dart'; -export 'src/widgets/pressable/pressable_state.notifier.dart'; +export 'src/widgets/pressable/pressable_data.notifier.dart'; +export 'src/widgets/pressable/pressable_util.dart'; export 'src/widgets/pressable/pressable_widget.dart'; -export 'src/widgets/pressable/widget_state_util.dart'; diff --git a/lib/src/deprecations.dart b/lib/src/deprecations.dart index 73dfac07f..606c74e21 100644 --- a/lib/src/deprecations.dart +++ b/lib/src/deprecations.dart @@ -15,9 +15,9 @@ import '../src/utils/context_variant_util/on_helper_util.dart'; import '../src/utils/context_variant_util/on_orientation_util.dart'; import '../src/utils/helper_util.dart'; import '../src/variants/variant.dart'; -import '../src/widgets/pressable/widget_state_util.dart'; import 'core/attribute.dart'; import 'factory/style_mix.dart'; +import 'widgets/pressable/pressable_util.dart'; const kShortAliasDeprecation = 'Short aliases will be deprecated, you can create your own. Example: final p = padding;'; diff --git a/lib/src/factory/mix_provider.dart b/lib/src/factory/mix_provider.dart index e375b5142..d2838c6fe 100644 --- a/lib/src/factory/mix_provider.dart +++ b/lib/src/factory/mix_provider.dart @@ -18,12 +18,9 @@ class MixProvider extends InheritedWidget { /// Retrieves the nearest [MixData] from the widget tree. Throws if not found. static MixData of(BuildContext context) { final mixData = maybeOf(context); - if (mixData == null) { - throw StateError('MixProvider not found in widget tree. ' - 'Ensure that Mix is present in the widget tree and try again.'); - } + assert(mixData != null, 'MixProvider not found in widget tree.'); - return mixData; + return mixData!; } final MixData? data; diff --git a/lib/src/factory/mix_provider_data.dart b/lib/src/factory/mix_provider_data.dart index d15353396..96d2ebc02 100644 --- a/lib/src/factory/mix_provider_data.dart +++ b/lib/src/factory/mix_provider_data.dart @@ -5,7 +5,7 @@ import '../core/attribute.dart'; import '../core/attributes_map.dart'; import '../helpers/compare_mixin.dart'; import '../theme/token_resolver.dart'; -import '../widgets/pressable/widget_state_util.dart'; +import '../widgets/pressable/pressable_util.dart'; import 'mix_provider.dart'; import 'style_mix.dart'; @@ -120,7 +120,7 @@ List applyContextToVisualAttributes( List gestureVariantTypes = []; for (ContextVariantAttribute attr in contextVariants) { - if (attr.variant is WidgetStateVariant) { + if (attr.variant is PressableDataVariant) { gestureVariantTypes.add(attr); } else { contextVariantTypes.add(attr); @@ -128,7 +128,8 @@ List applyContextToVisualAttributes( } for (MultiVariantAttribute attr in multiVariants) { - if (attr.variant.variants.any((variant) => variant is WidgetStateVariant)) { + if (attr.variant.variants + .any((variant) => variant is PressableDataVariant)) { gestureVariantTypes.add(attr); } else { contextVariantTypes.add(attr); diff --git a/lib/src/utils/custom_focusable_action_detector.dart b/lib/src/utils/custom_focusable_action_detector.dart new file mode 100644 index 000000000..6536de937 --- /dev/null +++ b/lib/src/utils/custom_focusable_action_detector.dart @@ -0,0 +1,287 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; + +class CustomFocusableActionDetector extends StatefulWidget { + /// Create a const [CustomFocusableActionDetector]. + const CustomFocusableActionDetector({ + super.key, + this.enabled = true, + this.focusNode, + this.autofocus = false, + this.descendantsAreFocusable = true, + this.descendantsAreTraversable = true, + this.shortcuts, + this.actions, + this.onShowFocusHighlight, + this.onShowHoverHighlight, + this.onFocusChange, + this.mouseCursor = MouseCursor.defer, + this.includeFocusSemantics = true, + this.onMouseEnter, + this.onMouseExit, + this.onMouseHover, + required this.child, + }); + + /// Is this widget enabled or not. + /// + /// If disabled, will not send any notifications needed to update highlight or + /// focus state, and will not define or respond to any actions or shortcuts. + /// + /// When disabled, adds [Focus] to the widget tree, but sets + /// [Focus.canRequestFocus] to false. + final bool enabled; + + /// {@macro flutter.widgets.Focus.focusNode} + final FocusNode? focusNode; + + /// {@macro flutter.widgets.Focus.autofocus} + final bool autofocus; + + /// {@macro flutter.widgets.Focus.descendantsAreFocusable} + final bool descendantsAreFocusable; + + /// {@macro flutter.widgets.Focus.descendantsAreTraversable} + final bool descendantsAreTraversable; + + /// {@macro flutter.widgets.actions.actions} + final Map>? actions; + + /// {@macro flutter.widgets.shortcuts.shortcuts} + final Map? shortcuts; + + /// A function that will be called when the focus highlight should be shown or + /// hidden. + /// + /// This method is not triggered at the unmount of the widget. + final ValueChanged? onShowFocusHighlight; + + /// A function that will be called when the hover highlight should be shown or hidden. + /// + /// This method is not triggered at the unmount of the widget. + final ValueChanged? onShowHoverHighlight; + + /// A function that will be called when the focus changes. + /// + /// Called with true if the [focusNode] has primary focus. + final ValueChanged? onFocusChange; + + /// A function that will be called when the mouse enters + /// + /// Called with the [PointerEnterEvent] when the mouse enters the widget. + final void Function(PointerExitEvent event)? onMouseExit; + + /// A function that will be called when the mouse exits + /// + /// Called with the [PointerExitEvent] when the mouse exits the widget. + final void Function(PointerEnterEvent event)? onMouseEnter; + + /// A function that will be called when the pointer hovers over the widget. + /// + /// Called with the [PointerHoverEvent] when the pointer hovers over the widget. + final void Function(PointerHoverEvent event)? onMouseHover; + + /// The cursor for a mouse pointer when it enters or is hovering over the + /// widget. + /// + /// The [mouseCursor] defaults to [MouseCursor.defer], deferring the choice of + /// cursor to the next region behind it in hit-test order. + final MouseCursor mouseCursor; + + /// Whether to include semantics from [Focus]. + /// + /// Defaults to true. + final bool includeFocusSemantics; + + /// The child widget for this [CustomFocusableActionDetector] widget. + /// + /// {@macro flutter.widgets.ProxyWidget.child} + final Widget child; + + @override + State createState() => + _CustomFocusableActionDetectorState(); +} + +class _CustomFocusableActionDetectorState + extends State { + // This global key is needed to keep only the necessary widgets in the tree + // while maintaining the subtree's state. + // + // See https://github.com/flutter/flutter/issues/64058 for an explanation of + // why using a global key over keeping the shape of the tree. + final GlobalKey _mouseRegionKey = GlobalKey(); + + @override + void initState() { + super.initState(); + SchedulerBinding.instance.addPostFrameCallback((Duration duration) { + _updateHighlightMode(FocusManager.instance.highlightMode); + }); + FocusManager.instance + .addHighlightModeListener(_handleFocusHighlightModeChange); + } + + void _updateHighlightMode(FocusHighlightMode mode) { + _mayTriggerCallback(task: () { + switch (mode) { + case FocusHighlightMode.touch: + _canShowHighlight = false; + break; + case FocusHighlightMode.traditional: + _canShowHighlight = true; + break; + } + }); + } + + // Have to have this separate from the _updateHighlightMode because it gets + // called in initState, where things aren't mounted yet. + // Since this method is a highlight mode listener, it is only called + // immediately following pointer events. + void _handleFocusHighlightModeChange(FocusHighlightMode mode) { + if (!mounted) { + return; + } + _updateHighlightMode(mode); + } + + void _handleMouseEnter(PointerEnterEvent event) { + if (!_hovering) { + _mayTriggerCallback(task: () { + _hovering = true; + }); + widget.onMouseEnter?.call(event); + } + } + + void _handleMouseExit(PointerExitEvent event) { + if (_hovering) { + _mayTriggerCallback(task: () { + _hovering = false; + }); + widget.onMouseExit?.call(event); + } + } + + void _handleFocusChange(bool focused) { + if (_focused != focused) { + _mayTriggerCallback(task: () { + _focused = focused; + }); + widget.onFocusChange?.call(_focused); + } + } + + // Record old states, do `task` if not null, then compare old states with the + // new states, and trigger callbacks if necessary. + // + // The old states are collected from `oldWidget` if it is provided, or the + // current widget (before doing `task`) otherwise. The new states are always + // collected from the current widget. + void _mayTriggerCallback({ + VoidCallback? task, + CustomFocusableActionDetector? oldWidget, + }) { + bool shouldShowHoverHighlight(CustomFocusableActionDetector target) { + return _hovering && target.enabled && _canShowHighlight; + } + + bool canRequestFocus(CustomFocusableActionDetector target) { + final NavigationMode mode = MediaQuery.maybeNavigationModeOf(context) ?? + NavigationMode.traditional; + switch (mode) { + case NavigationMode.traditional: + return target.enabled; + case NavigationMode.directional: + return true; + } + } + + bool shouldShowFocusHighlight(CustomFocusableActionDetector target) { + return _focused && _canShowHighlight && canRequestFocus(target); + } + + assert(SchedulerBinding.instance.schedulerPhase != + SchedulerPhase.persistentCallbacks); + final CustomFocusableActionDetector oldTarget = oldWidget ?? widget; + final bool didShowHoverHighlight = shouldShowHoverHighlight(oldTarget); + final bool didShowFocusHighlight = shouldShowFocusHighlight(oldTarget); + if (task != null) { + task(); + } + final bool doShowHoverHighlight = shouldShowHoverHighlight(widget); + final bool doShowFocusHighlight = shouldShowFocusHighlight(widget); + if (didShowFocusHighlight != doShowFocusHighlight) { + widget.onShowFocusHighlight?.call(doShowFocusHighlight); + } + if (didShowHoverHighlight != doShowHoverHighlight) { + widget.onShowHoverHighlight?.call(doShowHoverHighlight); + } + } + + @override + void dispose() { + FocusManager.instance + .removeHighlightModeListener(_handleFocusHighlightModeChange); + super.dispose(); + } + + @override + void didUpdateWidget(CustomFocusableActionDetector oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.enabled != oldWidget.enabled) { + SchedulerBinding.instance.addPostFrameCallback((Duration duration) { + _mayTriggerCallback(oldWidget: oldWidget); + }); + } + } + + bool _canShowHighlight = false; + bool _hovering = false; + bool _focused = false; + bool get _canRequestFocus { + final NavigationMode mode = + MediaQuery.maybeNavigationModeOf(context) ?? NavigationMode.traditional; + switch (mode) { + case NavigationMode.traditional: + return widget.enabled; + case NavigationMode.directional: + return true; + } + } + + @override + Widget build(BuildContext context) { + Widget child = MouseRegion( + key: _mouseRegionKey, + onEnter: _handleMouseEnter, + onExit: _handleMouseExit, + onHover: widget.onMouseHover, + cursor: widget.mouseCursor, + child: Focus( + focusNode: widget.focusNode, + autofocus: widget.autofocus, + onFocusChange: _handleFocusChange, + canRequestFocus: _canRequestFocus, + descendantsAreFocusable: widget.descendantsAreFocusable, + descendantsAreTraversable: widget.descendantsAreTraversable, + includeSemantics: widget.includeFocusSemantics, + child: widget.child, + ), + ); + if (widget.enabled && + widget.actions != null && + widget.actions!.isNotEmpty) { + child = Actions(actions: widget.actions!, child: child); + } + if (widget.enabled && + widget.shortcuts != null && + widget.shortcuts!.isNotEmpty) { + child = Shortcuts(shortcuts: widget.shortcuts!, child: child); + } + + return child; + } +} diff --git a/lib/src/widgets/pressable/pressable_data.notifier.dart b/lib/src/widgets/pressable/pressable_data.notifier.dart new file mode 100644 index 000000000..e3d2c940b --- /dev/null +++ b/lib/src/widgets/pressable/pressable_data.notifier.dart @@ -0,0 +1,138 @@ +import 'package:flutter/widgets.dart'; + +import '../../helpers/compare_mixin.dart'; + +enum PressableDataAspect { focused, disabled, state, cursorPosition } + +@immutable +class CursorPosition { + final Alignment alignment; + final Offset offset; + + const CursorPosition({required this.alignment, required this.offset}); +} + +@immutable +class PressableStateData with Comparable { + final bool focused; + + final bool disabled; + final PressableState state; + final CursorPosition cursorPosition; + + const PressableStateData({ + required this.focused, + required this.disabled, + required this.state, + required this.cursorPosition, + }); + + const PressableStateData.none() + : focused = false, + disabled = true, + cursorPosition = const CursorPosition( + alignment: Alignment.center, + offset: Offset.zero, + ), + state = PressableState.none; + + PressableStateData copyWith({ + bool? focused, + bool? disabled, + PressableState? state, + CursorPosition? cursorPosition, + }) { + return PressableStateData( + focused: focused ?? this.focused, + disabled: disabled ?? this.disabled, + state: state ?? this.state, + cursorPosition: cursorPosition ?? this.cursorPosition, + ); + } + + @override + get props => [focused, disabled, state, cursorPosition]; +} + +enum PressableState { + none, + hovered, + pressed, + longPressed, +} + +class PressableDataNotifier extends InheritedModel { + const PressableDataNotifier({ + super.key, + required super.child, + required this.data, + }); + + static PressableStateData of( + BuildContext context, { + PressableDataAspect? aspect, + }) { + final model = InheritedModel.inheritFrom( + context, + aspect: aspect, + ); + + assert( + model != null, + 'No Pressable data found in context. Make sure to wrap your widget a Pressable widget', + ); + + return model!.data; + } + + static bool isDisabledOf(BuildContext context) { + return of(context, aspect: PressableDataAspect.disabled).disabled; + } + + static CursorPosition cursorPositionOf(BuildContext context) { + return of(context, aspect: PressableDataAspect.cursorPosition) + .cursorPosition; + } + + static bool isFocusedOf(BuildContext context) { + return of(context, aspect: PressableDataAspect.focused).focused; + } + + static PressableState stateOf(BuildContext context) { + return of(context, aspect: PressableDataAspect.state).state; + } + + final PressableStateData data; + + @override + bool updateShouldNotify(PressableDataNotifier oldWidget) { + return oldWidget.data != data; + } + + @override + bool updateShouldNotifyDependent( + PressableDataNotifier oldWidget, + Set dependencies, + ) { + if (oldWidget.data.focused != data.focused && + dependencies.contains(PressableDataAspect.focused)) { + return true; + } + if (oldWidget.data.disabled != data.disabled && + dependencies.contains(PressableDataAspect.disabled)) { + return true; + } + + if (oldWidget.data.state != data.state && + dependencies.contains(PressableDataAspect.state)) { + return true; + } + + if (oldWidget.data.cursorPosition != data.cursorPosition && + dependencies.contains(PressableDataAspect.cursorPosition)) { + return true; + } + + return false; + } +} diff --git a/lib/src/widgets/pressable/pressable_state.notifier.dart b/lib/src/widgets/pressable/pressable_state.notifier.dart deleted file mode 100644 index 8cd25b498..000000000 --- a/lib/src/widgets/pressable/pressable_state.notifier.dart +++ /dev/null @@ -1,73 +0,0 @@ -import 'package:flutter/widgets.dart'; - -import '../../helpers/compare_mixin.dart'; - -@immutable -class WidgetStateData with Comparable { - final bool focus; - final bool hover; - final WidgetStatus status; - final WidgetState state; - - const WidgetStateData({ - required this.focus, - required this.status, - required this.state, - required this.hover, - }); - - const WidgetStateData.none() - : focus = false, - hover = false, - status = WidgetStatus.disabled, - state = WidgetState.none; - - WidgetStateData copyWith({ - bool? focus, - bool? hover, - WidgetStatus? status, - WidgetState? state, - }) { - return WidgetStateData( - focus: focus ?? this.focus, - status: status ?? this.status, - state: state ?? this.state, - hover: hover ?? this.hover, - ); - } - - @override - get props => [focus, status, state, hover]; -} - -enum WidgetStatus { - enabled, - disabled, -} - -enum WidgetState { - none, - pressed, - longPressed, -} - -class WidgetStateNotifier extends InheritedWidget { - const WidgetStateNotifier({ - super.key, - required super.child, - required this.data, - }); - - static WidgetStateData? of(BuildContext context) { - return context - .dependOnInheritedWidgetOfExactType() - ?.data; - } - - final WidgetStateData data; - - @override - bool updateShouldNotify(WidgetStateNotifier oldWidget) { - return oldWidget.data != data; - } -} diff --git a/lib/src/widgets/pressable/pressable_util.dart b/lib/src/widgets/pressable/pressable_util.dart new file mode 100644 index 000000000..83315d698 --- /dev/null +++ b/lib/src/widgets/pressable/pressable_util.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; + +import '../../helpers/string_ext.dart'; +import '../../variants/variant.dart'; +import 'pressable_data.notifier.dart'; + +/// Global context variants for handling common widget states and gestures. + +/// Applies styles when the widget is pressed. +final onPressed = _onState(PressableState.pressed); + +/// Applies styles when the widget is long pressed. +final onLongPressed = _onState(PressableState.longPressed); + +/// Applies styles when widget is hovered over. +final onHover = _onState(PressableState.hovered); + +/// Applies styles when the widget is disabled. +final onDisabled = _onDisabled(true); + +/// Applies styles when the widget is enabled. +final onEnabled = _onDisabled(false); + +/// Applies styles when the widget has focus.dar +final onFocused = ContextVariant( + 'on-focused', + + /// Applies the variant only when the GestureStateNotifier's focus property is true. + (context) => PressableDataNotifier.isFocusedOf(context) == true, +); + +/// Helper class for creating widget state-based context variants. +@immutable +class PressableDataVariant extends ContextVariant { + const PressableDataVariant(super.name, super.when); +} + +/// Creates a [PressableDataVariant] based on the specified [state]. +/// +/// This function constructs a WidgetStateVariant with a name based on the provided state and a condition that checks if the GestureStateNotifier in the context matches the given state. +PressableDataVariant _onState(PressableState state) { + return PressableDataVariant( + 'on-${state.name.paramCase}', + (context) => PressableDataNotifier.stateOf(context) == state, + ); +} + +/// Creates a [PressableDataVariant] based on the specified [status]. +/// +/// Similar to `_onState`, this function creates a WidgetStateVariant with a condition that checks if the GestureStateNotifier in the context matches the provided status. +PressableDataVariant _onDisabled(bool disabled) { + return PressableDataVariant( + 'on-${disabled ? 'disabled' : 'enabled'}', + (context) => PressableDataNotifier.isDisabledOf(context) == disabled, + ); +} diff --git a/lib/src/widgets/pressable/pressable_widget.dart b/lib/src/widgets/pressable/pressable_widget.dart index c54859aef..6839ae64e 100644 --- a/lib/src/widgets/pressable/pressable_widget.dart +++ b/lib/src/widgets/pressable/pressable_widget.dart @@ -1,31 +1,51 @@ +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import '../../factory/style_mix.dart'; import '../../specs/container/box_widget.dart'; -import 'pressable_state.notifier.dart'; +import '../../utils/custom_focusable_action_detector.dart'; +import 'pressable_data.notifier.dart'; class PressableBox extends StatelessWidget { const PressableBox({ super.key, - this.onPressed, + this.style, this.onLongPress, this.focusNode, this.autofocus = false, + this.enableFeedback = false, this.unpressDelay = const Duration(milliseconds: 150), this.onFocusChange, - this.behavior, - required this.child, - this.style, + @Deprecated('Use onTap instead') VoidCallback? onPressed, + VoidCallback? onPress, + @Deprecated('Use hitTestBehavior instead') HitTestBehavior? behavior, + this.hitTestBehavior, this.animationDuration = const Duration(milliseconds: 125), this.animationCurve = Curves.linear, this.disabled = false, - }); + required this.child, + }) : onPress = onPress ?? onPressed; + + /// Should gestures provide audible and/or haptic feedback + /// + /// On platforms like Android, enabling feedback will result in audible and tactile + /// responses to certain actions. For example, a tap may produce a clicking sound, + /// while a long-press may trigger a short vibration. + final bool enableFeedback; + + /// The callback that is called when the box is tapped or otherwise activated. + /// + /// If this callback and [onLongPress] are null, then it will be disabled automatically. + final VoidCallback? onPress; + + /// The callback that is called when long-pressed. + /// + /// If this callback and [onPress] are null, then `PressableBox` will be disabled automatically. + final VoidCallback? onLongPress; final Style? style; final Widget child; final bool disabled; - final VoidCallback? onPressed; - final VoidCallback? onLongPress; final FocusNode? focusNode; final bool autofocus; final Duration unpressDelay; @@ -33,19 +53,19 @@ class PressableBox extends StatelessWidget { final Duration animationDuration; final Curve animationCurve; - final HitTestBehavior? behavior; + final HitTestBehavior? hitTestBehavior; @override Widget build(BuildContext context) { return Pressable( - behavior: behavior, - focusNode: focusNode, - autofocus: autofocus, - onFocusChange: onFocusChange, + disabled: disabled, + onPress: onPress, + hitTestBehavior: hitTestBehavior, onLongPress: onLongPress, + onFocusChange: onFocusChange, + autofocus: autofocus, + focusNode: focusNode, unpressDelay: unpressDelay, - onPressed: onPressed, - disabled: disabled, child: AnimatedBox( style: style, curve: animationCurve, @@ -56,144 +76,277 @@ class PressableBox extends StatelessWidget { } } -class Pressable extends StatefulWidget { +class Pressable extends _PressableBuilderWidget { const Pressable({ - this.behavior, - this.focusNode, - this.autofocus = false, + super.key, + required super.child, + super.disabled, + super.enableFeedback, + @Deprecated('Use onTap instead') VoidCallback? onPressed, + VoidCallback? onPress, + @Deprecated('Use hitTestBehavior instead') HitTestBehavior? behavior, + HitTestBehavior? hitTestBehavior, + super.onLongPress, + super.onFocusChange, + super.autofocus, + super.focusNode, + super.onKey, + super.onKeyEvent, + super.unpressDelay, + }) : super( + onPress: onPress ?? onPressed, + hitTestBehavior: + hitTestBehavior ?? behavior ?? HitTestBehavior.opaque, + ); + + @override + PressableWidgetState createState() => PressableWidgetState(); +} + +class PressableWidgetState extends _PressableBuilderWidgetState {} + +abstract class _PressableBuilderWidget extends StatefulWidget { + const _PressableBuilderWidget({ + super.key, required this.child, - this.onFocusChange, - this.onLongPress, - this.unpressDelay = const Duration(), - this.onPressed, this.disabled = false, - super.key, + this.enableFeedback = false, + this.onPress, + this.onLongPress, + this.onFocusChange, + this.autofocus = false, + this.focusNode, + this.onKey, + this.onKeyEvent, + this.hitTestBehavior = HitTestBehavior.opaque, + this.unpressDelay, }); final Widget child; - final VoidCallback? onPressed; + + final bool disabled; + + /// Should gestures provide audible and/or haptic feedback + /// + /// On platforms like Android, enabling feedback will result in audible and tactile + /// responses to certain actions. For example, a tap may produce a clicking sound, + /// while a long-press may trigger a short vibration. + final bool enableFeedback; + + /// The callback that is called when the box is tapped or otherwise activated. + /// + /// If this callback and [onLongPress] are null, then it will be disabled automatically. + final VoidCallback? onPress; + + /// The callback that is called when long-pressed. + /// + /// If this callback and [onPress] are null, then `PressableBox` will be disabled automatically. final VoidCallback? onLongPress; - final FocusNode? focusNode; + + /// Called when the focus state of the [Focus] changes. + /// + /// Called with true when the [Focus] node gains focus + /// and false when the [Focus] node loses focus. + final ValueChanged? onFocusChange; + + /// {@macro flutter.widgets.Focus.autofocus} final bool autofocus; - final bool disabled; - final Duration unpressDelay; - final Function(bool focus)? onFocusChange; - final HitTestBehavior? behavior; + /// {@macro flutter.widgets.Focus.focusNode} + final FocusNode? focusNode; - @override - PressableWidgetState createState() => PressableWidgetState(); -} + /// {@macro flutter.widgets.Focus.onKey} + final FocusOnKeyCallback? onKey; -class PressableWidgetState extends State { - late FocusNode _node; - bool _isInternalNode = false; - bool _hover = false; - bool _focus = false; - bool _pressed = false; - bool _longpressed = false; + /// {@macro flutter.widgets.Focus.onKeyEvent} + final FocusOnKeyEventCallback? onKeyEvent; - @override - void initState() { - super.initState(); - _node = widget.focusNode ?? _createFocusNode(); - _isInternalNode = widget.focusNode == null; - } + /// {@macro flutter.widgets.GestureDetector.hitTestBehavior} + final HitTestBehavior hitTestBehavior; - FocusNode _createFocusNode() { - return FocusNode(debugLabel: '${widget.runtimeType}'); - } + /// The duration to wait after the press is released before the state of pressed is removed + final Duration? unpressDelay; +} + +abstract class _PressableBuilderWidgetState + extends State { + bool _isHovered = false; + bool _isFocused = false; + bool _isPressed = false; + bool _isLongPressed = false; + Offset _localCursorPosition = Offset.zero; + Alignment _cursorAlignment = Alignment.center; + int _pressCount = 0; - WidgetState get _currentGesture { + PressableState get _currentState { // Long pressed has priority over pressed // Due to delay of removing the _press state - if (_longpressed) return WidgetState.longPressed; + if (_isLongPressed) return PressableState.longPressed; - if (_pressed) return WidgetState.pressed; + if (_isPressed) return PressableState.pressed; - return WidgetState.none; + if (_isHovered) return PressableState.hovered; + + return PressableState.none; } - @override - void didUpdateWidget(Pressable oldWidget) { - super.didUpdateWidget(oldWidget); - if (widget.focusNode != oldWidget.focusNode) { - if (_isInternalNode) _node.dispose(); + void _handleFocusUpdate(bool hasFocus) { + updateState(() { + _isFocused = hasFocus; + }); + } - _node = widget.focusNode ?? _createFocusNode(); - _isInternalNode = widget.focusNode == null; + void _handleHoverUpdate(bool isHovered) { + updateState(() { + _isHovered = isHovered; + }); + } + + void _updateCursorPosition(Offset newLocalCursorPosition) { + if (newLocalCursorPosition != _localCursorPosition) { + setState(() { + _localCursorPosition = newLocalCursorPosition; + _updateCursorAlignment(); + }); } } - @override - void dispose() { - if (_isInternalNode) _node.dispose(); + void _updateCursorAlignment() { + final size = context.size; + if (size != null) { + final ax = _localCursorPosition.dx / size.width; + final ay = _localCursorPosition.dy / size.height; + _cursorAlignment = Alignment( + ((ax - 0.5) * 2).clamp(-1.0, 1.0), + ((ay - 0.5) * 2).clamp(-1.0, 1.0), + ); + } + } + + void _handlePanUpdate(DragUpdateDetails event) { + _updateCursorPosition(event.localPosition); + } + + void _handlePanDown(DragDownDetails details) { + _updateCursorPosition(details.localPosition); + } + + void _handlePanUp(DragEndDetails details) { + _handlePressUpdate(true); + } + + void _handleMouseHover(PointerHoverEvent event) { + _updateCursorPosition(event.localPosition); + } + + void _handleOnMouseEnter(PointerEnterEvent event) { + _updateCursorPosition(event.localPosition); + } - super.dispose(); + void _handleOnMouseExit(PointerExitEvent event) { + _updateCursorPosition(event.localPosition); } - void handleUnpress() { - void unpress() { - if (!_pressed) return; - updateState(() => _pressed = false); + void _handlePressUpdate(bool isPressed) { + if (isPressed == _isPressed) return; + + updateState(() { + _isPressed = isPressed; + }); + + if (isPressed) { + _pressCount++; + final initialPressCount = _pressCount; + unpressAfterDelay(initialPressCount); } + } - Future.delayed(widget.unpressDelay, unpress); + void handleLongPressUpdate(bool isLongPressed) { + if (isLongPressed == _isLongPressed) return; + + updateState(() { + _isLongPressed = isLongPressed; + }); } - updateState(void Function() fn) { + void updateState(void Function() fn) { if (!mounted) return; setState(fn); } - @override - Widget build(BuildContext context) { - final currentGesture = _currentGesture; - final currentStatus = - widget.disabled ? WidgetStatus.disabled : WidgetStatus.enabled; + Future unpressAfterDelay(int initialPressCount) async { + final delay = widget.unpressDelay; + + if (delay != null && delay != Duration.zero) { + await Future.delayed(delay); + } + + if (_isPressed && _pressCount == initialPressCount) { + updateState(() => _isPressed = false); + } + } + + bool get isEnabled { + return !widget.disabled && + (widget.onPress != null || widget.onLongPress != null); + } + + bool get isDisabled => !isEnabled; + + void handleOnPress() { + if (!mounted) return; + widget.onPress?.call(); + if (widget.enableFeedback) Feedback.forTap(context); + + _handlePressUpdate(true); + } - final onEnabled = currentStatus == WidgetStatus.enabled; + void handleOnLongPress() { + if (!mounted) return; + + widget.onLongPress?.call(); + if (widget.enableFeedback) Feedback.forLongPress(context); + } - final focusableDetector = FocusableActionDetector( - enabled: onEnabled, - focusNode: _node, + @override + Widget build(BuildContext context) { + final focusableDetector = CustomFocusableActionDetector( + enabled: isEnabled, + focusNode: widget.focusNode, autofocus: widget.autofocus, - onShowFocusHighlight: (v) => updateState(() => _focus = v), - onShowHoverHighlight: (v) => updateState(() => _hover = v), + onShowFocusHighlight: _handleFocusUpdate, + onShowHoverHighlight: _handleHoverUpdate, onFocusChange: widget.onFocusChange, - child: WidgetStateNotifier( - data: WidgetStateData( - focus: _focus, - status: currentStatus, - state: currentGesture, - hover: _hover, + onMouseEnter: _handleOnMouseEnter, + onMouseExit: _handleOnMouseExit, + onMouseHover: _handleMouseHover, + child: PressableDataNotifier( + data: PressableStateData( + focused: _isFocused, + disabled: isDisabled, + state: _currentState, + cursorPosition: CursorPosition( + alignment: _cursorAlignment, + offset: _localCursorPosition, + ), ), child: widget.child, ), ); - return MergeSemantics( - child: Semantics( - enabled: onEnabled, - button: true, - focusable: onEnabled && _node.canRequestFocus, - focused: _node.hasFocus, - child: widget.disabled - ? GestureDetector(child: focusableDetector) - : GestureDetector( - onTapDown: (_) => updateState(() => _pressed = true), - onTapUp: (_) => handleUnpress(), - onTap: widget.onPressed, - onTapCancel: () => handleUnpress(), - onLongPressCancel: () => - updateState(() => _longpressed = false), - onLongPress: widget.onLongPress, - onLongPressStart: (_) => updateState(() => _longpressed = true), - onLongPressEnd: (_) => updateState(() => _longpressed = false), - behavior: widget.behavior, - child: focusableDetector, - ), - ), + return GestureDetector( + onTapUp: isEnabled ? (_) => _handlePressUpdate(false) : null, + onTap: isEnabled ? handleOnPress : null, + onTapCancel: isEnabled ? () => _handlePressUpdate(false) : null, + onLongPressCancel: isEnabled ? () => handleLongPressUpdate(false) : null, + onLongPress: isEnabled ? handleOnLongPress : null, + onLongPressStart: isEnabled ? (_) => handleLongPressUpdate(true) : null, + onLongPressEnd: isEnabled ? (_) => handleLongPressUpdate(false) : null, + onPanDown: isEnabled ? _handlePanDown : null, + onPanUpdate: isEnabled ? _handlePanUpdate : null, + onPanEnd: isEnabled ? _handlePanUp : null, + behavior: widget.hitTestBehavior, + child: focusableDetector, ); } } diff --git a/lib/src/widgets/pressable/widget_state_util.dart b/lib/src/widgets/pressable/widget_state_util.dart deleted file mode 100644 index 26441235d..000000000 --- a/lib/src/widgets/pressable/widget_state_util.dart +++ /dev/null @@ -1,61 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../../helpers/string_ext.dart'; -import '../../variants/variant.dart'; -import 'pressable_state.notifier.dart'; - -/// Global context variants for handling common widget states and gestures. - -/// Applies styles when the widget is pressed. -final onPressed = _onState(WidgetState.pressed); - -/// Applies styles when the widget is long pressed. -final onLongPressed = _onState(WidgetState.longPressed); - -/// Applies styles when the widget is disabled. -final onDisabled = _onStatus(WidgetStatus.disabled); - -/// Applies styles when the widget is enabled. -final onEnabled = _onStatus(WidgetStatus.enabled); - -/// Applies styles when the widget has focus.dar -final onFocused = ContextVariant( - 'on-focused', - - /// Applies the variant only when the GestureStateNotifier's focus property is true. - (context) => WidgetStateNotifier.of(context)?.focus == true, -); - -/// Applies styles when the widget is being hovered over. -final onHover = ContextVariant( - 'on-hover', - - /// Applies the variant only when the GestureStateNotifier's hover property is true. - (context) => WidgetStateNotifier.of(context)?.hover == true, -); - -/// Helper class for creating widget state-based context variants. -@immutable -class WidgetStateVariant extends ContextVariant { - const WidgetStateVariant(super.name, super.when); -} - -/// Creates a [WidgetStateVariant] based on the specified [state]. -/// -/// This function constructs a WidgetStateVariant with a name based on the provided state and a condition that checks if the GestureStateNotifier in the context matches the given state. -WidgetStateVariant _onState(WidgetState state) { - return WidgetStateVariant( - 'on-${state.name.paramCase}', - (context) => WidgetStateNotifier.of(context)?.state == state, - ); -} - -/// Creates a [WidgetStateVariant] based on the specified [status]. -/// -/// Similar to `_onState`, this function creates a WidgetStateVariant with a condition that checks if the GestureStateNotifier in the context matches the provided status. -WidgetStateVariant _onStatus(WidgetStatus status) { - return WidgetStateVariant( - 'on-${status.name.paramCase}', - (context) => WidgetStateNotifier.of(context)?.status == status, - ); -} diff --git a/pubspec.yaml b/pubspec.yaml index 72e73ea05..992a6a2cc 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -4,7 +4,7 @@ version: 1.0.0-beta.6 homepage: https://github.com/leoafarias/mix environment: - sdk: ">=2.17.0 <4.0.0" + sdk: ">=3.0.0 <4.0.0" dependencies: flutter: diff --git a/test/helpers/testing_utils.dart b/test/helpers/testing_utils.dart index 643ce7238..0a652bf2a 100644 --- a/test/helpers/testing_utils.dart +++ b/test/helpers/testing_utils.dart @@ -114,19 +114,17 @@ extension WidgetTesterExt on WidgetTester { Future pumpWithPressable( Widget widget, { - WidgetStateData data = const WidgetStateData.none(), - WidgetState state = WidgetState.none, - WidgetStatus status = WidgetStatus.enabled, + PressableStateData data = const PressableStateData.none(), + PressableState state = PressableState.none, + bool disabled = false, bool focus = false, - bool hover = false, }) async { await pumpWidget( MaterialApp( - home: WidgetStateNotifier( + home: PressableDataNotifier( data: data.copyWith( - focus: focus, - hover: hover, - status: status, + focused: focus, + disabled: disabled, state: state, ), child: widget, diff --git a/test/src/widgets/pressable/pressable_data.notifier_test.dart b/test/src/widgets/pressable/pressable_data.notifier_test.dart new file mode 100644 index 000000000..f1d5945ae --- /dev/null +++ b/test/src/widgets/pressable/pressable_data.notifier_test.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mix/mix.dart'; + +import '../../../helpers/testing_utils.dart'; + +void main() { + group('PressableNotifier', () { + const gestureData = PressableStateData( + focused: true, + disabled: false, + state: PressableState.pressed, + cursorPosition: CursorPosition( + alignment: Alignment.center, + offset: Offset.zero, + ), + ); + test('constructor', () { + final notifier = PressableDataNotifier( + data: gestureData, + child: Container(), + ); + + expect(notifier.data.state, PressableState.pressed); + expect(notifier.data.focused, true); + expect(notifier.child, isA()); + }); + + test('of', () { + final notifier = PressableDataNotifier( + data: gestureData, + child: Container(), + ); + + final otherNotifier = PressableDataNotifier( + data: const PressableStateData( + focused: false, + disabled: true, + state: PressableState.none, + cursorPosition: CursorPosition( + alignment: Alignment.center, + offset: Offset.zero, + ), + ), + child: Container(), + ); + + final sameNotifier = PressableDataNotifier( + data: gestureData, + child: Container(), + ); + + expect(notifier.updateShouldNotify(otherNotifier), true); + expect(notifier.updateShouldNotify(sameNotifier), false); + expect( + () => PressableDataNotifier.of(MockBuildContext()), + throwsAssertionError, + ); + }); + + testWidgets('can get it from context', (tester) async { + await tester.pumpWithPressable( + Container(), + state: PressableState.pressed, + focus: true, + ); + + final context = tester.element(find.byType(Container)); + + final notifier = PressableDataNotifier.of(context); + + expect(notifier, isA()); + expect(notifier.state, PressableState.pressed); + expect(notifier.focused, true); + }); + }); +} diff --git a/test/src/widgets/pressable/pressable_state.notifier_test.dart b/test/src/widgets/pressable/pressable_state.notifier_test.dart deleted file mode 100644 index ea6af56ee..000000000 --- a/test/src/widgets/pressable/pressable_state.notifier_test.dart +++ /dev/null @@ -1,68 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mix/mix.dart'; - -import '../../../helpers/testing_utils.dart'; - -void main() { - group('PressableNotifier', () { - const gestureData = WidgetStateData( - focus: true, - status: WidgetStatus.enabled, - state: WidgetState.pressed, - hover: false, - ); - test('constructor', () { - final notifier = WidgetStateNotifier( - data: gestureData, - child: Container(), - ); - - expect(notifier.data.state, WidgetState.pressed); - expect(notifier.data.focus, true); - expect(notifier.child, isA()); - }); - - test('of', () { - final notifier = WidgetStateNotifier( - data: gestureData, - child: Container(), - ); - - final otherNotifier = WidgetStateNotifier( - data: const WidgetStateData( - focus: false, - status: WidgetStatus.disabled, - state: WidgetState.none, - hover: false, - ), - child: Container(), - ); - - final sameNotifier = WidgetStateNotifier( - data: gestureData, - child: Container(), - ); - - expect(notifier.updateShouldNotify(otherNotifier), true); - expect(notifier.updateShouldNotify(sameNotifier), false); - expect(WidgetStateNotifier.of(MockBuildContext()), null); - }); - - testWidgets('can get it from context', (tester) async { - await tester.pumpWithPressable( - Container(), - state: WidgetState.pressed, - focus: true, - ); - - final context = tester.element(find.byType(Container)); - - final notifier = WidgetStateNotifier.of(context); - - expect(notifier, isA()); - expect(notifier!.state, WidgetState.pressed); - expect(notifier.focus, true); - }); - }); -} diff --git a/test/src/widgets/pressable/widget_state_util_test.dart b/test/src/widgets/pressable/pressable_util_test.dart similarity index 90% rename from test/src/widgets/pressable/widget_state_util_test.dart rename to test/src/widgets/pressable/pressable_util_test.dart index 1b38bb3a7..479b0925f 100644 --- a/test/src/widgets/pressable/widget_state_util_test.dart +++ b/test/src/widgets/pressable/pressable_util_test.dart @@ -13,7 +13,7 @@ void main() { testWidgets('press state', (tester) async { await tester.pumpWithPressable( Container(), - state: WidgetState.pressed, + state: PressableState.pressed, focus: true, ); @@ -30,7 +30,7 @@ void main() { testWidgets('long press state', (tester) async { await tester.pumpWithPressable( Container(), - state: WidgetState.longPressed, + state: PressableState.longPressed, focus: true, ); @@ -48,7 +48,11 @@ void main() { }); testWidgets('hover state', (tester) async { - await tester.pumpWithPressable(Container(), focus: true, hover: true); + await tester.pumpWithPressable( + Container(), + state: PressableState.hovered, + focus: true, + ); final context = tester.element(find.byType(Container)); @@ -56,14 +60,14 @@ void main() { expect(onHoverAttr.when(context), true); expect(onHoverAttr.value, Style(attribute1, attribute2, attribute3)); - expect(onHoverAttr.variant.name, 'on-hover'); + expect(onHoverAttr.variant.name, 'on-hovered'); expect(onHoverAttr.variant.when(context), true); }); testWidgets('disabled state', (tester) async { await tester.pumpWithPressable( Container(), - status: WidgetStatus.disabled, + disabled: true, focus: true, ); @@ -84,7 +88,7 @@ void main() { testWidgets('enabled state', (tester) async { await tester.pumpWithPressable( Container(), - state: WidgetState.pressed, + state: PressableState.pressed, focus: true, ); @@ -102,7 +106,7 @@ void main() { testWidgets('focus state', (tester) async { await tester.pumpWithPressable( Container(), - state: WidgetState.pressed, + state: PressableState.pressed, focus: true, ); diff --git a/test/src/widgets/pressable/pressable_widget_test.dart b/test/src/widgets/pressable/pressable_widget_test.dart index 68ee66431..cdaebf14d 100644 --- a/test/src/widgets/pressable/pressable_widget_test.dart +++ b/test/src/widgets/pressable/pressable_widget_test.dart @@ -13,14 +13,17 @@ void main() { testWidgets('Pressable', (tester) async { final firstKey = UniqueKey(); final secondKey = UniqueKey(); + final thirdKey = UniqueKey(); await tester.pumpWidget(Column( children: [ - Pressable(onPressed: null, child: Container(key: firstKey)), + Pressable(onPress: null, child: Container(key: firstKey)), Pressable( - onPressed: null, disabled: true, + onPress: null, child: Container(key: secondKey), ), + // Test with a onpress function + Pressable(onPress: () {}, child: Container(key: thirdKey)), ], )); @@ -28,14 +31,19 @@ void main() { final firstContext = tester.element(find.byKey(firstKey)); final secondContext = tester.element(find.byKey(secondKey)); + final thirdContext = tester.element(find.byKey(thirdKey)); - final firstNotifier = WidgetStateNotifier.of(firstContext); - final secondNotifier = WidgetStateNotifier.of(secondContext); + final firstNotifier = PressableDataNotifier.of(firstContext); + final secondNotifier = PressableDataNotifier.of(secondContext); + final thirdNotifier = PressableDataNotifier.of(thirdContext); - expect(onEnabledAttr.when(firstContext), true); - expect(firstNotifier!.status, WidgetStatus.enabled); + expect(onEnabledAttr.when(firstContext), false); + expect(firstNotifier.disabled, true); expect(onEnabledAttr.when(secondContext), false); - expect(secondNotifier!.status, WidgetStatus.disabled); + expect(secondNotifier.disabled, true); + + expect(onEnabledAttr.when(thirdContext), true); + expect(thirdNotifier.disabled, false); }); testWidgets( @@ -45,10 +53,10 @@ void main() { await tester.pumpWidget( Pressable( - onPressed: () { + disabled: false, + onPress: () { counter++; }, - disabled: false, child: Container(), ), ); @@ -70,10 +78,10 @@ void main() { await tester.pumpWidget( Pressable( - onPressed: () { + disabled: true, + onPress: () { counter++; }, - disabled: true, child: Container(), ), ); @@ -97,10 +105,10 @@ void main() { await tester.pumpWidget( PressableBox( - onPressed: () { + unpressDelay: Duration.zero, + onPress: () { counter++; }, - unpressDelay: Duration.zero, animationDuration: Duration.zero, disabled: false, child: Container(), @@ -124,10 +132,10 @@ void main() { await tester.pumpWidget( PressableBox( - onPressed: () { + unpressDelay: Duration.zero, + onPress: () { counter++; }, - unpressDelay: Duration.zero, animationDuration: Duration.zero, disabled: true, child: Container(), diff --git a/tool/update_exports.dart b/tool/update_exports.dart index 2f00200ca..77f27e535 100755 --- a/tool/update_exports.dart +++ b/tool/update_exports.dart @@ -18,19 +18,13 @@ void main() { exportFile.deleteSync(); } - final defaultExports = {'export \'src/deprecations.dart\';'}; - final outputString = StringBuffer(); - outputString.writeln('library mix;'); - outputString.writeln(''); outputString - .writeln('// This file is generated by the update_exports.dart script.'); - outputString.writeln('// DO NOT MODIFY MANUALLY'); + .writeln('/// This file is generated by the update_exports.dart script.'); + outputString.writeln('/// DO NOT MODIFY MANUALLY'); outputString.writeln(''); - outputString.writeln('// Some default exports'); - defaultExports.forEach(outputString.writeln); - + outputString.writeln('library mix;'); outputString.writeln(''); outputString.writeln('// Automated file exports'); @@ -57,12 +51,6 @@ void main() { continue; } - final filePath = 'export \'$relativePath\';'; - - if (defaultExports.contains(filePath)) { - continue; - } - outputString.writeln('export \'$relativePath\';'); } diff --git a/website/pages/docs/overview/best-practices.mdx b/website/pages/docs/overview/best-practices.mdx index 9eec616d3..2395463bc 100644 --- a/website/pages/docs/overview/best-practices.mdx +++ b/website/pages/docs/overview/best-practices.mdx @@ -47,16 +47,16 @@ class CustomButton extends StatelessWidget { const CustomButton({ super.key, required this.title, - required this.onPressed, + required this.onPress, }); final String title; - final void Function() onPressed; + final void Function() onPress; @override Widget build(BuildContext context) { return PressableBox( - onPressed: onPressed, + onPress: onPress, child: StyledText( title, ), @@ -101,20 +101,20 @@ class CustomButton extends StatelessWidget { const CustomButton({ super.key, required this.title, - required this.onPressed, + required this.onPress, this.type = CustomButtonType.primary, this.size = CustomButtonSize.large, }); final String title; - final void Function() onPressed; + final void Function() onPress; final CustomButtonType type; final CustomButtonSize size; @override Widget build(BuildContext context) { return PressableBox( - onPressed: onPressed, + onPress: onPress, child: StyledText( title, ), @@ -185,13 +185,13 @@ class CustomButton extends StatelessWidget { const CustomButton({ super.key, required this.title, - required this.onPressed, + required this.onPress, this.type = CustomButtonType.primary, this.size = CustomButtonSize.large, }); final String title; - final void Function() onPressed; + final void Function() onPress; final CustomButtonType type; final CustomButtonSize size; @@ -199,7 +199,7 @@ class CustomButton extends StatelessWidget { Widget build(BuildContext context) { final style = CustomButtonStyle(type, size); return PressableBox( - onPressed: onPressed, + onPress: onPress, style: style.container(), child: StyledText( title,