From d55b875855d6d68200b5631997ee0ac147b5d410 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 31 Jul 2026 01:05:10 +0200 Subject: [PATCH] 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 --- lib/focus/focusable_text_field.dart | 107 +++++++- lib/screens/settings/add_jellyfin_screen.dart | 2 +- .../settings/seerr_connect_screen.dart | 2 +- test/screens/search_screen_test.dart | 9 +- .../settings/add_jellyfin_screen_test.dart | 97 +++++++ test/widgets/focusable_text_field_test.dart | 248 ++++++++++++++++++ 6 files changed, 457 insertions(+), 8 deletions(-) diff --git a/lib/focus/focusable_text_field.dart b/lib/focus/focusable_text_field.dart index dfd653ca..02ca5d01 100644 --- a/lib/focus/focusable_text_field.dart +++ b/lib/focus/focusable_text_field.dart @@ -36,10 +36,25 @@ bool _usesTvKeyboard({required TvTextInputPresentation presentation, TextInputTy String? _keyboardHint(InputDecoration? decoration) => decoration?.hintText ?? decoration?.labelText; enum TvTextInputAutoOpenBehavior { + /// Resolve per presentation: [onFirstFocus] for the native tvOS keyboard — + /// arriving at a field opens it once, but returning to it during D-pad + /// traversal does not, since it is a modal full-screen surface and + /// re-raising it on every pass makes a form untraversable. [onFocus] for the + /// in-app Flutter overlay, which is cheap, non-modal, and involves no UIKit + /// first responder. + automatic, + /// Open the selected TV text input presentation whenever the field receives - /// focus. + /// focus. On Apple TV this raises the system keyboard on every focus entry, + /// including plain D-pad traversal — prefer [automatic] unless the field is + /// the sole purpose of its screen. onFocus, + /// Open on the field's first focus, then stay closed on later focus entries. + /// Explicit tap/select still opens it, as does the first focus after a + /// focus-node or presentation change. + onFirstFocus, + /// Keep initial focus on the field without opening text input, then open it /// automatically on later focus entries. Explicit tap/select still opens it. afterFirstFocus, @@ -48,6 +63,17 @@ enum TvTextInputAutoOpenBehavior { never, } +/// Auto-open policy for an autofocused server-URL field (#1217): entering the +/// screen must not bury the form under a keyboard the user did not ask for. +/// +/// This is the one documented exception to the `automatic` rule that a field's +/// first focus opens text input — the URL field's first focus is the screen's +/// own `autofocus`, not the user arriving. Apple TV therefore waits for an +/// explicit Select; the in-app overlay is cheap enough to open on a deliberate +/// return. +TvTextInputAutoOpenBehavior get deferredUrlFieldAutoOpen => + PlatformDetector.isAppleTV() ? TvTextInputAutoOpenBehavior.never : TvTextInputAutoOpenBehavior.afterFirstFocus; + /// Imperative handle to TV text input for a [FocusableTextField] / /// [FocusableTextFormField]. Pass the same instance to the field's /// `tvTextInputController`; the field's host attaches itself on mount. @@ -618,7 +644,7 @@ abstract class _FocusableTextInputBase extends StatelessWidget { this.autofocus = false, this.enabled = true, this.tvTextInputPresentation = TvTextInputPresentation.automatic, - this.tvTextInputAutoOpenBehavior = TvTextInputAutoOpenBehavior.onFocus, + this.tvTextInputAutoOpenBehavior = TvTextInputAutoOpenBehavior.automatic, this.tvTextInputController, this.obscureText = false, this.autocorrect = true, @@ -818,11 +844,46 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> { } void _handleFocusChanged() { + _restoreFocusAfterPlatformDismissal(); _syncNativeTextInputActivation(); _syncNativeTextInputFocus(); _syncTvKeyboardAutoOpen(); } + /// [EditableText.connectionClosed] unfocuses the field outright + /// (editable_text.dart:4138-4145). On tvOS that fires whenever UIKit + /// dismisses the system keyboard, which is a dismissal, not a navigation — + /// left alone it strands the user with nothing focused and no way back. + /// + /// Deliberate exits never reach here: every path that moves focus off an + /// active field (D-pad escape, Menu, [TvTextInputController.closeTextInput], + /// submit) deactivates first. `unfocus()` parks focus on the field's own + /// enclosing scope, so that exact node — not merely "some scope" — is the + /// signature. A dialog or route opening in the post-frame gap makes *its* + /// scope primary, which must not be mistaken for our dismissal. + void _restoreFocusAfterPlatformDismissal() { + final node = _installedFocusNode; + if (node == null || node.hasFocus || !_nativeTextInputActivated) return; + // Apple TV only: Android TV's IME close keeps its historical semantics, + // and no production field selects the native path there anyway. + if (!PlatformDetector.isAppleTV()) return; + if (!widget.input.enabled || !widget.input._usesNativeTvKeyboard) return; + final scope = node.enclosingScope; + if (scope == null || !identical(FocusManager.instance.primaryFocus, scope)) return; + + _setNativeTextInputActivated(false); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final target = _installedFocusNode; + if (target == null || target.hasFocus || !target.canRequestFocus) return; + if (!identical(FocusManager.instance.primaryFocus, scope)) return; + // Set before requesting focus so the resulting focus-change callback + // cannot reopen the keyboard we were just dismissed out of. + _suppressNativeTextInputForCurrentFocus = true; + target.requestFocus(); + }); + } + void _syncNativeTextInputActivation() { final input = widget.input; final focused = _installedFocusNode?.hasFocus == true && input.enabled && input._usesNativeTvKeyboard; @@ -834,6 +895,22 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> { if (_suppressNativeTextInputForCurrentFocus) return; switch (input.tvTextInputAutoOpenBehavior) { + // Apple TV's system keyboard is modal and full-screen. Arriving at a + // field should still open it — otherwise typing always costs two + // presses — but re-raising it every time D-pad traversal passes back + // over the field makes a multi-field form unusable, so `automatic` + // resolves to `onFirstFocus` there. Android TV's native IME is a docked + // soft keyboard that does not take over the screen, so it keeps the + // historical auto-open. Explicit modes stay literal on both: a caller + // that asks for onFocus gets onFocus. + case TvTextInputAutoOpenBehavior.automatic: + if (PlatformDetector.isAppleTV() && _hasSeenNativeTextInputFocus) return; + _hasSeenNativeTextInputFocus = true; + _setNativeTextInputActivated(true); + case TvTextInputAutoOpenBehavior.onFirstFocus: + if (_hasSeenNativeTextInputFocus) return; + _hasSeenNativeTextInputFocus = true; + _setNativeTextInputActivated(true); case TvTextInputAutoOpenBehavior.onFocus: _hasSeenNativeTextInputFocus = true; _setNativeTextInputActivated(true); @@ -880,15 +957,24 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> { final input = widget.input; final callback = input._effectiveOnEditingComplete; + final onSubmitted = input.onSubmitted; if (callback != null) { callback(); - } else if (input.onSubmitted == null) { + } else if (onSubmitted == null) { // Supplying this wrapper replaces EditableText's default completion. // Preserve it when there is no submit callback; submitted TV fields keep // focus until their callback chooses the next target so D-pad navigation // cannot dead-end while asynchronous work runs. _defaultEditingComplete(input.textInputAction); } + + // EditableText invokes onEditingComplete and onSubmitted independently + // (_finalizeEditing, editable_text.dart:3841-3898), so both must fire when + // both are supplied. The native path withholds onSubmitted from the widget + // — letting EditableText own it would schedule a connection restart that + // re-attaches and re-shows the input we just dismissed, which on tvOS + // tears the system keyboard down and back up mid-submit — so call it here. + onSubmitted?.call(input.controller.text); } void _syncNativeTextInputFocus() { @@ -946,8 +1032,15 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> { bool _shouldAutoOpenTvKeyboardForCurrentFocus() { switch (widget.input.tvTextInputAutoOpenBehavior) { + // The Flutter overlay is an in-app, non-modal widget with no UIKit first + // responder behind it, so opening it on focus costs nothing. + case TvTextInputAutoOpenBehavior.automatic: case TvTextInputAutoOpenBehavior.onFocus: return true; + case TvTextInputAutoOpenBehavior.onFirstFocus: + if (_hasSeenTvKeyboardFocus) return false; + _hasSeenTvKeyboardFocus = true; + return true; case TvTextInputAutoOpenBehavior.afterFirstFocus: if (!_hasSeenTvKeyboardFocus) { _hasSeenTvKeyboardFocus = true; @@ -1237,7 +1330,10 @@ class FocusableTextField extends _FocusableTextInputBase { textInputAction: textInputAction, inputFormatters: inputFormatters, onChanged: onChanged, - onSubmitted: onSubmitted, + // Withheld on the native TV path: the host invokes it from + // _handleNativeEditingComplete so EditableText cannot schedule a + // connection restart that re-shows the dismissed input. + onSubmitted: _usesNativeTvKeyboard ? null : onSubmitted, onEditingComplete: onEditingComplete, autofocus: autofocus, autocorrect: autocorrect, @@ -1326,7 +1422,8 @@ class FocusableTextFormField extends _FocusableTextInputBase { textInputAction: textInputAction, inputFormatters: inputFormatters, onChanged: onChanged, - onFieldSubmitted: onFieldSubmitted, + // Withheld on the native TV path — see FocusableTextField.build. + onFieldSubmitted: _usesNativeTvKeyboard ? null : onFieldSubmitted, onEditingComplete: onEditingComplete, validator: validator, autovalidateMode: autovalidateMode, diff --git a/lib/screens/settings/add_jellyfin_screen.dart b/lib/screens/settings/add_jellyfin_screen.dart index ae25bcc6..82bdc2a2 100644 --- a/lib/screens/settings/add_jellyfin_screen.dart +++ b/lib/screens/settings/add_jellyfin_screen.dart @@ -488,7 +488,7 @@ class _AddJellyfinScreenState extends State with AsyncFormSta ? TvTextInputPresentation.platform : TvTextInputPresentation.automatic, autofocus: true, - tvTextInputAutoOpenBehavior: TvTextInputAutoOpenBehavior.afterFirstFocus, + tvTextInputAutoOpenBehavior: deferredUrlFieldAutoOpen, keyboardType: TextInputType.url, minLines: 1, maxLines: 4, diff --git a/lib/screens/settings/seerr_connect_screen.dart b/lib/screens/settings/seerr_connect_screen.dart index 63c223b2..caa3574b 100644 --- a/lib/screens/settings/seerr_connect_screen.dart +++ b/lib/screens/settings/seerr_connect_screen.dart @@ -184,7 +184,7 @@ class _SeerrConnectScreenState extends State with AsyncFormS controller: _urlController, focusNode: _urlFocus, autofocus: true, - tvTextInputAutoOpenBehavior: TvTextInputAutoOpenBehavior.afterFirstFocus, + tvTextInputAutoOpenBehavior: deferredUrlFieldAutoOpen, keyboardType: TextInputType.url, autocorrect: false, enableSuggestions: false, diff --git a/test/screens/search_screen_test.dart b/test/screens/search_screen_test.dart index b2a7191a..96b8b244 100644 --- a/test/screens/search_screen_test.dart +++ b/test/screens/search_screen_test.dart @@ -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(find.byType(TextField)).readOnly, isTrue); + + await tester.sendKeyEvent(LogicalKeyboardKey.select); + await tester.pumpAndSettle(); expect(tester.widget(find.byType(TextField)).readOnly, isFalse); expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsNothing); diff --git a/test/screens/settings/add_jellyfin_screen_test.dart b/test/screens/settings/add_jellyfin_screen_test.dart index ff4736e1..17e6f883 100644 --- a/test/screens/settings/add_jellyfin_screen_test.dart +++ b/test/screens/settings/add_jellyfin_screen_test.dart @@ -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(find.byType(TextField)).readOnly, isTrue); + + await tester.sendKeyEvent(LogicalKeyboardKey.select); + await tester.pumpAndSettle(); + expect(tester.widget(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 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 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(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 dismissIfOpen() async { + final open = tester.widgetList(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 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( diff --git a/test/widgets/focusable_text_field_test.dart b/test/widgets/focusable_text_field_test.dart index 652d2788..7e787923 100644 --- a/test/widgets/focusable_text_field_test.dart +++ b/test/widgets/focusable_text_field_test.dart @@ -256,6 +256,7 @@ void main() { fieldFocusNode.requestFocus(); await tester.pumpAndSettle(); + await _raiseNativeInput(tester); expect(tester.widget(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 = []; + 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(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(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(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(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(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(find.byType(TextField)).readOnly; + List 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(find.byType(TextField)).readOnly, isFalse); textInputController.closeTextInput(); @@ -403,6 +593,7 @@ void main() { fieldFocusNode.requestFocus(); await tester.pumpAndSettle(); + await _raiseNativeInput(tester); expect(tester.widget(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(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(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 _raiseNativeInput(WidgetTester tester) async { + await tester.sendKeyEvent(LogicalKeyboardKey.select); + await tester.pumpAndSettle(); +} + KeyDownEvent _remoteKey(LogicalKeyboardKey key) { return KeyDownEvent( physicalKey: _physicalKeyFor(key),