fix(tvos): restore native text input navigation
This commit is contained in:
@@ -62,7 +62,7 @@ class TvTextInputController {
|
||||
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.
|
||||
void closeTextInput() => _host?._dismissTvKeyboard();
|
||||
|
||||
@@ -730,6 +730,7 @@ typedef _FocusableTextInputBuilder =
|
||||
required FocusNode focusNode,
|
||||
required VoidCallback openKeyboard,
|
||||
required VoidCallback activateNativeTextInput,
|
||||
required VoidCallback? onEditingComplete,
|
||||
});
|
||||
|
||||
class _FocusableTextInputHost extends StatefulWidget {
|
||||
@@ -759,6 +760,7 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
||||
bool _nativeTextInputActivated = false;
|
||||
bool _hasSeenNativeTextInputFocus = false;
|
||||
bool _suppressNativeTextInputForCurrentFocus = false;
|
||||
bool _nativeTextInputCompletionHandled = false;
|
||||
|
||||
FocusNode get _effectiveFocusNode => _focusNodeBinding.node;
|
||||
|
||||
@@ -849,6 +851,7 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
||||
|
||||
void _setNativeTextInputActivated(bool activated) {
|
||||
if (_nativeTextInputActivated == activated) return;
|
||||
if (activated) _nativeTextInputCompletionHandled = false;
|
||||
if (!mounted) {
|
||||
_nativeTextInputActivated = activated;
|
||||
return;
|
||||
@@ -863,6 +866,31 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
||||
_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() {
|
||||
final focused = _installedFocusNode?.hasFocus == true && widget.input.enabled && widget.input._usesNativeTvKeyboard;
|
||||
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
|
||||
/// show results instead of the keyboard). Suppresses auto-reopen so a field
|
||||
/// that regains focus when the dialog route pops does not relaunch it.
|
||||
/// Dismiss active text input imperatively (e.g. a companion-remote search
|
||||
/// that must show results instead of the keyboard). Suppresses auto-reopen
|
||||
/// so a field that keeps or regains focus does not relaunch it.
|
||||
void _dismissTvKeyboard() {
|
||||
// No-op when nothing is up: setting the suppress flag here (with no
|
||||
// compensating unfocus to clear it) would block a later legitimate
|
||||
// auto-open when the user deliberately focuses the field.
|
||||
if (widget.input._usesNativeTvKeyboard && _nativeTextInputActivated) {
|
||||
_suppressNativeTextInputForCurrentFocus = true;
|
||||
_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;
|
||||
_tvKeyboardOpenScheduled = false;
|
||||
_suppressTvKeyboardAutoOpen = true;
|
||||
@@ -1132,6 +1165,7 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
||||
focusNode: focusNode,
|
||||
openKeyboard: _openTvKeyboard,
|
||||
activateNativeTextInput: _activateNativeTextInput,
|
||||
onEditingComplete: _effectiveOnEditingComplete,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1186,6 +1220,7 @@ class FocusableTextField extends _FocusableTextInputBase {
|
||||
required FocusNode focusNode,
|
||||
required VoidCallback openKeyboard,
|
||||
required VoidCallback activateNativeTextInput,
|
||||
required VoidCallback? onEditingComplete,
|
||||
}) {
|
||||
final tvInput = _tvInputConfiguration(
|
||||
usesTvKeyboard: usesTvKeyboard,
|
||||
@@ -1203,7 +1238,7 @@ class FocusableTextField extends _FocusableTextInputBase {
|
||||
inputFormatters: inputFormatters,
|
||||
onChanged: onChanged,
|
||||
onSubmitted: onSubmitted,
|
||||
onEditingComplete: _effectiveOnEditingComplete,
|
||||
onEditingComplete: onEditingComplete,
|
||||
autofocus: autofocus,
|
||||
autocorrect: autocorrect,
|
||||
enableSuggestions: enableSuggestions,
|
||||
@@ -1274,6 +1309,7 @@ class FocusableTextFormField extends _FocusableTextInputBase {
|
||||
required FocusNode focusNode,
|
||||
required VoidCallback openKeyboard,
|
||||
required VoidCallback activateNativeTextInput,
|
||||
required VoidCallback? onEditingComplete,
|
||||
}) {
|
||||
final tvInput = _tvInputConfiguration(
|
||||
usesTvKeyboard: usesTvKeyboard,
|
||||
@@ -1291,7 +1327,7 @@ class FocusableTextFormField extends _FocusableTextInputBase {
|
||||
inputFormatters: inputFormatters,
|
||||
onChanged: onChanged,
|
||||
onFieldSubmitted: onFieldSubmitted,
|
||||
onEditingComplete: _effectiveOnEditingComplete,
|
||||
onEditingComplete: onEditingComplete,
|
||||
validator: validator,
|
||||
autovalidateMode: autovalidateMode,
|
||||
onSaved: onSaved,
|
||||
|
||||
@@ -72,7 +72,6 @@ class _CatalogSearchScreenState extends State<CatalogSearchScreen> with Debounce
|
||||
FocusableTextField(
|
||||
controller: searchController,
|
||||
focusNode: searchFocusNode,
|
||||
tvTextInputPresentation: TvTextInputPresentation.flutterOverlay,
|
||||
textInputAction: TextInputAction.search,
|
||||
onNavigateDown: searchResults.isNotEmpty && !isSearching ? firstResultFocusNode.requestFocus : null,
|
||||
onNavigateRight: searchController.text.isNotEmpty ? _clearFocusNode.requestFocus : null,
|
||||
|
||||
@@ -226,7 +226,6 @@ class _SearchScreenState extends State<SearchScreen>
|
||||
controller: searchController,
|
||||
focusNode: searchFocusNode,
|
||||
tvTextInputController: _tvTextInputController,
|
||||
tvTextInputPresentation: TvTextInputPresentation.flutterOverlay,
|
||||
textInputAction: TextInputAction.search,
|
||||
onNavigateLeft: _navigateToSidebar,
|
||||
onNavigateRight: searchController.text.isNotEmpty ? _clearFocusNode.requestFocus : null,
|
||||
|
||||
@@ -487,7 +487,9 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
|
||||
FocusableTextFormField(
|
||||
controller: _urlController,
|
||||
focusNode: _urlFocus,
|
||||
tvTextInputPresentation: TvTextInputPresentation.flutterOverlay,
|
||||
tvTextInputPresentation: PlatformDetector.isAppleTV()
|
||||
? TvTextInputPresentation.platform
|
||||
: TvTextInputPresentation.automatic,
|
||||
autofocus: true,
|
||||
tvTextInputAutoOpenBehavior: TvTextInputAutoOpenBehavior.afterFirstFocus,
|
||||
keyboardType: TextInputType.url,
|
||||
|
||||
@@ -962,6 +962,7 @@ class _RelayUrlDialogState extends State<_RelayUrlDialog> {
|
||||
}
|
||||
},
|
||||
onEditingComplete: () => _saveFocusNode.requestFocus(),
|
||||
onNavigateDown: _saveFocusNode.requestFocus,
|
||||
),
|
||||
actions: [
|
||||
DialogActionButton(onPressed: _reset, label: t.settings.resetToDefault),
|
||||
|
||||
@@ -78,38 +78,38 @@ void main() {
|
||||
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);
|
||||
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';
|
||||
// Let the normal debounce populate results while the OSK remains open.
|
||||
// DebouncedMediaSearch now uses a fake-clock-aware Timer.
|
||||
// Let the normal debounce populate results while native input remains active.
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
await tester.pump();
|
||||
expect(client.queries, ['movie']);
|
||||
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();
|
||||
|
||||
expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsNothing);
|
||||
expect(FocusManager.instance.primaryFocus?.debugLabel, 'SearchFirstResult');
|
||||
expect(find.text('Movie 1'), findsOneWidget);
|
||||
expect(client.queries, ['movie']);
|
||||
});
|
||||
|
||||
testWidgets('TV OSK search key before the debounce fires searches immediately', (tester) async {
|
||||
final (client, key) = await _pumpTvSearchScreen(tester);
|
||||
testWidgets('TV native Search action before debounce searches immediately', (tester) async {
|
||||
final (client, _) = await _pumpTvSearchScreen(tester);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsOneWidget);
|
||||
|
||||
_searchController(tester).text = 'movie';
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
expect(client.queries, isEmpty);
|
||||
|
||||
await tester.tap(_keyboardDoneKey());
|
||||
await tester.showKeyboard(find.byType(TextField));
|
||||
await tester.testTextInput.receiveAction(TextInputAction.search);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(client.queries, ['movie']);
|
||||
@@ -117,88 +117,77 @@ void main() {
|
||||
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);
|
||||
await tester.pumpAndSettle();
|
||||
// The search screen autofocuses its input on TV, so the OSK is already up —
|
||||
// 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);
|
||||
expect(tester.widget<TextField>(find.byType(TextField)).readOnly, isFalse);
|
||||
|
||||
(key.currentState! as SearchInputFocusable).submitSearchQuery('movie');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(client.queries, ['movie']);
|
||||
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(FocusManager.instance.primaryFocus?.debugLabel, 'SearchFirstResult');
|
||||
|
||||
// Stays closed on subsequent frames, and the selection write from the
|
||||
// focus change must not re-arm the debounce into a second identical fetch.
|
||||
// Selection updates must not re-arm the debounce into a second fetch.
|
||||
await tester.pump(const Duration(milliseconds: 600));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsNothing);
|
||||
expect(client.queries, ['movie']);
|
||||
|
||||
// Re-submitting already-displayed results requests the input and then the
|
||||
// existing first result in the same turn. That superseded input request
|
||||
// must not leave its one-focus-entry keyboard suppression stuck.
|
||||
// Re-submitting already-displayed results leaves result focus stable.
|
||||
final searchInput = key.currentState! as SearchInputFocusable;
|
||||
searchInput.submitSearchQuery('movie');
|
||||
await tester.pumpAndSettle();
|
||||
expect(FocusManager.instance.primaryFocus?.debugLabel, 'SearchFirstResult');
|
||||
expect(client.queries, ['movie']);
|
||||
|
||||
// A deliberate return to the input starts a fresh native session.
|
||||
searchInput.focusSearchInput();
|
||||
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());
|
||||
});
|
||||
|
||||
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: []);
|
||||
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');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
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(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.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());
|
||||
});
|
||||
|
||||
testWidgets('companion-remote submitSearchQuery whose search fails keeps focus on the input without the OSK', (
|
||||
tester,
|
||||
) async {
|
||||
testWidgets('failed companion-remote query keeps native input closed', (tester) async {
|
||||
final (client, key) = await _pumpTvSearchScreen(tester, registerClient: false);
|
||||
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');
|
||||
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(find.byIcon(Symbols.error_rounded), findsOneWidget);
|
||||
expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsNothing);
|
||||
expect(FocusManager.instance.primaryFocus?.debugLabel, 'SearchInput');
|
||||
expect(tester.widget<TextField>(find.byType(TextField)).readOnly, isTrue);
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
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());
|
||||
});
|
||||
@@ -292,9 +281,7 @@ Future<(_FakeMediaServerClient, GlobalKey<State<SearchScreen>>)> _pumpTvSearchSc
|
||||
bool registerClient = true,
|
||||
List<_FakeMediaServerClient> additionalClients = const [],
|
||||
}) async {
|
||||
TvDetectionService.debugSetAppleTVOverride(null);
|
||||
await TvDetectionService.getInstance(forceTv: true);
|
||||
TvDetectionService.setForceTVSync(true);
|
||||
TvDetectionService.debugSetAppleTVOverride(true);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
tester.view.physicalSize = const Size(1280, 720);
|
||||
addTearDown(() {
|
||||
@@ -345,13 +332,6 @@ Future<(_FakeMediaServerClient, GlobalKey<State<SearchScreen>>)> _pumpTvSearchSc
|
||||
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) {
|
||||
return tester.widget<FocusableTextField>(find.byType(FocusableTextField)).controller;
|
||||
}
|
||||
|
||||
@@ -370,6 +370,7 @@ void main() {
|
||||
|
||||
expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddJellyfin:Url');
|
||||
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 {
|
||||
@@ -425,8 +426,9 @@ void main() {
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(FocusManager.instance.primaryFocus?.debugLabel, 'TvVirtualKeyboard');
|
||||
expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsOneWidget);
|
||||
expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddJellyfin:Url');
|
||||
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 {
|
||||
|
||||
@@ -261,6 +261,120 @@ void main() {
|
||||
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 {
|
||||
TvDetectionService.debugSetAppleTVOverride(true);
|
||||
final controller = TextEditingController();
|
||||
|
||||
Reference in New Issue
Block a user