fix(input): reset long press state safely
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user