From 9e85aa7be6aa7bd30036681e186e76239c0085a4 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Tue, 12 May 2026 01:00:03 +0200 Subject: [PATCH] fix(tv): handle native text input completion --- lib/focus/focusable_text_field.dart | 121 ++++++++++++-- .../add_local_profile_screen_test.dart | 22 +++ test/widgets/focusable_text_field_test.dart | 154 +++++++++++++++++- 3 files changed, 278 insertions(+), 19 deletions(-) diff --git a/lib/focus/focusable_text_field.dart b/lib/focus/focusable_text_field.dart index 27ad42a8..3e7906c9 100644 --- a/lib/focus/focusable_text_field.dart +++ b/lib/focus/focusable_text_field.dart @@ -430,6 +430,13 @@ abstract class _FocusableTextInputBase extends StatelessWidget { }); bool get _hasTvKeyboard => _usesTvKeyboard(enableTvKeyboard); + bool get _usesNativeTvKeyboard => PlatformDetector.isTV() && !_hasTvKeyboard; + + VoidCallback? get _effectiveOnEditingComplete { + if (onEditingComplete != null) return onEditingComplete; + if (_usesNativeTvKeyboard && onSubmitted == null) return _handleTvKeyboardAction; + return null; + } void _showTvKeyboard(BuildContext context) { if (!enabled) return; @@ -445,10 +452,22 @@ abstract class _FocusableTextInputBase extends StatelessWidget { maxLines: maxLines, onChanged: onChanged, onSubmitted: onSubmitted, - onAction: onEditingComplete ?? onSelect, + onAction: _handleTvKeyboardAction, ); } + void _handleTvKeyboardAction() { + if (onEditingComplete != null) { + onEditingComplete!(); + } else if (onSelect != null) { + onSelect!(); + } else if (onNavigateDown != null) { + onNavigateDown!(); + } else { + _defaultEditingComplete(textInputAction); + } + } + KeyEventResult _handleKey(BuildContext context, FocusNode _, KeyEvent event) { return _handleInputKey( controller: controller, @@ -473,16 +492,84 @@ abstract class _FocusableTextInputBase extends StatelessWidget { ); } - Widget buildFocusableInput(BuildContext context, Widget Function(bool usesTvKeyboard) builder) { - final usesTvKeyboard = _hasTvKeyboard; - return Focus( - // This wrapper only intercepts key events bubbling from the input; the - // real TextField/TextFormField must remain the traversable focus target. - canRequestFocus: false, - skipTraversal: true, - onKeyEvent: (node, event) => _handleKey(context, node, event), - child: builder(usesTvKeyboard), - ); + Widget buildFocusableInput(BuildContext context, Widget Function(bool usesTvKeyboard, FocusNode focusNode) builder) { + return _FocusableTextInputHost(input: this, builder: builder); + } +} + +class _FocusableTextInputHost extends StatefulWidget { + final _FocusableTextInputBase input; + final Widget Function(bool usesTvKeyboard, FocusNode focusNode) builder; + + const _FocusableTextInputHost({required this.input, required this.builder}); + + @override + State<_FocusableTextInputHost> createState() => _FocusableTextInputHostState(); +} + +class _FocusableTextInputHostState extends State<_FocusableTextInputHost> { + FocusNode? _ownedFocusNode; + FocusNode? _installedFocusNode; + FocusOnKeyEventCallback? _previousOnKeyEvent; + late final FocusOnKeyEventCallback _keyHandler = _handleKey; + + FocusNode get _effectiveFocusNode => + widget.input.focusNode ?? (_ownedFocusNode ??= FocusNode(debugLabel: 'FocusableTextInput')); + + @override + void didUpdateWidget(_FocusableTextInputHost oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.input.focusNode != widget.input.focusNode) { + _restoreInstalledHandler(); + } + } + + @override + void dispose() { + _restoreInstalledHandler(); + _ownedFocusNode?.dispose(); + super.dispose(); + } + + KeyEventResult _handleKey(FocusNode node, KeyEvent event) { + final previous = _previousOnKeyEvent; + if (previous != null && !identical(previous, _keyHandler)) { + final result = previous(node, event); + if (result != KeyEventResult.ignored) return result; + } + return widget.input._handleKey(context, node, event); + } + + void _installKeyHandler(FocusNode node) { + // Handle D-pad escapes on the field's own node so EditableText shortcuts + // can't consume directions before our reusable navigation callbacks run. + if (_installedFocusNode == node) { + if (identical(node.onKeyEvent, _keyHandler)) return; + _previousOnKeyEvent = node.onKeyEvent; + node.onKeyEvent = _keyHandler; + return; + } + + _restoreInstalledHandler(); + _installedFocusNode = node; + _previousOnKeyEvent = node.onKeyEvent; + node.onKeyEvent = _keyHandler; + } + + void _restoreInstalledHandler() { + final node = _installedFocusNode; + if (node != null && identical(node.onKeyEvent, _keyHandler)) { + node.onKeyEvent = _previousOnKeyEvent; + } + _installedFocusNode = null; + _previousOnKeyEvent = null; + } + + @override + Widget build(BuildContext context) { + final focusNode = _effectiveFocusNode; + _installKeyHandler(focusNode); + return widget.builder(widget.input._hasTvKeyboard, focusNode); } } @@ -530,9 +617,9 @@ class FocusableTextField extends _FocusableTextInputBase { Widget build(BuildContext context) { return buildFocusableInput( context, - (usesTvKeyboard) => TextField( + (usesTvKeyboard, effectiveFocusNode) => TextField( controller: controller, - focusNode: focusNode, + focusNode: effectiveFocusNode, enabled: enabled, decoration: decoration, keyboardType: usesTvKeyboard ? TextInputType.none : keyboardType, @@ -540,7 +627,7 @@ class FocusableTextField extends _FocusableTextInputBase { inputFormatters: inputFormatters, onChanged: onChanged, onSubmitted: onSubmitted, - onEditingComplete: onEditingComplete, + onEditingComplete: _effectiveOnEditingComplete, autofocus: autofocus, autocorrect: autocorrect, enableSuggestions: enableSuggestions, @@ -605,9 +692,9 @@ class FocusableTextFormField extends _FocusableTextInputBase { Widget build(BuildContext context) { return buildFocusableInput( context, - (usesTvKeyboard) => TextFormField( + (usesTvKeyboard, effectiveFocusNode) => TextFormField( controller: controller, - focusNode: focusNode, + focusNode: effectiveFocusNode, enabled: enabled, decoration: decoration, keyboardType: usesTvKeyboard ? TextInputType.none : keyboardType, @@ -615,7 +702,7 @@ class FocusableTextFormField extends _FocusableTextInputBase { inputFormatters: inputFormatters, onChanged: onChanged, onFieldSubmitted: onFieldSubmitted, - onEditingComplete: onEditingComplete, + onEditingComplete: _effectiveOnEditingComplete, validator: validator, autovalidateMode: autovalidateMode, onSaved: onSaved, diff --git a/test/screens/profile/add_local_profile_screen_test.dart b/test/screens/profile/add_local_profile_screen_test.dart index f7435933..477bb20b 100644 --- a/test/screens/profile/add_local_profile_screen_test.dart +++ b/test/screens/profile/add_local_profile_screen_test.dart @@ -19,6 +19,7 @@ void main() { tearDown(() { TvDetectionService.debugSetAppleTVOverride(null); + TvDetectionService.setForceTVSync(false); }); testWidgets('D-pad leaves profile name input and reaches actions', (tester) async { @@ -44,6 +45,27 @@ void main() { expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddLocalProfile:Cancel'); }); + testWidgets('Android TV native keyboard done leaves profile name input', (tester) async { + TvDetectionService.debugSetAppleTVOverride(null); + await TvDetectionService.getInstance(forceTv: true); + TvDetectionService.setForceTVSync(true); + + await tester.pumpWidget( + TranslationProvider( + child: const InputModeTracker(child: MaterialApp(home: AddLocalProfileScreen())), + ), + ); + await tester.pumpAndSettle(); + + expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddLocalProfile:Name'); + + await tester.showKeyboard(find.byType(TextField)); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pump(); + + expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddLocalProfile:SetPin'); + }); + testWidgets('remote back pops the new profile page', (tester) async { await tester.pumpWidget( TranslationProvider( diff --git a/test/widgets/focusable_text_field_test.dart b/test/widgets/focusable_text_field_test.dart index 64b10580..f873ace9 100644 --- a/test/widgets/focusable_text_field_test.dart +++ b/test/widgets/focusable_text_field_test.dart @@ -9,9 +9,10 @@ import 'package:plezy/utils/platform_detector.dart'; void main() { tearDown(() { TvDetectionService.debugSetAppleTVOverride(null); + TvDetectionService.setForceTVSync(false); }); - testWidgets('tab traversal focuses the text form field instead of its key handler wrapper', (tester) async { + testWidgets('tab traversal focuses the text form field', (tester) async { final controller = TextEditingController(); final fieldFocusNode = FocusNode(debugLabel: 'server_url_field'); final buttonFocusNode = FocusNode(debugLabel: 'find_server_button'); @@ -43,7 +44,7 @@ void main() { expect(buttonFocusNode.hasFocus, isFalse); }); - testWidgets('focused text form field still receives wrapper select handling', (tester) async { + testWidgets('focused text form field still receives select handling', (tester) async { final controller = TextEditingController(); final fieldFocusNode = FocusNode(debugLabel: 'server_url_field'); var selects = 0; @@ -66,6 +67,116 @@ void main() { expect(selects, 1); }); + testWidgets('d-pad direction handlers are installed on the text field focus node', (tester) async { + final controller = TextEditingController(); + final fieldFocusNode = FocusNode(debugLabel: 'name_field'); + final nextFocusNode = FocusNode(debugLabel: 'next_button'); + addTearDown(controller.dispose); + addTearDown(fieldFocusNode.dispose); + addTearDown(nextFocusNode.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Column( + children: [ + FocusableTextField( + controller: controller, + focusNode: fieldFocusNode, + onNavigateDown: nextFocusNode.requestFocus, + ), + FilledButton(focusNode: nextFocusNode, onPressed: () {}, child: const Text('Next')), + ], + ), + ), + ), + ); + + fieldFocusNode.requestFocus(); + await tester.pump(); + final handler = fieldFocusNode.onKeyEvent; + + expect(handler, isNotNull); + final result = handler!( + fieldFocusNode, + const KeyDownEvent( + physicalKey: PhysicalKeyboardKey.arrowDown, + logicalKey: LogicalKeyboardKey.arrowDown, + timeStamp: Duration.zero, + deviceType: ui.KeyEventDeviceType.directionalPad, + ), + ); + await tester.pump(); + + expect(result, KeyEventResult.handled); + expect(nextFocusNode.hasPrimaryFocus, isTrue); + }); + + testWidgets('existing focus node key handler is preserved before text field navigation', (tester) async { + final controller = TextEditingController(); + final handledKeys = []; + final fieldFocusNode = FocusNode( + debugLabel: 'custom_field', + onKeyEvent: (_, event) { + if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.arrowUp) { + handledKeys.add(event.logicalKey); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }, + ); + final nextFocusNode = FocusNode(debugLabel: 'next_button'); + addTearDown(controller.dispose); + addTearDown(fieldFocusNode.dispose); + addTearDown(nextFocusNode.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Column( + children: [ + FocusableTextField( + controller: controller, + focusNode: fieldFocusNode, + onNavigateDown: nextFocusNode.requestFocus, + ), + FilledButton(focusNode: nextFocusNode, onPressed: () {}, child: const Text('Next')), + ], + ), + ), + ), + ); + + fieldFocusNode.requestFocus(); + await tester.pump(); + final handler = fieldFocusNode.onKeyEvent!; + + final customResult = handler( + fieldFocusNode, + const KeyDownEvent( + physicalKey: PhysicalKeyboardKey.arrowUp, + logicalKey: LogicalKeyboardKey.arrowUp, + timeStamp: Duration.zero, + deviceType: ui.KeyEventDeviceType.directionalPad, + ), + ); + final navigationResult = handler( + fieldFocusNode, + const KeyDownEvent( + physicalKey: PhysicalKeyboardKey.arrowDown, + logicalKey: LogicalKeyboardKey.arrowDown, + timeStamp: Duration.zero, + deviceType: ui.KeyEventDeviceType.directionalPad, + ), + ); + await tester.pump(); + + expect(customResult, KeyEventResult.handled); + expect(handledKeys, [LogicalKeyboardKey.arrowUp]); + expect(navigationResult, KeyEventResult.handled); + expect(nextFocusNode.hasPrimaryFocus, isTrue); + }); + testWidgets('tvOS keyboard enter does not open virtual keyboard', (tester) async { TvDetectionService.debugSetAppleTVOverride(true); final controller = TextEditingController(); @@ -126,6 +237,45 @@ void main() { expect(find.byType(Dialog), findsOneWidget); }); + testWidgets('Android TV native keyboard done uses D-pad navigation', (tester) async { + TvDetectionService.debugSetAppleTVOverride(null); + await TvDetectionService.getInstance(forceTv: true); + TvDetectionService.setForceTVSync(true); + final controller = TextEditingController(); + final fieldFocusNode = FocusNode(debugLabel: 'name_field'); + final nextFocusNode = FocusNode(debugLabel: 'next_button'); + addTearDown(controller.dispose); + addTearDown(fieldFocusNode.dispose); + addTearDown(nextFocusNode.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Column( + children: [ + FocusableTextField( + controller: controller, + focusNode: fieldFocusNode, + textInputAction: TextInputAction.done, + onNavigateDown: nextFocusNode.requestFocus, + ), + FilledButton(focusNode: nextFocusNode, onPressed: () {}, child: const Text('Next')), + ], + ), + ), + ), + ); + + fieldFocusNode.requestFocus(); + await tester.pump(); + await tester.showKeyboard(find.byType(TextField)); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pump(); + + expect(nextFocusNode.hasPrimaryFocus, isTrue); + expect(find.byType(Dialog), findsNothing); + }); + testWidgets('tvOS engine-synthesized select opens the virtual keyboard', (tester) async { // The custom Flutter tvOS engine emits Siri Remote center-dpad presses // as `LogicalKeyboardKey.select` with `deviceType=keyboard` (via the