fix(input): reset long press state safely

This commit is contained in:
edde746
2026-07-12 08:42:19 +02:00
parent 9c6fcbc841
commit 6d94880c2f
6 changed files with 117 additions and 17 deletions
+13 -3
View File
@@ -86,7 +86,12 @@ mixin FocusableChipStateMixin<T extends StatefulWidget> on State<T> {
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<T extends StatefulWidget> on State<T> {
}
if (SelectKeyUpSuppressor.consumeIfSuppressed(event)) {
if (event is KeyUpEvent && key.isSelectKey) {
_longPressTimer?.cancel();
_isSelectKeyDown = false;
}
return KeyEventResult.handled;
}
@@ -125,7 +134,7 @@ mixin FocusableChipStateMixin<T extends StatefulWidget> on State<T> {
_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<T extends StatefulWidget> on State<T> {
// 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;
}
+7 -2
View File
@@ -376,6 +376,10 @@ class _FocusableWrapperState extends State<FocusableWrapper> 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<FocusableWrapper> 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<FocusableWrapper> with SingleTickerPr
// Context menu key
if (key.isContextMenuKey) {
SelectKeyUpSuppressor.suppressSelectUntilKeyUp();
_longPressTimer?.cancel();
_isSelectKeyDown = false;
widget.onLongPress?.call();
return finish(KeyEventResult.handled, 'context-menu');
}
+22 -11
View File
@@ -2416,20 +2416,31 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
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;
+5 -1
View File
@@ -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);
});
@@ -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);
});
}
+28
View File
@@ -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);
});
}