fix(tvos): raise the system keyboard on arrival, not on every focus

Apple TV single-line fields moved to the engine's UITextField proxy in
2.10.0 (71735354), which made three focus behaviours user-visible.

Submitting re-attached the input connection. EditableText schedules a
restart when a submit action fires with a non-null onSubmitted, and that
microtask runs before the setState flipping readOnly, so the field
re-showed a keyboard the form had just dismissed. The native path now
withholds onSubmitted from EditableText and invokes it from the host,
independently of onEditingComplete as _finalizeEditing does.

Auto-open fired on every focus entry, so D-pad traversal of a multi-field
form raised and dismissed the modal system keyboard on each step.
TvTextInputAutoOpenBehavior gains onFirstFocus, and the new `automatic`
default resolves to it on Apple TV: arriving at a field opens it once,
returning to it does not. Android TV keeps its docked-IME auto-open, and
explicit modes stay literal on both. The autofocused Jellyfin and Seerr
URL fields keep an explicit exception so entering the screen still does
not bury the form (#1217).

EditableText.connectionClosed unfocuses the field outright, so a UIKit
keyboard dismissal left nothing focused at all. The host takes focus back,
keyed on identity with the field's own enclosing scope so a dialog or
route claiming focus meanwhile is left alone.

close #1728
This commit is contained in:
edde746
2026-07-31 01:05:10 +02:00
parent 944a8d89f5
commit d55b875855
6 changed files with 457 additions and 8 deletions
+8 -1
View File
@@ -144,9 +144,16 @@ void main() {
expect(FocusManager.instance.primaryFocus?.debugLabel, 'SearchFirstResult');
expect(client.queries, ['movie']);
// A deliberate return to the input starts a fresh native session.
// Returning to the input is the D-pad-up path from the first result
// (search_screen.dart onNavigateUp), so it must not re-raise the system
// keyboard; Select does. The field's first focus already opened it once.
searchInput.focusSearchInput();
await tester.pumpAndSettle();
expect(FocusManager.instance.primaryFocus?.debugLabel, 'SearchInput');
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);
@@ -420,9 +420,106 @@ void main() {
expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddJellyfin:Url');
expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsNothing);
// Returning to the URL field by D-pad must not raise the system keyboard;
// only an explicit Select does. Auto-opening on focus made the form
// untraversable on Apple TV (#1728).
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);
});
/// Drives the Apple TV Add Jellyfin flow up to the credentials step and
/// leaves focus on the username field, as the probe does.
Future<void> pumpAppleTvCredentialsStep(WidgetTester tester) async {
TvDetectionService.debugSetAppleTVOverride(true);
PlatformDetector.debugSetIsDesktopOSOverride(false);
await tester.pumpWidget(
InputModeTracker(
child: _testApp(
AddJellyfinScreen(authServiceFactory: () => _jellyfinAuthService(), localDiscoveryFactory: _noLocalServers),
),
),
);
await tester.pumpAndSettle();
await tester.sendKeyEvent(LogicalKeyboardKey.select);
await tester.pumpAndSettle();
tester.testTextInput.updateEditingValue(const TextEditingValue(text: 'https://jf.example.com'));
await tester.pump();
}
List<String> drainTextInput(WidgetTester tester) {
final methods = tester.testTextInput.log.map((call) => call.method).toList();
tester.testTextInput.log.clear();
return methods;
}
testWidgets('Apple TV probe handoff attaches text input exactly once', (tester) async {
await pumpAppleTvCredentialsStep(tester);
drainTextInput(tester);
await tester.testTextInput.receiveAction(TextInputAction.go);
await tester.pumpAndSettle();
final handoff = drainTextInput(tester);
// The username field's first focus legitimately raises input once. What
// must not happen is a second attach: EditableText used to schedule a
// connection restart on submit (submit action + non-null onFieldSubmitted)
// and re-show the URL field it had just dismissed, so the handoff carried
// two setClient/show pairs. On tvOS that tears the system keyboard down
// and re-presents it while the next field is claiming it.
expect(handoff.where((m) => m == 'TextInput.setClient'), hasLength(1));
expect(handoff.where((m) => m == 'TextInput.show'), hasLength(1));
expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddJellyfin:Username');
expect(tester.widget<TextField>(find.byType(TextField).at(1)).readOnly, isFalse);
});
testWidgets('Apple TV D-pad traversal stops raising the keyboard after each field is seen', (tester) async {
await pumpAppleTvCredentialsStep(tester);
await tester.testTextInput.receiveAction(TextInputAction.go);
await tester.pumpAndSettle();
// On device the D-pad belongs to the tvOS keyboard while it is up, so a
// user can only traverse after dismissing it. Model that: dismiss via the
// platform (as UIKit does), which must leave focus on the field, and only
// then send the arrow.
Future<void> dismissIfOpen() async {
final open = tester.widgetList<TextField>(find.byType(TextField)).any((field) => !field.readOnly);
if (!open) return;
final focused = FocusManager.instance.primaryFocus?.debugLabel;
tester.testTextInput.closeConnection();
await tester.pumpAndSettle();
expect(FocusManager.instance.primaryFocus?.debugLabel, focused, reason: 'dismissal must not move focus');
}
Future<void> walk() async {
for (final key in [LogicalKeyboardKey.arrowDown, LogicalKeyboardKey.arrowUp]) {
for (var step = 0; step < 3; step++) {
await dismissIfOpen();
await tester.sendKeyEvent(key);
await tester.pumpAndSettle();
}
}
}
// First pass may raise input once per field it has never focused before —
// arriving at a field is an intent to type.
await walk();
// Every later pass must be silent. `onFocus` re-raised the system keyboard
// on every single traversal step, which made the form unusable.
for (var pass = 2; pass <= 3; pass++) {
drainTextInput(tester);
await walk();
final traversal = drainTextInput(tester);
expect(traversal, isNot(contains('TextInput.setClient')), reason: 'pass $pass');
expect(traversal, isNot(contains('TextInput.show')), reason: 'pass $pass');
}
});
testWidgets('D-pad moves from URL through Change to credentials after server is found', (tester) async {
await tester.pumpWidget(
_testApp(
+248
View File
@@ -256,6 +256,7 @@ void main() {
fieldFocusNode.requestFocus();
await tester.pumpAndSettle();
await _raiseNativeInput(tester);
expect(tester.widget<TextField>(find.byType(TextField)).readOnly, isFalse);
expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsNothing);
@@ -292,6 +293,7 @@ void main() {
fieldFocusNode.requestFocus();
await tester.pumpAndSettle();
await _raiseNativeInput(tester);
await tester.showKeyboard(find.byType(TextField));
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pump();
@@ -331,6 +333,7 @@ void main() {
fieldFocusNode.requestFocus();
await tester.pumpAndSettle();
await _raiseNativeInput(tester);
await tester.showKeyboard(find.byType(TextField));
await tester.testTextInput.receiveAction(TextInputAction.go);
await tester.pump();
@@ -344,6 +347,192 @@ void main() {
expect(submissions, ['https://jellyfin.example.com']);
});
testWidgets('Apple TV native submit fires both onEditingComplete and onSubmitted', (tester) async {
TvDetectionService.debugSetAppleTVOverride(true);
final controller = TextEditingController(text: 'https://jellyfin.example.com');
final fieldFocusNode = FocusNode(debugLabel: 'native_url_field');
final submissions = <String>[];
var completed = 0;
addTearDown(controller.dispose);
addTearDown(fieldFocusNode.dispose);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: FocusableTextFormField(
controller: controller,
focusNode: fieldFocusNode,
tvTextInputPresentation: TvTextInputPresentation.platform,
textInputAction: TextInputAction.go,
onEditingComplete: () => completed++,
onFieldSubmitted: submissions.add,
),
),
),
);
fieldFocusNode.requestFocus();
await tester.pumpAndSettle();
await _raiseNativeInput(tester);
await tester.showKeyboard(find.byType(TextField));
await tester.testTextInput.receiveAction(TextInputAction.go);
await tester.pump();
// EditableText calls the two independently; withholding onSubmitted from
// the widget on the native path must not collapse them into an either/or.
expect(completed, 1);
expect(submissions, ['https://jellyfin.example.com']);
expect(tester.widget<TextField>(find.byType(TextField)).readOnly, isTrue);
});
testWidgets('Apple TV keeps field focus when the platform dismisses the keyboard', (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();
await _raiseNativeInput(tester);
expect(tester.widget<TextField>(find.byType(TextField)).readOnly, isFalse);
// UIKit dismissing the tvOS keyboard closes the connection with no
// performAction; EditableText.connectionClosed then unfocuses the field.
tester.testTextInput.log.clear();
tester.testTextInput.closeConnection();
await tester.pumpAndSettle();
expect(fieldFocusNode.hasPrimaryFocus, isTrue, reason: 'dismissal must not strand focus');
expect(tester.widget<TextField>(find.byType(TextField)).readOnly, isTrue);
final afterDismissal = tester.testTextInput.log.map((call) => call.method);
expect(afterDismissal, isNot(contains('TextInput.setClient')));
expect(afterDismissal, isNot(contains('TextInput.show')));
// The field is still usable: Select raises input again, D-pad still leaves.
await _raiseNativeInput(tester);
expect(tester.widget<TextField>(find.byType(TextField)).readOnly, isFalse);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.pumpAndSettle();
expect(nextFocusNode.hasPrimaryFocus, isTrue);
});
testWidgets('Apple TV dismissal does not steal focus from a scope claimed meanwhile', (tester) async {
TvDetectionService.debugSetAppleTVOverride(true);
final controller = TextEditingController();
final fieldFocusNode = FocusNode(debugLabel: 'native_url_field');
final rivalScope = FocusScopeNode(debugLabel: 'rival_sheet_scope');
addTearDown(controller.dispose);
addTearDown(fieldFocusNode.dispose);
addTearDown(rivalScope.dispose);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Column(
children: [
FocusableTextField(controller: controller, focusNode: fieldFocusNode),
FocusScope(node: rivalScope, child: const SizedBox.shrink()),
],
),
),
),
);
fieldFocusNode.requestFocus();
await tester.pumpAndSettle();
expect(tester.widget<TextField>(find.byType(TextField)).readOnly, isFalse);
// The connection closes and another scope — a sheet or dialog — becomes
// primary before the restore callback runs. `primaryFocus is FocusScopeNode`
// would be satisfied by that rival scope; only identity with the field's
// own enclosing scope may trigger a restore.
tester.testTextInput.closeConnection();
rivalScope.requestFocus();
await tester.pumpAndSettle();
expect(rivalScope.hasFocus, isTrue);
expect(fieldFocusNode.hasPrimaryFocus, isFalse, reason: 'must not steal focus from the rival scope');
});
testWidgets('Apple TV first focus opens once, dismissal and refocus stay closed, Select reopens', (tester) async {
TvDetectionService.debugSetAppleTVOverride(true);
final controller = TextEditingController();
final fieldFocusNode = FocusNode(debugLabel: 'native_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: [
// Defaulted: exercises `automatic` resolving to onFirstFocus.
FocusableTextField(controller: controller, focusNode: fieldFocusNode),
Focus(focusNode: otherFocusNode, child: const SizedBox.shrink()),
],
),
),
),
);
bool readOnly() => tester.widget<TextField>(find.byType(TextField)).readOnly;
List<String> drain() {
final methods = tester.testTextInput.log.map((call) => call.method).toList();
tester.testTextInput.log.clear();
return methods;
}
// 1. First focus raises input once.
drain();
fieldFocusNode.requestFocus();
await tester.pumpAndSettle();
expect(readOnly(), isFalse, reason: 'first focus should open');
expect(drain().where((m) => m == 'TextInput.show'), hasLength(1));
// 2. UIKit dismissal keeps focus and does not reopen.
tester.testTextInput.closeConnection();
await tester.pumpAndSettle();
expect(fieldFocusNode.hasPrimaryFocus, isTrue, reason: 'dismissal must not strand focus');
expect(readOnly(), isTrue);
expect(drain(), isNot(contains('TextInput.show')));
// 3. Navigating away and back stays closed.
otherFocusNode.requestFocus();
await tester.pumpAndSettle();
drain();
fieldFocusNode.requestFocus();
await tester.pumpAndSettle();
expect(readOnly(), isTrue, reason: 'refocus must not reopen');
expect(drain(), isNot(contains('TextInput.show')));
// 4. Select always reopens.
await _raiseNativeInput(tester);
expect(readOnly(), isFalse);
});
testWidgets('Apple TV controller closes native input without losing field focus', (tester) async {
TvDetectionService.debugSetAppleTVOverride(true);
final controller = TextEditingController();
@@ -366,6 +555,7 @@ void main() {
fieldFocusNode.requestFocus();
await tester.pumpAndSettle();
await _raiseNativeInput(tester);
expect(tester.widget<TextField>(find.byType(TextField)).readOnly, isFalse);
textInputController.closeTextInput();
@@ -403,6 +593,7 @@ void main() {
fieldFocusNode.requestFocus();
await tester.pumpAndSettle();
await _raiseNativeInput(tester);
expect(tester.widget<TextField>(find.byType(TextField)).readOnly, isFalse);
final result = fieldFocusNode.onKeyEvent!(fieldFocusNode, _remoteKey(LogicalKeyboardKey.arrowDown));
@@ -431,6 +622,7 @@ void main() {
fieldFocusNode.requestFocus();
await tester.pumpAndSettle();
await _raiseNativeInput(tester);
final result = fieldFocusNode.onKeyEvent!(fieldFocusNode, _remoteKey(LogicalKeyboardKey.goBack));
await tester.pumpAndSettle();
@@ -464,6 +656,9 @@ void main() {
fieldFocusNode.requestFocus();
await tester.pumpAndSettle();
// Raise input first; the Select below is the one that reproduces a UIKit
// dismissal arriving while Flutter still believes input is active.
await _raiseNativeInput(tester);
final result = fieldFocusNode.onKeyEvent!(fieldFocusNode, _remoteKey(LogicalKeyboardKey.select));
await tester.pump();
@@ -543,6 +738,51 @@ void main() {
expect(tester.widget<TextField>(find.byType(TextField)).readOnly, isFalse);
});
testWidgets('Apple TV automatic auto-open fires once, then stays closed on every refocus', (tester) async {
TvDetectionService.debugSetAppleTVOverride(true);
final controller = TextEditingController();
final fieldFocusNode = FocusNode(debugLabel: 'native_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: [
// No tvTextInputAutoOpenBehavior: exercises the `automatic`
// default, which resolves to `onFirstFocus` on native tvOS.
FocusableTextField(controller: controller, focusNode: fieldFocusNode),
Focus(focusNode: otherFocusNode, child: const SizedBox.shrink()),
],
),
),
),
);
bool readOnly() => tester.widget<TextField>(find.byType(TextField)).readOnly;
fieldFocusNode.requestFocus();
await tester.pumpAndSettle();
expect(readOnly(), isFalse, reason: 'first focus should raise input');
// Every later entry stays closed. `onFocus` reopened on all of them, which
// is what made D-pad traversal of a form unusable; `afterFirstFocus` would
// also reopen from entry 2 onwards.
for (var entry = 2; entry <= 4; entry++) {
otherFocusNode.requestFocus();
await tester.pumpAndSettle();
fieldFocusNode.requestFocus();
await tester.pumpAndSettle();
expect(readOnly(), isTrue, reason: 'focus entry $entry must not raise the system keyboard');
}
await _raiseNativeInput(tester);
expect(readOnly(), isFalse);
});
testWidgets('Android TV native keyboard done uses D-pad navigation', (tester) async {
TvDetectionService.debugSetAppleTVOverride(null);
await TvDetectionService.getInstance(forceTv: true);
@@ -1300,6 +1540,14 @@ Finder _tvKeyboardDoneKey(IconData icon) {
return find.descendant(of: find.byKey(const Key('tv_virtual_keyboard_panel')), matching: find.byIcon(icon));
}
/// Native tvOS input no longer auto-opens on focus — `automatic` resolves to
/// `never` there, because the system keyboard is modal and would make D-pad
/// traversal of a form impossible. An explicit Select raises it, as on device.
Future<void> _raiseNativeInput(WidgetTester tester) async {
await tester.sendKeyEvent(LogicalKeyboardKey.select);
await tester.pumpAndSettle();
}
KeyDownEvent _remoteKey(LogicalKeyboardKey key) {
return KeyDownEvent(
physicalKey: _physicalKeyFor(key),