diff --git a/packages/mix/lib/src/core/widget_state/internal/gesture_mix_state.dart b/packages/mix/lib/src/core/widget_state/internal/gesture_mix_state.dart index 21f1673e9..c375e0992 100644 --- a/packages/mix/lib/src/core/widget_state/internal/gesture_mix_state.dart +++ b/packages/mix/lib/src/core/widget_state/internal/gesture_mix_state.dart @@ -24,6 +24,22 @@ class GestureMixStateWidget extends StatefulWidget { this.onPanStart, this.excludeFromSemantics = false, this.hitTestBehavior = HitTestBehavior.opaque, + this.onDoubleTapDown, + this.onDoubleTapCancel, + this.onScaleStart, + this.onScaleUpdate, + this.onScaleEnd, + this.onForcePressStart, + this.onForcePressPeak, + this.onForcePressUpdate, + this.onForcePressEnd, + this.onSecondaryTap, + this.onSecondaryTapDown, + this.onSecondaryTapUp, + this.onSecondaryTapCancel, + this.onTertiaryTapDown, + this.onTertiaryTapUp, + this.onTertiaryTapCancel, required this.unpressDelay, }); @@ -79,6 +95,54 @@ class GestureMixStateWidget extends StatefulWidget { /// The duration to wait after the press is released before updating the press state. final Duration unpressDelay; + /// The callback that is called when the user starts a double tap gesture. + final GestureTapDownCallback? onDoubleTapDown; + + /// The callback that is called when the user cancels a double tap gesture. + final GestureTapCancelCallback? onDoubleTapCancel; + + /// The callback that is called when the user starts a scale gesture. + final GestureScaleStartCallback? onScaleStart; + + /// The callback that is called when the user updates a scale gesture. + final GestureScaleUpdateCallback? onScaleUpdate; + + /// The callback that is called when the user ends a scale gesture. + final GestureScaleEndCallback? onScaleEnd; + + /// The callback that is called when the user starts a force press gesture. + final GestureForcePressStartCallback? onForcePressStart; + + /// The callback that is called when the force press gesture reaches its peak force. + final GestureForcePressPeakCallback? onForcePressPeak; + + /// The callback that is called when the force press gesture is updated. + final GestureForcePressUpdateCallback? onForcePressUpdate; + + /// The callback that is called when the force press gesture ends. + final GestureForcePressEndCallback? onForcePressEnd; + + /// The callback that is called when the user performs a secondary tap gesture. + final GestureTapCallback? onSecondaryTap; + + /// The callback that is called when the user starts a secondary tap gesture. + final GestureTapDownCallback? onSecondaryTapDown; + + /// The callback that is called when the user ends a secondary tap gesture. + final GestureTapUpCallback? onSecondaryTapUp; + + /// The callback that is called when the user cancels a secondary tap gesture. + final GestureTapCancelCallback? onSecondaryTapCancel; + + /// The callback that is called when the user starts a tertiary tap gesture. + final GestureTapDownCallback? onTertiaryTapDown; + + /// The callback that is called when the user ends a tertiary tap gesture. + final GestureTapUpCallback? onTertiaryTapUp; + + /// The callback that is called when the user cancels a tertiary tap gesture. + final GestureTapCancelCallback? onTertiaryTapCancel; + @override State createState() => _GestureMixStateWidgetState(); } @@ -178,10 +242,74 @@ class _GestureMixStateWidgetState extends State { if (widget.enableFeedback) Feedback.forLongPress(context); } + void _onDoubleTapDown(TapDownDetails details) { + widget.onDoubleTapDown?.call(details); + } + + void _onDoubleTapCancel() { + widget.onDoubleTapCancel?.call(); + } + + void _onScaleStart(ScaleStartDetails details) { + widget.onScaleStart?.call(details); + } + + void _onScaleUpdate(ScaleUpdateDetails details) { + widget.onScaleUpdate?.call(details); + } + + void _onScaleEnd(ScaleEndDetails details) { + widget.onScaleEnd?.call(details); + } + + void _onForcePressStart(ForcePressDetails details) { + widget.onForcePressStart?.call(details); + } + + void _onForcePressPeak(ForcePressDetails details) { + widget.onForcePressPeak?.call(details); + } + + void _onForcePressUpdate(ForcePressDetails details) { + widget.onForcePressUpdate?.call(details); + } + + void _onForcePressEnd(ForcePressDetails details) { + widget.onForcePressEnd?.call(details); + } + + void _onSecondaryTap() { + widget.onSecondaryTap?.call(); + } + + void _onSecondaryTapDown(TapDownDetails details) { + widget.onSecondaryTapDown?.call(details); + } + + void _onSecondaryTapUp(TapUpDetails details) { + widget.onSecondaryTapUp?.call(details); + } + + void _onSecondaryTapCancel() { + widget.onSecondaryTapCancel?.call(); + } + + void _onTertiaryTapDown(TapDownDetails details) { + widget.onTertiaryTapDown?.call(details); + } + + void _onTertiaryTapUp(TapUpDetails details) { + widget.onTertiaryTapUp?.call(details); + } + + void _onTertiaryTapCancel() { + widget.onTertiaryTapCancel?.call(); + } + @override void dispose() { _timer?.cancel(); - // Dispose if being managed internally + // Dispose if being managed internally if (widget.controller == null) _controller.dispose(); super.dispose(); } @@ -201,6 +329,31 @@ class _GestureMixStateWidgetState extends State { onPanUpdate: widget.onPanUpdate != null ? _onPanUpdate : null, onPanEnd: widget.onPanEnd != null ? _onPanEnd : null, onPanCancel: widget.onPanCancel != null ? _onPanCancel : null, + onDoubleTapDown: widget.onDoubleTapDown != null ? _onDoubleTapDown : null, + onDoubleTapCancel: + widget.onDoubleTapCancel != null ? _onDoubleTapCancel : null, + onScaleStart: widget.onScaleStart != null ? _onScaleStart : null, + onScaleUpdate: widget.onScaleUpdate != null ? _onScaleUpdate : null, + onScaleEnd: widget.onScaleEnd != null ? _onScaleEnd : null, + onForcePressStart: + widget.onForcePressStart != null ? _onForcePressStart : null, + onForcePressPeak: + widget.onForcePressPeak != null ? _onForcePressPeak : null, + onForcePressUpdate: + widget.onForcePressUpdate != null ? _onForcePressUpdate : null, + onForcePressEnd: widget.onForcePressEnd != null ? _onForcePressEnd : null, + onSecondaryTap: widget.onSecondaryTap != null ? _onSecondaryTap : null, + onSecondaryTapDown: + widget.onSecondaryTapDown != null ? _onSecondaryTapDown : null, + onSecondaryTapUp: + widget.onSecondaryTapUp != null ? _onSecondaryTapUp : null, + onSecondaryTapCancel: + widget.onSecondaryTapCancel != null ? _onSecondaryTapCancel : null, + onTertiaryTapDown: + widget.onTertiaryTapDown != null ? _onTertiaryTapDown : null, + onTertiaryTapUp: widget.onTertiaryTapUp != null ? _onTertiaryTapUp : null, + onTertiaryTapCancel: + widget.onTertiaryTapCancel != null ? _onTertiaryTapCancel : null, behavior: widget.hitTestBehavior, excludeFromSemantics: widget.excludeFromSemantics, child: widget.child, diff --git a/packages/mix/lib/src/core/widget_state/widget_state_controller.dart b/packages/mix/lib/src/core/widget_state/widget_state_controller.dart index b7156177f..cd1d75fed 100644 --- a/packages/mix/lib/src/core/widget_state/widget_state_controller.dart +++ b/packages/mix/lib/src/core/widget_state/widget_state_controller.dart @@ -108,6 +108,19 @@ class MixWidgetStateController extends ChangeNotifier { } } + // disposed flag + bool _disposed = false; + + /// Disposes the controller. + @override + void dispose() { + super.dispose(); + _disposed = true; + } + + /// Checks if the controller has been disposed. + bool get disposed => _disposed; + /// Checks if the widget is currently in the given state [key]. bool has(MixWidgetState key) => value.contains(key); } diff --git a/packages/mix/test/src/core/mix_state/internal/gesture_mix_state_test.dart b/packages/mix/test/src/core/mix_state/internal/gesture_mix_state_test.dart index 35fee09cc..95a2a4504 100644 --- a/packages/mix/test/src/core/mix_state/internal/gesture_mix_state_test.dart +++ b/packages/mix/test/src/core/mix_state/internal/gesture_mix_state_test.dart @@ -5,12 +5,10 @@ import 'package:mix/src/core/widget_state/internal/mix_widget_state_builder.dart import 'package:mix/src/core/widget_state/widget_state_controller.dart'; void main() { - group('GesturableWidget', () { + group('GestureMixStateWidget', () { const key = Key('context_key'); const unpressDelay = Duration(milliseconds: 500); - final controller = MixWidgetStateController(); - GestureMixStateWidget buildGestureMixStateWidget({ Duration unpressDelay = unpressDelay, Function()? onTap, @@ -19,7 +17,18 @@ void main() { Function(DragDownDetails)? onPanDown, Function(DragUpdateDetails)? onPanUpdate, Function(DragEndDetails)? onPanEnd, + Function(TapUpDetails)? onTapUp, + Function()? onTapCancel, + Function(LongPressStartDetails)? onLongPressStart, + Function(LongPressEndDetails)? onLongPressEnd, + Function()? onLongPressCancel, + Function()? onPanCancel, + bool enableFeedback = false, + bool excludeFromSemantics = false, + HitTestBehavior hitTestBehavior = HitTestBehavior.opaque, + MixWidgetStateController? controller, }) { + controller ??= MixWidgetStateController(); return GestureMixStateWidget( controller: controller, onTap: onTap, @@ -29,6 +38,15 @@ void main() { onPanDown: onPanDown, onPanUpdate: onPanUpdate, onPanEnd: onPanEnd, + onTapUp: onTapUp, + onTapCancel: onTapCancel, + onLongPressStart: onLongPressStart, + onLongPressEnd: onLongPressEnd, + onLongPressCancel: onLongPressCancel, + onPanCancel: onPanCancel, + enableFeedback: enableFeedback, + excludeFromSemantics: excludeFromSemantics, + hitTestBehavior: hitTestBehavior, child: MixWidgetStateBuilder( controller: controller, builder: (_) => const SizedBox( @@ -41,110 +59,100 @@ void main() { } testWidgets('should update press state when tapped', (tester) async { + final controller = MixWidgetStateController(); bool onTapCalled = false; + bool onControllerTapCalled = false; await tester.pumpWidget( buildGestureMixStateWidget( + controller: controller, onTap: () { onTapCalled = true; }, ), ); - await tester.tap(find.byType(GestureMixStateWidget)); - await tester.pump(); + controller.addListener(() { + if (onControllerTapCalled) return; + onControllerTapCalled = controller.pressed; + }); - final context = tester.element(find.byKey(key)); + await tester.tap(find.byKey(key)); + await tester.pump(); - expect( - MixWidgetState.hasStateOf(context, MixWidgetState.pressed), - isTrue, - reason: 'GesturableState should be pressed immediately after tap', - ); + expect(onControllerTapCalled, isTrue); expect(onTapCalled, isTrue); }); testWidgets('should update long press state when long pressed', (tester) async { + final controller = MixWidgetStateController(); bool onLongPressCalled = false; + bool onControllerLongPressCalled = false; await tester.pumpWidget( buildGestureMixStateWidget( + controller: controller, onLongPress: () { onLongPressCalled = true; }, ), ); - await tester.longPress(find.byType(GestureMixStateWidget)); - final context = tester.element(find.byKey(key)); + controller.addListener(() { + if (onControllerLongPressCalled) return; + onControllerLongPressCalled = controller.longPressed; + }); + await tester.longPress(find.byKey(key)); + await tester.pump(); + + expect(onControllerLongPressCalled, isTrue); expect(onLongPressCalled, isTrue); - expect(MixWidgetState.hasStateOf(context, MixWidgetState.longPressed), - isTrue); }); testWidgets('should update press state after delay when tapped', (tester) async { + final controller = MixWidgetStateController(); await tester.pumpWidget( buildGestureMixStateWidget( + controller: controller, onTap: () {}, unpressDelay: const Duration(milliseconds: 100), ), ); - await tester.tap(find.byType(GestureMixStateWidget)); + await tester.tap(find.byKey(key)); await tester.pump(); - final context = tester.element(find.byKey(key)); - expect( - MixWidgetState.hasStateOf(context, MixWidgetState.pressed), - isTrue, - reason: 'GesturableState should be pressed immediately after tap', - ); + expect(controller.pressed, isTrue); - await tester.pump( - const Duration(milliseconds: 50), - ); - expect( - MixWidgetState.hasStateOf(context, MixWidgetState.pressed), - isTrue, - reason: 'GesturableState should still be pressed 50ms after tap', - ); + await tester.pump(const Duration(milliseconds: 50)); + expect(controller.pressed, isTrue); - await tester.pump( - const Duration(milliseconds: 100), - ); - expect( - MixWidgetState.hasStateOf(context, MixWidgetState.pressed), - isFalse, - reason: - 'GesturableState should be unpressed after unpressDelay has passed', - ); + await tester.pump(const Duration(milliseconds: 100)); + expect(controller.pressed, isFalse); }); testWidgets('should not update press state when disabled', (tester) async { - await tester.pumpWidget( - buildGestureMixStateWidget( - onTap: () {}, - unpressDelay: Duration.zero, - ), - ); + final controller = MixWidgetStateController(); + await tester + .pumpWidget(buildGestureMixStateWidget(controller: controller)); - await tester.tap(find.byType(GestureMixStateWidget)); - final context = tester.element(find.byKey(key)); - expect( - MixWidgetState.hasStateOf(context, MixWidgetState.pressed), isFalse); + await tester.tap(find.byKey(key)); + await tester.pump(); + expect(controller.pressed, isFalse); }); - testWidgets('GesturableWidget pan functions test', ( - WidgetTester tester, - ) async { + testWidgets('should handle pan gestures correctly', (tester) async { bool isPanStartCalled = false; bool isPanDownCalled = false; bool isPanUpdateCalled = false; bool isPanEndCalled = false; + final controller = MixWidgetStateController(); + await tester.pumpWidget( MaterialApp( home: buildGestureMixStateWidget( + controller: controller, onPanStart: (details) { isPanStartCalled = true; }, @@ -157,69 +165,131 @@ void main() { onPanEnd: (details) { isPanEndCalled = true; }, - unpressDelay: Duration.zero, ), ), ); - final gesturableWidget = find.byType(GestureMixStateWidget); - expect(gesturableWidget, findsOneWidget); - - final gesturableWidgetCenter = tester.getCenter(gesturableWidget); - final gesture = await tester.startGesture(gesturableWidgetCenter); - await gesture.moveBy(const Offset(50, 50), timeStamp: Durations.medium1); - // move back to the original position - await gesture.moveBy(const Offset(-50, -50), - timeStamp: Durations.medium1); + final gesture = + await tester.startGesture(tester.getCenter(find.byKey(key))); + await gesture.moveBy(const Offset(50, 50)); await gesture.up(); - // move it again but cancel it - await gesture.down(gesturableWidgetCenter); + await tester.pump(); - await gesture.moveBy(const Offset(50, 50)); - await gesture.cancel(timeStamp: const Duration(milliseconds: 100)); - await tester.pump(const Duration(milliseconds: 200)); + expect(isPanStartCalled, isTrue); + expect(isPanDownCalled, isTrue); + expect(isPanUpdateCalled, isTrue); + expect(isPanEndCalled, isTrue); + }); - expect( - isPanStartCalled, - isTrue, - reason: 'onPanStart was not called', + testWidgets('should provide feedback when enabled', (tester) async { + final controller = MixWidgetStateController(); + + bool onTapCalled = false; + bool onLongPressCalled = false; + await tester.pumpWidget( + buildGestureMixStateWidget( + controller: controller, + onTap: () { + onTapCalled = true; + }, + onLongPress: () { + onLongPressCalled = true; + }, + enableFeedback: true, + ), ); - expect( - isPanDownCalled, - isTrue, - reason: 'onPanDown was not called', + + await tester.tap(find.byKey(key)); + await tester.pump(); + expect(onTapCalled, isTrue); + + await tester.longPress(find.byKey(key)); + await tester.pump(); + expect(onLongPressCalled, isTrue); + }); + + testWidgets('should exclude from semantics when set', (tester) async { + await tester.pumpWidget( + buildGestureMixStateWidget( + excludeFromSemantics: true, + ), ); - expect( - isPanUpdateCalled, - isTrue, - reason: 'onPanUpdate was not called', + + final widget = tester.widget(find.byType(GestureMixStateWidget)) + as GestureMixStateWidget; + + expect(widget.excludeFromSemantics, isTrue); + }); + + testWidgets('should handle different hit test behaviors', (tester) async { + final controller = MixWidgetStateController(); + await tester.pumpWidget( + buildGestureMixStateWidget( + controller: controller, + onTap: () {}, + hitTestBehavior: HitTestBehavior.translucent, + ), ); - expect( - isPanEndCalled, - isTrue, - reason: 'onPanEnd was not called', + + await tester.tap(find.byKey(key)); + await tester.pump(); + expect(controller.pressed, isTrue); + }); + + testWidgets('should update state with external controller', (tester) async { + final externalController = MixWidgetStateController(); + await tester.pumpWidget( + buildGestureMixStateWidget( + onTap: () {}, + controller: externalController, + ), ); + + await tester.tap(find.byKey(key)); + await tester.pump(); + expect(externalController.pressed, isTrue); }); - testWidgets( - 'should propagate the onTap when it doesn\'t receive null', - (tester) async { - bool onTapCalled = false; + testWidgets('should handle null callbacks gracefully', (tester) async { + final controller = MixWidgetStateController(); + await tester + .pumpWidget(buildGestureMixStateWidget(controller: controller)); - await tester.pumpWidget( - GestureDetector( - onTap: () { - onTapCalled = true; - }, - child: buildGestureMixStateWidget(), - ), - ); + await tester.tap(find.byKey(key)); + await tester.pump(); + expect(controller.pressed, isFalse); + }); - await tester.tap(find.byType(GestureMixStateWidget)); + testWidgets('should dispose internal controller when disposed', + (tester) async { + final controller = MixWidgetStateController(); + await tester.pumpWidget(_DisposableController(controller: controller)); + await tester.pumpWidget(Container()); - expect(onTapCalled, isTrue); - }, - ); + expect(controller.disposed, isTrue); + }); }); } + +class _DisposableController extends StatefulWidget { + final MixWidgetStateController controller; + + const _DisposableController({required this.controller}); + + @override + State createState() => _DisposableControllerState(); +} + +class _DisposableControllerState extends State<_DisposableController> { + @override + void dispose() { + widget.controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return const SizedBox(); + } +}