fix(tvos): restore native text input navigation

This commit is contained in:
edde746
2026-07-26 07:08:01 +02:00
parent 71735354b9
commit 829d3745a1
8 changed files with 193 additions and 60 deletions
+45 -9
View File
@@ -62,7 +62,7 @@ class TvTextInputController {
if (identical(_host, host)) _host = null; if (identical(_host, host)) _host = null;
} }
/// Dismiss the Flutter overlay if it is open and prevent it from reopening /// Dismiss active native or Flutter text input and prevent it from reopening
/// while the field keeps focus. /// while the field keeps focus.
void closeTextInput() => _host?._dismissTvKeyboard(); void closeTextInput() => _host?._dismissTvKeyboard();
@@ -730,6 +730,7 @@ typedef _FocusableTextInputBuilder =
required FocusNode focusNode, required FocusNode focusNode,
required VoidCallback openKeyboard, required VoidCallback openKeyboard,
required VoidCallback activateNativeTextInput, required VoidCallback activateNativeTextInput,
required VoidCallback? onEditingComplete,
}); });
class _FocusableTextInputHost extends StatefulWidget { class _FocusableTextInputHost extends StatefulWidget {
@@ -759,6 +760,7 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
bool _nativeTextInputActivated = false; bool _nativeTextInputActivated = false;
bool _hasSeenNativeTextInputFocus = false; bool _hasSeenNativeTextInputFocus = false;
bool _suppressNativeTextInputForCurrentFocus = false; bool _suppressNativeTextInputForCurrentFocus = false;
bool _nativeTextInputCompletionHandled = false;
FocusNode get _effectiveFocusNode => _focusNodeBinding.node; FocusNode get _effectiveFocusNode => _focusNodeBinding.node;
@@ -849,6 +851,7 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
void _setNativeTextInputActivated(bool activated) { void _setNativeTextInputActivated(bool activated) {
if (_nativeTextInputActivated == activated) return; if (_nativeTextInputActivated == activated) return;
if (activated) _nativeTextInputCompletionHandled = false;
if (!mounted) { if (!mounted) {
_nativeTextInputActivated = activated; _nativeTextInputActivated = activated;
return; return;
@@ -863,6 +866,31 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
_setNativeTextInputActivated(true); _setNativeTextInputActivated(true);
} }
VoidCallback? get _effectiveOnEditingComplete {
final input = widget.input;
if (!input._usesNativeTvKeyboard) return input._effectiveOnEditingComplete;
return _handleNativeEditingComplete;
}
void _handleNativeEditingComplete() {
if (_nativeTextInputCompletionHandled) return;
_nativeTextInputCompletionHandled = true;
_suppressNativeTextInputForCurrentFocus = true;
_setNativeTextInputActivated(false);
final input = widget.input;
final callback = input._effectiveOnEditingComplete;
if (callback != null) {
callback();
} else if (input.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);
}
}
void _syncNativeTextInputFocus() { void _syncNativeTextInputFocus() {
final focused = _installedFocusNode?.hasFocus == true && widget.input.enabled && widget.input._usesNativeTvKeyboard; final focused = _installedFocusNode?.hasFocus == true && widget.input.enabled && widget.input._usesNativeTvKeyboard;
if (TextInputDiagnostics.enabled) { if (TextInputDiagnostics.enabled) {
@@ -993,13 +1021,18 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
); );
} }
/// Dismiss an open OSK imperatively (e.g. a companion-remote search that must /// Dismiss active text input imperatively (e.g. a companion-remote search
/// show results instead of the keyboard). Suppresses auto-reopen so a field /// that must show results instead of the keyboard). Suppresses auto-reopen
/// that regains focus when the dialog route pops does not relaunch it. /// so a field that keeps or regains focus does not relaunch it.
void _dismissTvKeyboard() { void _dismissTvKeyboard() {
// No-op when nothing is up: setting the suppress flag here (with no if (widget.input._usesNativeTvKeyboard && _nativeTextInputActivated) {
// compensating unfocus to clear it) would block a later legitimate _suppressNativeTextInputForCurrentFocus = true;
// auto-open when the user deliberately focuses the field. _setNativeTextInputActivated(false);
}
// No-op for the Flutter presentation when no overlay is up: setting its
// suppress flag without a compensating unfocus would block a later
// legitimate auto-open.
if (!_tvKeyboardOpen && !_tvKeyboardOpenScheduled) return; if (!_tvKeyboardOpen && !_tvKeyboardOpenScheduled) return;
_tvKeyboardOpenScheduled = false; _tvKeyboardOpenScheduled = false;
_suppressTvKeyboardAutoOpen = true; _suppressTvKeyboardAutoOpen = true;
@@ -1132,6 +1165,7 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
focusNode: focusNode, focusNode: focusNode,
openKeyboard: _openTvKeyboard, openKeyboard: _openTvKeyboard,
activateNativeTextInput: _activateNativeTextInput, activateNativeTextInput: _activateNativeTextInput,
onEditingComplete: _effectiveOnEditingComplete,
); );
} }
} }
@@ -1186,6 +1220,7 @@ class FocusableTextField extends _FocusableTextInputBase {
required FocusNode focusNode, required FocusNode focusNode,
required VoidCallback openKeyboard, required VoidCallback openKeyboard,
required VoidCallback activateNativeTextInput, required VoidCallback activateNativeTextInput,
required VoidCallback? onEditingComplete,
}) { }) {
final tvInput = _tvInputConfiguration( final tvInput = _tvInputConfiguration(
usesTvKeyboard: usesTvKeyboard, usesTvKeyboard: usesTvKeyboard,
@@ -1203,7 +1238,7 @@ class FocusableTextField extends _FocusableTextInputBase {
inputFormatters: inputFormatters, inputFormatters: inputFormatters,
onChanged: onChanged, onChanged: onChanged,
onSubmitted: onSubmitted, onSubmitted: onSubmitted,
onEditingComplete: _effectiveOnEditingComplete, onEditingComplete: onEditingComplete,
autofocus: autofocus, autofocus: autofocus,
autocorrect: autocorrect, autocorrect: autocorrect,
enableSuggestions: enableSuggestions, enableSuggestions: enableSuggestions,
@@ -1274,6 +1309,7 @@ class FocusableTextFormField extends _FocusableTextInputBase {
required FocusNode focusNode, required FocusNode focusNode,
required VoidCallback openKeyboard, required VoidCallback openKeyboard,
required VoidCallback activateNativeTextInput, required VoidCallback activateNativeTextInput,
required VoidCallback? onEditingComplete,
}) { }) {
final tvInput = _tvInputConfiguration( final tvInput = _tvInputConfiguration(
usesTvKeyboard: usesTvKeyboard, usesTvKeyboard: usesTvKeyboard,
@@ -1291,7 +1327,7 @@ class FocusableTextFormField extends _FocusableTextInputBase {
inputFormatters: inputFormatters, inputFormatters: inputFormatters,
onChanged: onChanged, onChanged: onChanged,
onFieldSubmitted: onFieldSubmitted, onFieldSubmitted: onFieldSubmitted,
onEditingComplete: _effectiveOnEditingComplete, onEditingComplete: onEditingComplete,
validator: validator, validator: validator,
autovalidateMode: autovalidateMode, autovalidateMode: autovalidateMode,
onSaved: onSaved, onSaved: onSaved,
-1
View File
@@ -72,7 +72,6 @@ class _CatalogSearchScreenState extends State<CatalogSearchScreen> with Debounce
FocusableTextField( FocusableTextField(
controller: searchController, controller: searchController,
focusNode: searchFocusNode, focusNode: searchFocusNode,
tvTextInputPresentation: TvTextInputPresentation.flutterOverlay,
textInputAction: TextInputAction.search, textInputAction: TextInputAction.search,
onNavigateDown: searchResults.isNotEmpty && !isSearching ? firstResultFocusNode.requestFocus : null, onNavigateDown: searchResults.isNotEmpty && !isSearching ? firstResultFocusNode.requestFocus : null,
onNavigateRight: searchController.text.isNotEmpty ? _clearFocusNode.requestFocus : null, onNavigateRight: searchController.text.isNotEmpty ? _clearFocusNode.requestFocus : null,
-1
View File
@@ -226,7 +226,6 @@ class _SearchScreenState extends State<SearchScreen>
controller: searchController, controller: searchController,
focusNode: searchFocusNode, focusNode: searchFocusNode,
tvTextInputController: _tvTextInputController, tvTextInputController: _tvTextInputController,
tvTextInputPresentation: TvTextInputPresentation.flutterOverlay,
textInputAction: TextInputAction.search, textInputAction: TextInputAction.search,
onNavigateLeft: _navigateToSidebar, onNavigateLeft: _navigateToSidebar,
onNavigateRight: searchController.text.isNotEmpty ? _clearFocusNode.requestFocus : null, onNavigateRight: searchController.text.isNotEmpty ? _clearFocusNode.requestFocus : null,
@@ -487,7 +487,9 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
FocusableTextFormField( FocusableTextFormField(
controller: _urlController, controller: _urlController,
focusNode: _urlFocus, focusNode: _urlFocus,
tvTextInputPresentation: TvTextInputPresentation.flutterOverlay, tvTextInputPresentation: PlatformDetector.isAppleTV()
? TvTextInputPresentation.platform
: TvTextInputPresentation.automatic,
autofocus: true, autofocus: true,
tvTextInputAutoOpenBehavior: TvTextInputAutoOpenBehavior.afterFirstFocus, tvTextInputAutoOpenBehavior: TvTextInputAutoOpenBehavior.afterFirstFocus,
keyboardType: TextInputType.url, keyboardType: TextInputType.url,
@@ -962,6 +962,7 @@ class _RelayUrlDialogState extends State<_RelayUrlDialog> {
} }
}, },
onEditingComplete: () => _saveFocusNode.requestFocus(), onEditingComplete: () => _saveFocusNode.requestFocus(),
onNavigateDown: _saveFocusNode.requestFocus,
), ),
actions: [ actions: [
DialogActionButton(onPressed: _reset, label: t.settings.resetToDefault), DialogActionButton(onPressed: _reset, label: t.settings.resetToDefault),
+26 -46
View File
@@ -78,38 +78,38 @@ void main() {
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
}); });
testWidgets('TV OSK search key moves focus to the first result', (tester) async { testWidgets('TV native Search action moves focus to the first result', (tester) async {
final (client, _) = await _pumpTvSearchScreen(tester); final (client, _) = await _pumpTvSearchScreen(tester);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsOneWidget); expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsNothing);
expect(tester.widget<TextField>(find.byType(TextField)).readOnly, isFalse);
_searchController(tester).text = 'movie'; _searchController(tester).text = 'movie';
// Let the normal debounce populate results while the OSK remains open. // Let the normal debounce populate results while native input remains active.
// DebouncedMediaSearch now uses a fake-clock-aware Timer.
await tester.pump(const Duration(milliseconds: 500)); await tester.pump(const Duration(milliseconds: 500));
await tester.pump(); await tester.pump();
expect(client.queries, ['movie']); expect(client.queries, ['movie']);
expect(find.text('Movie 1'), findsOneWidget); expect(find.text('Movie 1'), findsOneWidget);
await tester.tap(_keyboardDoneKey()); await tester.showKeyboard(find.byType(TextField));
await tester.testTextInput.receiveAction(TextInputAction.search);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsNothing);
expect(FocusManager.instance.primaryFocus?.debugLabel, 'SearchFirstResult'); expect(FocusManager.instance.primaryFocus?.debugLabel, 'SearchFirstResult');
expect(find.text('Movie 1'), findsOneWidget); expect(find.text('Movie 1'), findsOneWidget);
expect(client.queries, ['movie']); expect(client.queries, ['movie']);
}); });
testWidgets('TV OSK search key before the debounce fires searches immediately', (tester) async { testWidgets('TV native Search action before debounce searches immediately', (tester) async {
final (client, key) = await _pumpTvSearchScreen(tester); final (client, _) = await _pumpTvSearchScreen(tester);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsOneWidget);
_searchController(tester).text = 'movie'; _searchController(tester).text = 'movie';
await tester.pump(const Duration(milliseconds: 100)); await tester.pump(const Duration(milliseconds: 100));
expect(client.queries, isEmpty); expect(client.queries, isEmpty);
await tester.tap(_keyboardDoneKey()); await tester.showKeyboard(find.byType(TextField));
await tester.testTextInput.receiveAction(TextInputAction.search);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(client.queries, ['movie']); expect(client.queries, ['movie']);
@@ -117,88 +117,77 @@ void main() {
expect(FocusManager.instance.primaryFocus?.debugLabel, 'SearchFirstResult'); expect(FocusManager.instance.primaryFocus?.debugLabel, 'SearchFirstResult');
}); });
testWidgets('companion-remote submitSearchQuery dismisses an open OSK and focuses results', (tester) async { testWidgets('companion-remote submitSearchQuery closes native input and focuses results', (tester) async {
final (client, key) = await _pumpTvSearchScreen(tester); final (client, key) = await _pumpTvSearchScreen(tester);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// The search screen autofocuses its input on TV, so the OSK is already up — expect(tester.widget<TextField>(find.byType(TextField)).readOnly, isFalse);
// exactly the "keyboard already open when the remote search arrives" flow
// (the phone's Search chip sends tabSearch before the query).
expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsOneWidget);
(key.currentState! as SearchInputFocusable).submitSearchQuery('movie'); (key.currentState! as SearchInputFocusable).submitSearchQuery('movie');
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(client.queries, ['movie']); expect(client.queries, ['movie']);
expect(find.text('Movie 1'), findsOneWidget); expect(find.text('Movie 1'), findsOneWidget);
// The OSK is dismissed (and does not auto-reopen), focus lands on results.
expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsNothing); expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsNothing);
expect(FocusManager.instance.primaryFocus?.debugLabel, 'SearchFirstResult'); expect(FocusManager.instance.primaryFocus?.debugLabel, 'SearchFirstResult');
// Stays closed on subsequent frames, and the selection write from the // Selection updates must not re-arm the debounce into a second fetch.
// focus change must not re-arm the debounce into a second identical fetch.
await tester.pump(const Duration(milliseconds: 600)); await tester.pump(const Duration(milliseconds: 600));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsNothing);
expect(client.queries, ['movie']); expect(client.queries, ['movie']);
// Re-submitting already-displayed results requests the input and then the // Re-submitting already-displayed results leaves result focus stable.
// existing first result in the same turn. That superseded input request
// must not leave its one-focus-entry keyboard suppression stuck.
final searchInput = key.currentState! as SearchInputFocusable; final searchInput = key.currentState! as SearchInputFocusable;
searchInput.submitSearchQuery('movie'); searchInput.submitSearchQuery('movie');
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(FocusManager.instance.primaryFocus?.debugLabel, 'SearchFirstResult'); expect(FocusManager.instance.primaryFocus?.debugLabel, 'SearchFirstResult');
expect(client.queries, ['movie']); expect(client.queries, ['movie']);
// A deliberate return to the input starts a fresh native session.
searchInput.focusSearchInput(); searchInput.focusSearchInput();
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsOneWidget); expect(tester.widget<TextField>(find.byType(TextField)).readOnly, isFalse);
expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsNothing);
await tester.pumpWidget(const SizedBox.shrink()); await tester.pumpWidget(const SizedBox.shrink());
}); });
testWidgets('companion-remote submitSearchQuery with no results focuses the input without the OSK', (tester) async { testWidgets('companion-remote query with no results keeps native input closed', (tester) async {
final (client, key) = await _pumpTvSearchScreen(tester, items: []); final (client, key) = await _pumpTvSearchScreen(tester, items: []);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsOneWidget); expect(tester.widget<TextField>(find.byType(TextField)).readOnly, isFalse);
(key.currentState! as SearchInputFocusable).submitSearchQuery('zzz'); (key.currentState! as SearchInputFocusable).submitSearchQuery('zzz');
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(client.queries, ['zzz']); expect(client.queries, ['zzz']);
// No results: the OSK is dismissed and the input keeps focus WITHOUT the
// keyboard reopening, so the remote isn't stranded.
expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsNothing); expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsNothing);
expect(FocusManager.instance.primaryFocus?.debugLabel, 'SearchInput'); expect(FocusManager.instance.primaryFocus?.debugLabel, 'SearchInput');
expect(tester.widget<TextField>(find.byType(TextField)).readOnly, isTrue);
// Does not auto-reopen while the input keeps focus.
await tester.pump(const Duration(milliseconds: 200)); await tester.pump(const Duration(milliseconds: 200));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsNothing); expect(tester.widget<TextField>(find.byType(TextField)).readOnly, isTrue);
await tester.pumpWidget(const SizedBox.shrink()); await tester.pumpWidget(const SizedBox.shrink());
}); });
testWidgets('companion-remote submitSearchQuery whose search fails keeps focus on the input without the OSK', ( testWidgets('failed companion-remote query keeps native input closed', (tester) async {
tester,
) async {
final (client, key) = await _pumpTvSearchScreen(tester, registerClient: false); final (client, key) = await _pumpTvSearchScreen(tester, registerClient: false);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsOneWidget); expect(tester.widget<TextField>(find.byType(TextField)).readOnly, isFalse);
(key.currentState! as SearchInputFocusable).submitSearchQuery('movie'); (key.currentState! as SearchInputFocusable).submitSearchQuery('movie');
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// performSearchQuery threw (no servers): the failed state renders, the OSK
// is dismissed, and the input keeps focus so the remote isn't stranded.
expect(client.queries, isEmpty); expect(client.queries, isEmpty);
expect(find.byIcon(Symbols.error_rounded), findsOneWidget); expect(find.byIcon(Symbols.error_rounded), findsOneWidget);
expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsNothing); expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsNothing);
expect(FocusManager.instance.primaryFocus?.debugLabel, 'SearchInput'); expect(FocusManager.instance.primaryFocus?.debugLabel, 'SearchInput');
expect(tester.widget<TextField>(find.byType(TextField)).readOnly, isTrue);
await tester.pump(const Duration(milliseconds: 200)); await tester.pump(const Duration(milliseconds: 200));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsNothing); expect(tester.widget<TextField>(find.byType(TextField)).readOnly, isTrue);
await tester.pumpWidget(const SizedBox.shrink()); await tester.pumpWidget(const SizedBox.shrink());
}); });
@@ -292,9 +281,7 @@ Future<(_FakeMediaServerClient, GlobalKey<State<SearchScreen>>)> _pumpTvSearchSc
bool registerClient = true, bool registerClient = true,
List<_FakeMediaServerClient> additionalClients = const [], List<_FakeMediaServerClient> additionalClients = const [],
}) async { }) async {
TvDetectionService.debugSetAppleTVOverride(null); TvDetectionService.debugSetAppleTVOverride(true);
await TvDetectionService.getInstance(forceTv: true);
TvDetectionService.setForceTVSync(true);
tester.view.devicePixelRatio = 1.0; tester.view.devicePixelRatio = 1.0;
tester.view.physicalSize = const Size(1280, 720); tester.view.physicalSize = const Size(1280, 720);
addTearDown(() { addTearDown(() {
@@ -345,13 +332,6 @@ Future<(_FakeMediaServerClient, GlobalKey<State<SearchScreen>>)> _pumpTvSearchSc
return (client, key); return (client, key);
} }
Finder _keyboardDoneKey() {
return find.descendant(
of: find.byKey(const Key('tv_virtual_keyboard_panel')),
matching: find.byIcon(Symbols.search_rounded),
);
}
TextEditingController _searchController(WidgetTester tester) { TextEditingController _searchController(WidgetTester tester) {
return tester.widget<FocusableTextField>(find.byType(FocusableTextField)).controller; return tester.widget<FocusableTextField>(find.byType(FocusableTextField)).controller;
} }
@@ -370,6 +370,7 @@ void main() {
expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddJellyfin:Url'); expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddJellyfin:Url');
expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsNothing); expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsNothing);
expect(tester.widget<TextField>(find.byType(TextField)).keyboardType, TextInputType.url);
}); });
testWidgets('Android TV D-pad can leave initial URL focus before keyboard opens', (tester) async { testWidgets('Android TV D-pad can leave initial URL focus before keyboard opens', (tester) async {
@@ -425,8 +426,9 @@ void main() {
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(FocusManager.instance.primaryFocus?.debugLabel, 'TvVirtualKeyboard'); expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddJellyfin:Url');
expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsOneWidget); expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsNothing);
expect(tester.widget<TextField>(find.byType(TextField)).readOnly, isFalse);
}); });
testWidgets('D-pad moves from URL through Change to credentials after server is found', (tester) async { testWidgets('D-pad moves from URL through Change to credentials after server is found', (tester) async {
+114
View File
@@ -261,6 +261,120 @@ void main() {
expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsNothing); expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsNothing);
}); });
testWidgets('Apple TV native Done deactivates input before D-pad navigation', (tester) async {
TvDetectionService.debugSetAppleTVOverride(true);
final controller = TextEditingController();
final fieldFocusNode = FocusNode(debugLabel: 'native_url_field');
final nextFocusNode = FocusNode(debugLabel: 'save_button');
var completed = 0;
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,
onEditingComplete: () => completed++,
onNavigateDown: nextFocusNode.requestFocus,
),
FilledButton(focusNode: nextFocusNode, onPressed: () {}, child: const Text('Save')),
],
),
),
),
);
fieldFocusNode.requestFocus();
await tester.pumpAndSettle();
await tester.showKeyboard(find.byType(TextField));
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pump();
expect(completed, 1);
expect(fieldFocusNode.hasPrimaryFocus, isTrue);
expect(tester.widget<TextField>(find.byType(TextField)).readOnly, isTrue);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.pump();
expect(nextFocusNode.hasPrimaryFocus, isTrue);
expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsNothing);
});
testWidgets('Apple TV native Go submits once and deactivates input', (tester) async {
TvDetectionService.debugSetAppleTVOverride(true);
final controller = TextEditingController(text: 'https://jellyfin.example.com');
final fieldFocusNode = FocusNode(debugLabel: 'native_url_field');
final submissions = <String>[];
addTearDown(controller.dispose);
addTearDown(fieldFocusNode.dispose);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: FocusableTextFormField(
controller: controller,
focusNode: fieldFocusNode,
tvTextInputPresentation: TvTextInputPresentation.platform,
textInputAction: TextInputAction.go,
onFieldSubmitted: submissions.add,
),
),
),
);
fieldFocusNode.requestFocus();
await tester.pumpAndSettle();
await tester.showKeyboard(find.byType(TextField));
await tester.testTextInput.receiveAction(TextInputAction.go);
await tester.pump();
expect(submissions, ['https://jellyfin.example.com']);
expect(tester.widget<TextField>(find.byType(TextField)).readOnly, isTrue);
expect(fieldFocusNode.hasPrimaryFocus, isTrue);
tester.testTextInput.closeConnection();
await tester.pump();
expect(submissions, ['https://jellyfin.example.com']);
});
testWidgets('Apple TV controller closes native input without losing field focus', (tester) async {
TvDetectionService.debugSetAppleTVOverride(true);
final controller = TextEditingController();
final textInputController = TvTextInputController();
final fieldFocusNode = FocusNode(debugLabel: 'native_search_field');
addTearDown(controller.dispose);
addTearDown(fieldFocusNode.dispose);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: FocusableTextField(
controller: controller,
focusNode: fieldFocusNode,
tvTextInputController: textInputController,
),
),
),
);
fieldFocusNode.requestFocus();
await tester.pumpAndSettle();
expect(tester.widget<TextField>(find.byType(TextField)).readOnly, isFalse);
textInputController.closeTextInput();
await tester.pump();
expect(fieldFocusNode.hasPrimaryFocus, isTrue);
expect(tester.widget<TextField>(find.byType(TextField)).readOnly, isTrue);
});
testWidgets('Apple TV navigation resumes after native keyboard dismissal', (tester) async { testWidgets('Apple TV navigation resumes after native keyboard dismissal', (tester) async {
TvDetectionService.debugSetAppleTVOverride(true); TvDetectionService.debugSetAppleTVOverride(true);
final controller = TextEditingController(); final controller = TextEditingController();