fix(tvos): unify remote and text input ownership

This commit is contained in:
edde746
2026-07-26 07:08:01 +02:00
parent fcaa81bf3c
commit 71735354b9
17 changed files with 651 additions and 97 deletions
+226 -14
View File
@@ -149,7 +149,11 @@ void main() {
home: Scaffold(
body: TickerMode(
enabled: visible,
child: FocusableTextField(controller: controller, focusNode: fieldFocusNode),
child: FocusableTextField(
controller: controller,
focusNode: fieldFocusNode,
tvTextInputPresentation: TvTextInputPresentation.flutterOverlay,
),
),
),
),
@@ -181,7 +185,11 @@ void main() {
MaterialApp(
home: Scaffold(
body: present
? FocusableTextField(controller: controller, focusNode: fieldFocusNode)
? FocusableTextField(
controller: controller,
focusNode: fieldFocusNode,
tvTextInputPresentation: TvTextInputPresentation.flutterOverlay,
)
: const SizedBox.shrink(),
),
),
@@ -211,7 +219,11 @@ void main() {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: FocusableTextField(controller: controller, focusNode: fieldFocusNode),
body: FocusableTextField(
controller: controller,
focusNode: fieldFocusNode,
tvTextInputPresentation: TvTextInputPresentation.flutterOverlay,
),
),
),
);
@@ -227,6 +239,196 @@ void main() {
expect(find.byType(Dialog), findsNothing);
});
testWidgets('Apple TV automatic single-line input uses the platform field', (tester) async {
TvDetectionService.debugSetAppleTVOverride(true);
final controller = TextEditingController();
final fieldFocusNode = FocusNode(debugLabel: 'native_name_field');
addTearDown(controller.dispose);
addTearDown(fieldFocusNode.dispose);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: FocusableTextField(controller: controller, focusNode: fieldFocusNode),
),
),
);
fieldFocusNode.requestFocus();
await tester.pumpAndSettle();
expect(tester.widget<TextField>(find.byType(TextField)).readOnly, isFalse);
expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsNothing);
});
testWidgets('Apple TV navigation resumes after native keyboard dismissal', (tester) async {
TvDetectionService.debugSetAppleTVOverride(true);
final controller = TextEditingController();
final fieldFocusNode = FocusNode(debugLabel: 'native_url_field');
final nextFocusNode = FocusNode(debugLabel: 'save_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('Save')),
],
),
),
),
);
fieldFocusNode.requestFocus();
await tester.pumpAndSettle();
expect(tester.widget<TextField>(find.byType(TextField)).readOnly, isFalse);
final result = fieldFocusNode.onKeyEvent!(fieldFocusNode, _remoteKey(LogicalKeyboardKey.arrowDown));
await tester.pumpAndSettle();
expect(result, KeyEventResult.handled);
expect(nextFocusNode.hasPrimaryFocus, isTrue);
expect(tester.widget<TextField>(find.byType(TextField)).readOnly, isTrue);
});
testWidgets('Apple TV Menu dismissal does not also invoke app back', (tester) async {
TvDetectionService.debugSetAppleTVOverride(true);
final controller = TextEditingController();
final fieldFocusNode = FocusNode(debugLabel: 'native_url_field');
var backCount = 0;
addTearDown(controller.dispose);
addTearDown(fieldFocusNode.dispose);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: FocusableTextField(controller: controller, focusNode: fieldFocusNode, onBack: () => backCount++),
),
),
);
fieldFocusNode.requestFocus();
await tester.pumpAndSettle();
final result = fieldFocusNode.onKeyEvent!(fieldFocusNode, _remoteKey(LogicalKeyboardKey.goBack));
await tester.pumpAndSettle();
expect(result, KeyEventResult.handled);
expect(backCount, 0);
expect(fieldFocusNode.hasPrimaryFocus, isTrue);
expect(tester.widget<TextField>(find.byType(TextField)).readOnly, isTrue);
final secondResult = fieldFocusNode.onKeyEvent!(fieldFocusNode, _remoteKey(LogicalKeyboardKey.goBack));
await tester.pump();
expect(secondResult, KeyEventResult.handled);
expect(backCount, 1);
});
testWidgets('Apple TV Select reopens native input after dismissal', (tester) async {
TvDetectionService.debugSetAppleTVOverride(true);
final controller = TextEditingController();
final fieldFocusNode = FocusNode(debugLabel: 'native_url_field');
addTearDown(controller.dispose);
addTearDown(fieldFocusNode.dispose);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: FocusableTextField(controller: controller, focusNode: fieldFocusNode),
),
),
);
fieldFocusNode.requestFocus();
await tester.pumpAndSettle();
final result = fieldFocusNode.onKeyEvent!(fieldFocusNode, _remoteKey(LogicalKeyboardKey.select));
await tester.pump();
expect(result, KeyEventResult.handled);
expect(tester.widget<TextField>(find.byType(TextField)).readOnly, isTrue);
await tester.pump();
expect(tester.widget<TextField>(find.byType(TextField)).readOnly, isFalse);
});
testWidgets('Apple TV after-first-focus waits for explicit Select', (tester) async {
TvDetectionService.debugSetAppleTVOverride(true);
final controller = TextEditingController();
final fieldFocusNode = FocusNode(debugLabel: 'native_url_field');
addTearDown(controller.dispose);
addTearDown(fieldFocusNode.dispose);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: FocusableTextField(
controller: controller,
focusNode: fieldFocusNode,
tvTextInputAutoOpenBehavior: TvTextInputAutoOpenBehavior.afterFirstFocus,
),
),
),
);
fieldFocusNode.requestFocus();
await tester.pumpAndSettle();
expect(tester.widget<TextField>(find.byType(TextField)).readOnly, isTrue);
await tester.sendKeyEvent(LogicalKeyboardKey.select);
await tester.pumpAndSettle();
expect(tester.widget<TextField>(find.byType(TextField)).readOnly, isFalse);
expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsNothing);
});
testWidgets('Apple TV after-first-focus auto-opens on refocus', (tester) async {
TvDetectionService.debugSetAppleTVOverride(true);
final controller = TextEditingController();
final fieldFocusNode = FocusNode(debugLabel: 'native_url_field');
final otherFocusNode = FocusNode(debugLabel: 'next_button');
addTearDown(controller.dispose);
addTearDown(fieldFocusNode.dispose);
addTearDown(otherFocusNode.dispose);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Column(
children: [
FocusableTextField(
controller: controller,
focusNode: fieldFocusNode,
tvTextInputAutoOpenBehavior: TvTextInputAutoOpenBehavior.afterFirstFocus,
),
Focus(focusNode: otherFocusNode, child: const SizedBox(width: 1, height: 1)),
],
),
),
),
);
fieldFocusNode.requestFocus();
await tester.pumpAndSettle();
expect(tester.widget<TextField>(find.byType(TextField)).readOnly, isTrue);
otherFocusNode.requestFocus();
await tester.pumpAndSettle();
fieldFocusNode.requestFocus();
await tester.pumpAndSettle();
expect(tester.widget<TextField>(find.byType(TextField)).readOnly, isFalse);
});
testWidgets('Android TV native keyboard done uses D-pad navigation', (tester) async {
TvDetectionService.debugSetAppleTVOverride(null);
await TvDetectionService.getInstance(forceTv: true);
@@ -246,7 +448,7 @@ void main() {
FocusableTextField(
controller: controller,
focusNode: fieldFocusNode,
enableTvKeyboard: false,
tvTextInputPresentation: TvTextInputPresentation.platform,
textInputAction: TextInputAction.done,
onNavigateDown: nextFocusNode.requestFocus,
),
@@ -311,7 +513,7 @@ void main() {
FocusableTextFormField(
controller: controller,
focusNode: fieldFocusNode,
tvKeyboardAutoOpenBehavior: TvKeyboardAutoOpenBehavior.afterFirstFocus,
tvTextInputAutoOpenBehavior: TvTextInputAutoOpenBehavior.afterFirstFocus,
),
Focus(focusNode: otherFocusNode, child: const SizedBox(width: 1, height: 1)),
],
@@ -353,7 +555,7 @@ void main() {
body: FocusableTextFormField(
controller: controller,
focusNode: fieldFocusNode,
tvKeyboardAutoOpenBehavior: TvKeyboardAutoOpenBehavior.afterFirstFocus,
tvTextInputAutoOpenBehavior: TvTextInputAutoOpenBehavior.afterFirstFocus,
),
),
),
@@ -392,7 +594,7 @@ void main() {
FocusableTextFormField(
controller: controller,
focusNode: fieldFocusNode,
enableTvKeyboard: false,
tvTextInputPresentation: TvTextInputPresentation.platform,
onNavigateDown: nextFocusNode.requestFocus,
onSelect: () => selects++,
onBack: () => backs++,
@@ -467,7 +669,11 @@ void main() {
home: Scaffold(
body: Column(
children: [
FocusableTextFormField(controller: controller, focusNode: fieldFocusNode, enableTvKeyboard: false),
FocusableTextFormField(
controller: controller,
focusNode: fieldFocusNode,
tvTextInputPresentation: TvTextInputPresentation.platform,
),
Focus(focusNode: otherFocusNode, child: const SizedBox.shrink()),
],
),
@@ -557,11 +763,11 @@ void main() {
expect(find.byType(Dialog), findsNothing);
});
testWidgets('tvOS engine-synthesized select is handled by the virtual keyboard', (tester) async {
testWidgets('tvOS synthesized Select opens an explicit Flutter overlay', (tester) async {
// The custom Flutter tvOS engine emits Siri Remote center-dpad presses
// as `LogicalKeyboardKey.select` with `deviceType=keyboard` (via the
// legacy `flutter/keyevent` Android DPAD_CENTER path). On Apple TV this
// must open the on-screen keyboard, not submit the form. Previously
// legacy `flutter/keyevent` path). Fields that explicitly retain Plezy's
// Flutter overlay must open it rather than submit the form.
// `isPhysicalKeyboardEnter` matched select+keyboard and routed through
// `_submitTextInput`, which silently triggered form submit on every
// dpad center press (e.g. immediate validation error on empty fields).
@@ -579,6 +785,7 @@ void main() {
body: FocusableTextField(
controller: controller,
focusNode: fieldFocusNode,
tvTextInputPresentation: TvTextInputPresentation.flutterOverlay,
onSubmitted: (value) => submitted = value,
),
),
@@ -609,6 +816,7 @@ void main() {
body: FocusableTextField(
controller: controller,
focusNode: fieldFocusNode,
tvTextInputPresentation: TvTextInputPresentation.flutterOverlay,
maxLength: 2,
inputFormatters: [FilteringTextInputFormatter.allow(RegExp('[ab]'))],
onChanged: changes.add,
@@ -660,7 +868,11 @@ void main() {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: FocusableTextField(controller: controller, focusNode: fieldFocusNode),
body: FocusableTextField(
controller: controller,
focusNode: fieldFocusNode,
tvTextInputPresentation: TvTextInputPresentation.flutterOverlay,
),
),
),
);
@@ -689,7 +901,7 @@ void main() {
body: FocusableTextField(
controller: controller,
focusNode: fieldFocusNode,
tvKeyboardAutoOpenBehavior: TvKeyboardAutoOpenBehavior.never,
tvTextInputAutoOpenBehavior: TvTextInputAutoOpenBehavior.never,
),
),
),
@@ -772,7 +984,7 @@ void main() {
body: FocusableTextField(
controller: controller,
focusNode: fieldFocusNode,
tvKeyboardAutoOpenBehavior: TvKeyboardAutoOpenBehavior.never,
tvTextInputAutoOpenBehavior: TvTextInputAutoOpenBehavior.never,
maxLength: 8,
inputFormatters: [
TextInputFormatter.withFunction((_, nextValue) {