fix(tv): handle native text input completion

This commit is contained in:
edde746
2026-05-12 01:00:03 +02:00
parent fd69fe668a
commit 9e85aa7be6
3 changed files with 278 additions and 19 deletions
+104 -17
View File
@@ -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,
@@ -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(
+152 -2
View File
@@ -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 = <LogicalKeyboardKey>[];
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