diff --git a/flutter/lib/mobile/widgets/floating_mouse_widgets.dart b/flutter/lib/mobile/widgets/floating_mouse_widgets.dart index dbcc606af..7228244f7 100644 --- a/flutter/lib/mobile/widgets/floating_mouse_widgets.dart +++ b/flutter/lib/mobile/widgets/floating_mouse_widgets.dart @@ -372,6 +372,8 @@ class _FloatingLeftRightButtonState extends State { Offset _position = Offset.zero; bool _isInitialized = false; bool _isDown = false; + Object? _activePress; + bool _remoteButtonDown = false; Rect? _lastBlockedRect; Orientation? _previousOrientation; @@ -398,6 +400,11 @@ class _FloatingLeftRightButtonState extends State { @override void dispose() { + _activePress = null; + if (_remoteButtonDown) { + _remoteButtonDown = false; + _inputModel.tapUp(_isLeft ? MouseButtons.left : MouseButtons.right); + } if (_lastBlockedRect != null) { _cursorModel.removeBlockedRect(_lastBlockedRect!); } @@ -569,6 +576,8 @@ class _FloatingLeftRightButtonState extends State { child: Listener( onPointerMove: _onBodyPointerMoveUpdate, onPointerDown: (event) async { + final press = Object(); + _activePress = press; _isDragging = false; setState(() { _isDown = true; @@ -579,6 +588,8 @@ class _FloatingLeftRightButtonState extends State { isSpecialHoldDragActive = true; () async { await _cursorModel.syncCursorPosition(); + if (!mounted || !identical(_activePress, press)) return; + _remoteButtonDown = true; await _inputModel .tapDown(_isLeft ? MouseButtons.left : MouseButtons.right); }(); @@ -586,6 +597,8 @@ class _FloatingLeftRightButtonState extends State { }); }, onPointerUp: (event) { + if (!mounted) return; + _activePress = null; _cursorModel.blockEvents = false; setState(() { _isDown = false; @@ -604,8 +617,8 @@ class _FloatingLeftRightButtonState extends State { })); } else { // If it's not a quick tap, it could be a hold or drag. - // If it was a hold, isSpecialHoldDragActive is true. - if (isSpecialHoldDragActive) { + if (_remoteButtonDown) { + _remoteButtonDown = false; _inputModel .tapUp(_isLeft ? MouseButtons.left : MouseButtons.right); } @@ -617,13 +630,16 @@ class _FloatingLeftRightButtonState extends State { isSpecialHoldDragActive = false; }, onPointerCancel: (event) { + if (!mounted) return; + _activePress = null; _cursorModel.blockEvents = false; setState(() { _isDown = false; }); _tapDownTimer?.cancel(); _tapDownTimer = null; - if (isSpecialHoldDragActive) { + if (_remoteButtonDown) { + _remoteButtonDown = false; _inputModel.tapUp(_isLeft ? MouseButtons.left : MouseButtons.right); } isSpecialHoldDragActive = false; diff --git a/flutter/test/floating_mouse_hold_test.dart b/flutter/test/floating_mouse_hold_test.dart new file mode 100644 index 000000000..08b88ead0 --- /dev/null +++ b/flutter/test/floating_mouse_hold_test.dart @@ -0,0 +1,99 @@ +@TestOn('browser') + +import 'dart:async'; +import 'dart:js' as js; + +import 'package:flutter/material.dart'; +import 'package:flutter_hbb/common/widgets/remote_input.dart'; +import 'package:flutter_hbb/mobile/widgets/floating_mouse_widgets.dart'; +import 'package:flutter_hbb/models/input_model.dart'; +import 'package:flutter_hbb/models/model.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _Input extends Fake implements InputModel { + final events = <(String, MouseButtons)>[]; + + @override + Future tapDown(MouseButtons button) async => + events.add(('down', button)); + + @override + Future tapUp(MouseButtons button) async => events.add(('up', button)); +} + +class _FFI extends Fake implements FFI {} + +class _Cursor extends CursorModel { + _Cursor(FFI ffi) : super(WeakReference(ffi)); + final syncs = >[]; + + @override + Future syncCursorPosition() { + final sync = Completer(); + syncs.add(sync); + return sync.future; + } +} + +void main() { + setUp(() { + isSpecialHoldDragActive = false; + js.context['isMobile'] = js.allowInterop(() => true); + js.context['getByName'] = + js.allowInterop((String name, [String? key]) => ''); + js.context['setByName'] = js.allowInterop((String name, dynamic value) {}); + }); + tearDown(() { + isSpecialHoldDragActive = false; + for (final name in ['isMobile', 'getByName', 'setByName']) { + js.context.deleteProperty(name); + } + }); + + for (final end in ['release', 'cancel', 'dispose']) { + testWidgets('pending hold cannot press after $end', (tester) async { + final cancel = end == 'cancel'; + final ffi = _FFI(); + final input = _Input(); + final cursor = _Cursor(ffi); + final button = cancel ? MouseButtons.right : MouseButtons.left; + await tester.pumpWidget(MaterialApp( + home: Stack(children: [ + FloatingLeftRightButton( + isLeft: !cancel, inputModel: input, cursorModel: cursor), + ]), + )); + await tester.pump(); + final target = find.byType(FloatingLeftRightButton); + final first = await tester.startGesture(tester.getCenter(target)); + await tester.pump(const Duration(milliseconds: 201)); + expect(cursor.syncs, hasLength(1)); + if (end == 'dispose') { + await tester.pumpWidget(const SizedBox()); + cursor.syncs.single.complete(); + await tester.pump(const Duration(milliseconds: 1)); + expect(input.events, isEmpty); + return; + } + if (cancel) { + await first.cancel(); + } else { + await first.up(); + } + // A new press must not make the old synchronization valid again. + final second = await tester.startGesture(tester.getCenter(target)); + await tester.pump(const Duration(milliseconds: 201)); + cursor.syncs.first.complete(); + await tester.pump(); + expect(input.events, isEmpty); + cursor.syncs.last.complete(); + await tester.pump(); + expect(input.events, [('down', button)]); + await second.up(); + expect(input.events, [('down', button), ('up', button)]); + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(milliseconds: 1)); + expect(input.events, [('down', button), ('up', button)]); + }); + } +}