From 6d94880c2f7a5681bb868e285fada9d2bc625148 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 12 Jul 2026 00:38:59 +0200 Subject: [PATCH] fix(input): reset long press state safely --- lib/focus/focusable_chip_mixin.dart | 16 +++++-- lib/focus/focusable_wrapper.dart | 9 +++- lib/screens/media_detail_screen.dart | 33 ++++++++++----- lib/services/gamepad_service.dart | 6 ++- .../focus/focusable_chip_long_press_test.dart | 42 +++++++++++++++++++ test/focus/focusable_wrapper_test.dart | 28 +++++++++++++ 6 files changed, 117 insertions(+), 17 deletions(-) create mode 100644 test/focus/focusable_chip_long_press_test.dart diff --git a/lib/focus/focusable_chip_mixin.dart b/lib/focus/focusable_chip_mixin.dart index 0da516bf..43af66be 100644 --- a/lib/focus/focusable_chip_mixin.dart +++ b/lib/focus/focusable_chip_mixin.dart @@ -86,7 +86,12 @@ mixin FocusableChipStateMixin on State { void _onFocusChange() { if (mounted) { - setState(() => _isFocused = focusNode.hasFocus); + final hasFocus = focusNode.hasFocus; + setState(() => _isFocused = hasFocus); + if (!hasFocus) { + _longPressTimer?.cancel(); + _isSelectKeyDown = false; + } // Same convention as FocusableTileStateMixin: a chip inside a // scrollable strip (TabChipStrip, filter bars) reveals itself on // focus; a no-op when no ancestor scrollable exists. @@ -114,6 +119,10 @@ mixin FocusableChipStateMixin on State { } if (SelectKeyUpSuppressor.consumeIfSuppressed(event)) { + if (event is KeyUpEvent && key.isSelectKey) { + _longPressTimer?.cancel(); + _isSelectKeyDown = false; + } return KeyEventResult.handled; } @@ -125,7 +134,7 @@ mixin FocusableChipStateMixin on State { _isSelectKeyDown = true; _longPressTimer?.cancel(); _longPressTimer = Timer(const Duration(milliseconds: 500), () { - if (mounted) { + if (mounted && _isSelectKeyDown) { SelectKeyUpSuppressor.suppressSelectUntilKeyUp(); callbacks.onLongPress?.call(); } @@ -150,7 +159,8 @@ mixin FocusableChipStateMixin on State { // Context menu key triggers long press directly if (event.isActionable && key.isContextMenuKey && callbacks.onLongPress != null) { - SelectKeyUpSuppressor.suppressSelectUntilKeyUp(); + _longPressTimer?.cancel(); + _isSelectKeyDown = false; callbacks.onLongPress!(); return KeyEventResult.handled; } diff --git a/lib/focus/focusable_wrapper.dart b/lib/focus/focusable_wrapper.dart index ddb44c20..15e30fd3 100644 --- a/lib/focus/focusable_wrapper.dart +++ b/lib/focus/focusable_wrapper.dart @@ -376,6 +376,10 @@ class _FocusableWrapperState extends State with SingleTickerPr _logFocusableWrapper('node=${node.debugLabel} received key=(${_describeFocusableKey(event)})'); if (SelectKeyUpSuppressor.consumeIfSuppressed(event)) { + if (event is KeyUpEvent && key.isSelectKey) { + _longPressTimer?.cancel(); + _isSelectKeyDown = false; + } return finish(KeyEventResult.handled, 'select-key-up-suppressed'); } @@ -404,7 +408,7 @@ class _FocusableWrapperState extends State with SingleTickerPr _longPressTimer?.cancel(); _longPressTimer = Timer(widget.longPressDuration, () { // Long press detected - if (mounted) { + if (mounted && _isSelectKeyDown) { SelectKeyUpSuppressor.suppressSelectUntilKeyUp(); widget.onLongPress?.call(); } @@ -437,7 +441,8 @@ class _FocusableWrapperState extends State with SingleTickerPr // Context menu key if (key.isContextMenuKey) { - SelectKeyUpSuppressor.suppressSelectUntilKeyUp(); + _longPressTimer?.cancel(); + _isSelectKeyDown = false; widget.onLongPress?.call(); return finish(KeyEventResult.handled, 'context-menu'); } diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 0ca3aad4..d6628d4c 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -2416,20 +2416,31 @@ class _MediaDetailScreenState extends State if (key.isBackKey) return KeyEventResult.ignored; + if (SelectKeyUpSuppressor.consumeIfSuppressed(event)) { + if (event is KeyUpEvent && key.isSelectKey) { + _selectKeyTimer?.cancel(); + _isSelectKeyDown = false; + _longPressTriggered = false; + } + return KeyEventResult.handled; + } + // Handle SELECT with long-press detection if (key.isSelectKey) { if (event is KeyDownEvent) { - _selectKeyTimer?.cancel(); - _isSelectKeyDown = true; - _longPressTriggered = false; - _selectKeyTimer = Timer(_longPressDuration, () { - if (!mounted) return; - if (_isSelectKeyDown) { - _longPressTriggered = true; - SelectKeyUpSuppressor.suppressSelectUntilKeyUp(); - _extraCardKeys[_focusedExtraIndex]?.currentState?.showContextMenu(); - } - }); + if (!_isSelectKeyDown) { + _selectKeyTimer?.cancel(); + _isSelectKeyDown = true; + _longPressTriggered = false; + _selectKeyTimer = Timer(_longPressDuration, () { + if (!mounted) return; + if (_isSelectKeyDown) { + _longPressTriggered = true; + SelectKeyUpSuppressor.suppressSelectUntilKeyUp(); + _extraCardKeys[_focusedExtraIndex]?.currentState?.showContextMenu(); + } + }); + } return KeyEventResult.handled; } else if (event is KeyRepeatEvent) { return KeyEventResult.handled; diff --git a/lib/services/gamepad_service.dart b/lib/services/gamepad_service.dart index 54cd5b58..3e109a47 100644 --- a/lib/services/gamepad_service.dart +++ b/lib/services/gamepad_service.dart @@ -256,7 +256,8 @@ class GamepadService with WindowListener { } _pressedButtons.clear(); _suppressedButtons.clear(); - _heldFocusNodes.clear(); + SchedulerBinding.instance.addPostFrameCallback((_) => _heldFocusNodes.clear()); + key_sim.scheduleFrameIfIdle(); _duplicateInputGuard.clear(); // Reset analog stick state so re-focus doesn't inherit stale direction @@ -510,6 +511,7 @@ class GamepadService with WindowListener { /// Simulate a full key press (down + up) in a single frame. void _simulateKeyPress(LogicalKeyboardKey logicalKey) { + key_sim.scheduleFrameIfIdle(); SchedulerBinding.instance.addPostFrameCallback((_) { _dispatchKeyDown(logicalKey); _dispatchKeyUp(logicalKey); @@ -519,6 +521,7 @@ class GamepadService with WindowListener { /// Simulate only key down — pair with [_simulateKeyUp] on release /// so widget-level long-press timers see real hold duration. void _simulateKeyDown(LogicalKeyboardKey logicalKey) { + key_sim.scheduleFrameIfIdle(); SchedulerBinding.instance.addPostFrameCallback((_) { _dispatchKeyDown(logicalKey); }); @@ -526,6 +529,7 @@ class GamepadService with WindowListener { /// Simulate only key up — the release half of [_simulateKeyDown]. void _simulateKeyUp(LogicalKeyboardKey logicalKey) { + key_sim.scheduleFrameIfIdle(); SchedulerBinding.instance.addPostFrameCallback((_) { _dispatchKeyUp(logicalKey); }); diff --git a/test/focus/focusable_chip_long_press_test.dart b/test/focus/focusable_chip_long_press_test.dart new file mode 100644 index 00000000..dad25dcc --- /dev/null +++ b/test/focus/focusable_chip_long_press_test.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/widgets/focusable_tab_chip.dart'; + +void main() { + testWidgets('losing focus cancels a pending chip long press', (tester) async { + final firstNode = FocusNode(debugLabel: 'first'); + final secondNode = FocusNode(debugLabel: 'second'); + addTearDown(firstNode.dispose); + addTearDown(secondNode.dispose); + var longPressed = 0; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Row( + children: [ + FocusableTabChip( + label: 'First', + isSelected: true, + focusNode: firstNode, + onSelect: () {}, + onLongPress: () => longPressed++, + ), + FocusableTabChip(label: 'Second', isSelected: false, focusNode: secondNode, onSelect: () {}), + ], + ), + ), + ), + ); + firstNode.requestFocus(); + await tester.pump(); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.enter); + secondNode.requestFocus(); + await tester.pump(const Duration(milliseconds: 600)); + await tester.sendKeyUpEvent(LogicalKeyboardKey.enter); + + expect(longPressed, 0); + }); +} diff --git a/test/focus/focusable_wrapper_test.dart b/test/focus/focusable_wrapper_test.dart index 8e44ab0a..8dd35a34 100644 --- a/test/focus/focusable_wrapper_test.dart +++ b/test/focus/focusable_wrapper_test.dart @@ -56,4 +56,32 @@ void main() { expect(tester.takeException(), isNull); }); + + testWidgets('context menu key does not suppress the next select', (tester) async { + final node = FocusNode(debugLabel: 'card'); + addTearDown(node.dispose); + var selected = 0; + var longPressed = 0; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: FocusableWrapper( + focusNode: node, + onSelect: () => selected++, + onLongPress: () => longPressed++, + child: const SizedBox(width: 10, height: 10), + ), + ), + ), + ); + node.requestFocus(); + await tester.pump(); + + await tester.sendKeyEvent(LogicalKeyboardKey.contextMenu); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + + expect(longPressed, 1); + expect(selected, 1); + }); }