fix(tvos): restore native text input navigation
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user