fix(tv): restore profile pin keypad input

close #1011
This commit is contained in:
edde746
2026-05-11 15:13:37 +02:00
parent d901f5efc2
commit 0775ea0d26
2 changed files with 121 additions and 13 deletions
+28 -5
View File
@@ -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) {
+93 -8
View File
@@ -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 = <LogicalKeyboardKey>[];
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<void> _pumpPinDialogLauncher(WidgetTester tester, {required ValueChanged<String?> onResult}) async {
Future<void> _pumpPinDialogLauncher(
WidgetTester tester, {
required ValueChanged<String?> 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<void> _pressDpadKey(WidgetTester tester, LogicalKeyboardKey logicalKey, P
await tester.pump();
}
Future<void> _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) {