From f83702333d21df7e365be10b6d3b98aaf30752bf Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Mon, 26 Oct 2026 19:15:58 +0000 Subject: [PATCH] Require a two-finger double tap for connection options Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> Signed-off-by: kenny lopez --- mobile/lib/features/pairing/pairing_page.dart | 2 + .../pairing_page/pairing_welcome_view.dart | 5 +- .../pairing_page/two_finger_double_tap.dart | 83 +++++++++++++++++++ .../onboarding/hosted_communities_test.dart | 20 +++++ 4 files changed, 106 insertions(+), 4 deletions(-) create mode 100644 mobile/lib/features/pairing/pairing_page/two_finger_double_tap.dart diff --git a/mobile/lib/features/pairing/pairing_page.dart b/mobile/lib/features/pairing/pairing_page.dart index 2da561dce8..05984efc42 100644 --- a/mobile/lib/features/pairing/pairing_page.dart +++ b/mobile/lib/features/pairing/pairing_page.dart @@ -14,6 +14,7 @@ import 'package:flutter/foundation.dart'; import '../../shared/widgets/frosted_app_bar.dart'; import 'package:flutter/services.dart'; import 'package:flutter/semantics.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:buzz/shared/theme/buzz_icons.dart'; @@ -36,6 +37,7 @@ part 'pairing_page/onboarding_background.dart'; part 'pairing_page/onboarding_colors.dart'; part 'pairing_page/onboarding_glass_button.dart'; part 'pairing_page/pairing_welcome_view.dart'; +part 'pairing_page/two_finger_double_tap.dart'; part 'pairing_page/community_link_form.dart'; part 'pairing_page/onboarding_connection_view.dart'; part 'pairing_page/sas_verification_view.dart'; diff --git a/mobile/lib/features/pairing/pairing_page/pairing_welcome_view.dart b/mobile/lib/features/pairing/pairing_page/pairing_welcome_view.dart index f3d2f28367..70b8d1bd33 100644 --- a/mobile/lib/features/pairing/pairing_page/pairing_welcome_view.dart +++ b/mobile/lib/features/pairing/pairing_page/pairing_welcome_view.dart @@ -345,10 +345,7 @@ class _PairingWelcomeView extends HookWidget { child: Column( children: [ Expanded( - // Keep double-tap recognition off the action buttons so - // their single taps do not wait for the double-tap timeout. - child: GestureDetector( - behavior: HitTestBehavior.opaque, + child: _TwoFingerDoubleTapSurface( onDoubleTap: canRevealOptions ? revealOptions : null, child: Center( child: Column( diff --git a/mobile/lib/features/pairing/pairing_page/two_finger_double_tap.dart b/mobile/lib/features/pairing/pairing_page/two_finger_double_tap.dart new file mode 100644 index 0000000000..beb3235b76 --- /dev/null +++ b/mobile/lib/features/pairing/pairing_page/two_finger_double_tap.dart @@ -0,0 +1,83 @@ +part of '../pairing_page.dart'; + +class _TwoFingerDoubleTapSurface extends HookWidget { + const _TwoFingerDoubleTapSurface({required this.child, this.onDoubleTap}); + + final Widget child; + final VoidCallback? onDoubleTap; + + @override + Widget build(BuildContext context) { + final taps = useMemoized(_TwoFingerTapTracker.new, [onDoubleTap != null]); + return Listener( + behavior: HitTestBehavior.opaque, + onPointerDown: onDoubleTap == null ? null : taps.down, + onPointerMove: onDoubleTap == null ? null : taps.move, + onPointerCancel: onDoubleTap == null ? null : (_) => taps.cancel(), + onPointerUp: onDoubleTap == null + ? null + : (event) => taps.up(event, onDoubleTap!), + child: child, + ); + } +} + +// Observe two short, stationary two-finger chords without competing with taps +// or scrolling in Flutter's gesture arena. +class _TwoFingerTapTracker { + final _starts = {}; + final _down = {}; + Duration _started = Duration.zero; + Duration? _previousEnd; + Offset? _previousCenter; + bool _valid = true; + + void down(PointerDownEvent event) { + if (_down.isEmpty) { + _starts.clear(); + _started = event.timeStamp; + _valid = true; + } + _starts[event.pointer] = event.position; + _down.add(event.pointer); + if (_starts.length > 2) _valid = false; + } + + void move(PointerMoveEvent event) { + final start = _starts[event.pointer]; + if (start != null && (event.position - start).distance > kTouchSlop) { + _valid = false; + } + } + + void cancel() { + _down.clear(); + _starts.clear(); + _previousEnd = null; + _previousCenter = null; + _valid = false; + } + + void up(PointerUpEvent event, VoidCallback onDoubleTap) { + if (!_down.remove(event.pointer) || _down.isNotEmpty) return; + if (!_valid || + _starts.length != 2 || + event.timeStamp - _started > kDoubleTapTimeout) { + cancel(); + return; + } + final center = (_starts.values.first + _starts.values.last) / 2; + final gap = _previousEnd == null ? null : _started - _previousEnd!; + final isDouble = + gap != null && + gap >= kDoubleTapMinTime && + gap <= kDoubleTapTimeout && + (center - _previousCenter!).distance <= kDoubleTapSlop; + _previousEnd = event.timeStamp; + _previousCenter = center; + if (isDouble) { + cancel(); + onDoubleTap(); + } + } +} diff --git a/mobile/test/features/onboarding/hosted_communities_test.dart b/mobile/test/features/onboarding/hosted_communities_test.dart index 1307dad09d..b7861a867d 100644 --- a/mobile/test/features/onboarding/hosted_communities_test.dart +++ b/mobile/test/features/onboarding/hosted_communities_test.dart @@ -298,7 +298,27 @@ void main() { } Future revealOptions(WidgetTester tester) async { + // Ordinary double taps must not expose the hidden connection options. await tester.tapAt(const Offset(24, 120)); await tester.pump(const Duration(milliseconds: 50)); await tester.tapAt(const Offset(24, 120)); + await tester.pumpAndSettle(); + expect(find.text('Use a pairing code').hitTestable(), findsNothing); + + for (var tap = 0; tap < 2; tap++) { + final first = await tester.createGesture(pointer: 11); + final second = await tester.createGesture(pointer: 12); + await first.down( + const Offset(24, 120), + timeStamp: Duration(milliseconds: tap * 70), + ); + await second.down( + const Offset(70, 120), + timeStamp: Duration(milliseconds: tap * 70), + ); + await tester.pump(const Duration(milliseconds: 20)); + await first.up(timeStamp: Duration(milliseconds: tap * 70 + 20)); + await second.up(timeStamp: Duration(milliseconds: tap * 70 + 20)); + await tester.pump(const Duration(milliseconds: 50)); + } }