diff --git a/lib/screens/profile/pin_entry_dialog.dart b/lib/screens/profile/pin_entry_dialog.dart index d620a98b..8b2b358a 100644 --- a/lib/screens/profile/pin_entry_dialog.dart +++ b/lib/screens/profile/pin_entry_dialog.dart @@ -201,11 +201,13 @@ class _TvPinInputState extends State<_TvPinInput> with ControllerDisposerMixin { final FocusNode _mobileFocusNode = FocusNode(debugLabel: 'PinInputMobile'); late final TextEditingController _mobileController = createTextEditingController(); final FocusNode _keypadFocusNode = FocusNode(debugLabel: 'PinKeypad'); + bool _keypadRefocusScheduled = false; @override void initState() { super.initState(); _mobileFocusNode.addListener(_handleMobileFocusChanged); + _keypadFocusNode.addListener(_handleKeypadFocusChanged); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; @@ -220,6 +222,7 @@ class _TvPinInputState extends State<_TvPinInput> with ControllerDisposerMixin { @override void dispose() { + _keypadFocusNode.removeListener(_handleKeypadFocusChanged); _keypadFocusNode.dispose(); _mobileFocusNode.removeListener(_handleMobileFocusChanged); _mobileFocusNode.dispose(); @@ -230,6 +233,21 @@ class _TvPinInputState extends State<_TvPinInput> with ControllerDisposerMixin { if (mounted) setState(() {}); } + void _handleKeypadFocusChanged() { + if (!mounted || widget.isMobile || _keypadFocusNode.hasFocus) return; + _scheduleKeypadRefocus(); + } + + void _scheduleKeypadRefocus() { + if (_keypadRefocusScheduled) return; + _keypadRefocusScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _keypadRefocusScheduled = false; + if (!mounted || widget.isMobile || _keypadFocusNode.hasFocus) return; + _requestKeypadFocus(); + }); + } + void _reset() { setState(() { for (int i = 0; i < 4; i++) { @@ -361,13 +379,13 @@ class _TvPinInputState extends State<_TvPinInput> with ControllerDisposerMixin { return KeyEventResult.handled; } - if (event.isPhysicalKeyboardEnter) { - _trySubmit(); + if (event.isTvSelectEvent || (PlatformDetector.isTV() && event.isPhysicalKeyboardEnter)) { + _activate(_rows[_row][_column]); return KeyEventResult.handled; } - if (event.isTvSelectEvent) { - _activate(_rows[_row][_column]); + if (event.isPhysicalKeyboardEnter) { + _trySubmit(); return KeyEventResult.handled; } @@ -445,7 +463,12 @@ class _TvPinInputState extends State<_TvPinInput> with ControllerDisposerMixin { return _buildMobileLayout(context); } - return Focus(focusNode: _keypadFocusNode, onKeyEvent: _handleKey, child: _buildKeypadLayout(context)); + return Focus( + focusNode: _keypadFocusNode, + autofocus: true, + onKeyEvent: _handleKey, + child: _buildKeypadLayout(context), + ); } Widget _buildKeypadLayout(BuildContext context) { diff --git a/test/widgets/pin_entry_dialog_test.dart b/test/widgets/pin_entry_dialog_test.dart index f1573196..865c6f78 100644 --- a/test/widgets/pin_entry_dialog_test.dart +++ b/test/widgets/pin_entry_dialog_test.dart @@ -94,6 +94,68 @@ void main() { expect(find.byType(PinEntryDialog), findsNothing); }); + testWidgets('Android TV keyboard-mapped enter/select activates highlighted PIN key', (tester) async { + TvDetectionService.debugSetAppleTVOverride(true); + String? result; + + await _pumpPinDialogLauncher(tester, onResult: (pin) => result = pin); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + await _pressKeyboardMappedKey(tester, LogicalKeyboardKey.enter, PhysicalKeyboardKey.enter); // 1 + await _pressKeyboardMappedKey(tester, LogicalKeyboardKey.arrowRight, PhysicalKeyboardKey.arrowRight); + await _pressKeyboardMappedKey(tester, LogicalKeyboardKey.select, PhysicalKeyboardKey.select); // 2 + await _pressKeyboardMappedKey(tester, LogicalKeyboardKey.arrowRight, PhysicalKeyboardKey.arrowRight); + await _pressKeyboardMappedKey(tester, LogicalKeyboardKey.enter, PhysicalKeyboardKey.enter); // 3 + await _pressKeyboardMappedKey(tester, LogicalKeyboardKey.arrowLeft, PhysicalKeyboardKey.arrowLeft); + await _pressKeyboardMappedKey(tester, LogicalKeyboardKey.arrowLeft, PhysicalKeyboardKey.arrowLeft); + await _pressKeyboardMappedKey(tester, LogicalKeyboardKey.arrowDown, PhysicalKeyboardKey.arrowDown); + await _pressKeyboardMappedKey(tester, LogicalKeyboardKey.select, PhysicalKeyboardKey.select); // 4 + await tester.pumpAndSettle(); + + expect(result, '1234'); + expect(find.byType(PinEntryDialog), findsNothing); + }); + + testWidgets('TV PIN keypad reclaims focus after activation', (tester) async { + TvDetectionService.debugSetAppleTVOverride(true); + final underlyingFocusNode = FocusNode(debugLabel: 'UnderlyingProfileScreen'); + final leakedKeys = []; + String? result; + addTearDown(underlyingFocusNode.dispose); + + await _pumpPinDialogLauncher( + tester, + onResult: (pin) => result = pin, + underlyingFocusNode: underlyingFocusNode, + onUnderlyingKey: (_, event) { + if (event is KeyDownEvent) leakedKeys.add(event.logicalKey); + return KeyEventResult.handled; + }, + ); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + await _pressKeyboardMappedKey(tester, LogicalKeyboardKey.enter, PhysicalKeyboardKey.enter); // 1 + underlyingFocusNode.requestFocus(); + await tester.pump(); + await tester.pump(); + + await _pressKeyboardMappedKey(tester, LogicalKeyboardKey.arrowRight, PhysicalKeyboardKey.arrowRight); + await _pressKeyboardMappedKey(tester, LogicalKeyboardKey.select, PhysicalKeyboardKey.select); // 2 + await _pressKeyboardMappedKey(tester, LogicalKeyboardKey.arrowRight, PhysicalKeyboardKey.arrowRight); + await _pressKeyboardMappedKey(tester, LogicalKeyboardKey.select, PhysicalKeyboardKey.select); // 3 + await _pressKeyboardMappedKey(tester, LogicalKeyboardKey.arrowLeft, PhysicalKeyboardKey.arrowLeft); + await _pressKeyboardMappedKey(tester, LogicalKeyboardKey.arrowLeft, PhysicalKeyboardKey.arrowLeft); + await _pressKeyboardMappedKey(tester, LogicalKeyboardKey.arrowDown, PhysicalKeyboardKey.arrowDown); + await _pressKeyboardMappedKey(tester, LogicalKeyboardKey.select, PhysicalKeyboardKey.select); // 4 + await tester.pumpAndSettle(); + + expect(result, '1234'); + expect(leakedKeys, isEmpty); + expect(find.byType(PinEntryDialog), findsNothing); + }); + testWidgets('non-mobile PIN entry accepts physical keyboard digits', (tester) async { TvDetectionService.debugSetAppleTVOverride(true); String? result; @@ -141,19 +203,26 @@ void main() { }); } -Future _pumpPinDialogLauncher(WidgetTester tester, {required ValueChanged onResult}) async { +Future _pumpPinDialogLauncher( + WidgetTester tester, { + required ValueChanged onResult, + FocusNode? underlyingFocusNode, + FocusOnKeyEventCallback? onUnderlyingKey, +}) async { await tester.pumpWidget( MaterialApp( home: Builder( builder: (context) { - return Scaffold( - body: TextButton( - onPressed: () async { - onResult(await showPinEntryDialog(context, 'Protected Profile')); - }, - child: const Text('Open'), - ), + Widget body = TextButton( + onPressed: () async { + onResult(await showPinEntryDialog(context, 'Protected Profile')); + }, + child: const Text('Open'), ); + if (underlyingFocusNode != null) { + body = Focus(focusNode: underlyingFocusNode, onKeyEvent: onUnderlyingKey, child: body); + } + return Scaffold(body: body); }, ), ), @@ -177,6 +246,22 @@ Future _pressDpadKey(WidgetTester tester, LogicalKeyboardKey logicalKey, P await tester.pump(); } +Future _pressKeyboardMappedKey( + WidgetTester tester, + LogicalKeyboardKey logicalKey, + PhysicalKeyboardKey physicalKey, +) async { + _dispatchKey( + KeyDownEvent( + physicalKey: physicalKey, + logicalKey: logicalKey, + timeStamp: Duration.zero, + deviceType: ui.KeyEventDeviceType.keyboard, + ), + ); + await tester.pump(); +} + KeyEventResult _dispatchKey(KeyEvent event) { FocusNode? node = FocusManager.instance.primaryFocus; while (node != null) {