fix(tvos): unify remote and text input ownership
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user