fix(tvos): unify remote and text input ownership
This commit is contained in:
@@ -11,30 +11,50 @@ import 'dpad_navigator.dart';
|
||||
import 'key_event_utils.dart';
|
||||
import 'owned_focus_node_binding.dart';
|
||||
|
||||
bool _usesTvKeyboard(bool enableTvKeyboard) => enableTvKeyboard && PlatformDetector.isTV();
|
||||
enum TvTextInputPresentation {
|
||||
/// Use the native platform keyboard for single-line Apple TV input and the
|
||||
/// Flutter overlay on other TVs or for multiline input.
|
||||
automatic,
|
||||
|
||||
/// Always use the platform text input implementation.
|
||||
platform,
|
||||
|
||||
/// Always use Plezy's in-app Flutter keyboard overlay.
|
||||
flutterOverlay,
|
||||
}
|
||||
|
||||
bool _usesTvKeyboard({required TvTextInputPresentation presentation, TextInputType? keyboardType, int? maxLines}) {
|
||||
if (!PlatformDetector.isTV()) return false;
|
||||
return switch (presentation) {
|
||||
TvTextInputPresentation.automatic =>
|
||||
!PlatformDetector.isAppleTV() || _isMultilineTextInput(keyboardType: keyboardType, maxLines: maxLines),
|
||||
TvTextInputPresentation.platform => false,
|
||||
TvTextInputPresentation.flutterOverlay => true,
|
||||
};
|
||||
}
|
||||
|
||||
String? _keyboardHint(InputDecoration? decoration) => decoration?.hintText ?? decoration?.labelText;
|
||||
|
||||
enum TvKeyboardAutoOpenBehavior {
|
||||
/// Open the TV virtual keyboard whenever the field receives focus.
|
||||
enum TvTextInputAutoOpenBehavior {
|
||||
/// Open the selected TV text input presentation whenever the field receives
|
||||
/// focus.
|
||||
onFocus,
|
||||
|
||||
/// Keep initial focus on the field without opening the keyboard, then open
|
||||
/// 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,
|
||||
|
||||
/// Never auto-open the TV virtual keyboard on focus. Explicit tap/select
|
||||
/// still opens it.
|
||||
/// Never auto-open text input on focus. Explicit tap/select still opens it.
|
||||
never,
|
||||
}
|
||||
|
||||
/// Imperative handle to the TV on-screen keyboard of a [FocusableTextField] /
|
||||
/// Imperative handle to TV text input for a [FocusableTextField] /
|
||||
/// [FocusableTextFormField]. Pass the same instance to the field's
|
||||
/// `tvKeyboardController`; the field's host attaches itself on mount.
|
||||
/// `tvTextInputController`; the field's host attaches itself on mount.
|
||||
///
|
||||
/// Only meaningful on TV. On other platforms the OSK never exists, so every
|
||||
/// method is an effective no-op.
|
||||
class TvKeyboardController {
|
||||
/// Only meaningful on TV. On other platforms every method is an effective
|
||||
/// no-op.
|
||||
class TvTextInputController {
|
||||
_FocusableTextInputHostState? _host;
|
||||
|
||||
void _attach(_FocusableTextInputHostState host) => _host = host;
|
||||
@@ -42,14 +62,13 @@ class TvKeyboardController {
|
||||
if (identical(_host, host)) _host = null;
|
||||
}
|
||||
|
||||
/// Dismiss the OSK if it is open (and prevent it from auto-reopening while
|
||||
/// the field keeps focus). No-op when nothing is open.
|
||||
void closeKeyboard() => _host?._dismissTvKeyboard();
|
||||
/// Dismiss the Flutter overlay if it is open and prevent it from reopening
|
||||
/// while the field keeps focus.
|
||||
void closeTextInput() => _host?._dismissTvKeyboard();
|
||||
|
||||
/// Focus the field but do NOT open the OSK for this focus entry. Used as a
|
||||
/// fallback landing spot (e.g. a remote search that returned no results) so
|
||||
/// the remote isn't stranded on an off-screen element.
|
||||
void focusInputWithoutKeyboard() => _host?._focusWithoutKeyboard();
|
||||
/// Focus the field without opening either native or Flutter text input for
|
||||
/// this focus entry.
|
||||
void focusInputWithoutOpening() => _host?._focusWithoutKeyboard();
|
||||
}
|
||||
|
||||
String _describeTextInputKey(KeyEvent event) {
|
||||
@@ -117,6 +136,8 @@ KeyEventResult _handleInputKey({
|
||||
required bool usesTvKeyboard,
|
||||
required bool enabled,
|
||||
required VoidCallback openKeyboard,
|
||||
required bool activateNativeTextInput,
|
||||
required VoidCallback activateNativeTextInputCallback,
|
||||
required KeyEvent event,
|
||||
TextInputType? keyboardType,
|
||||
TextInputAction? textInputAction,
|
||||
@@ -154,7 +175,17 @@ KeyEventResult _handleInputKey({
|
||||
);
|
||||
}
|
||||
|
||||
if (_shouldPassNativeTvKeyToPlatform(usesTvKeyboard: usesTvKeyboard, enabled: enabled, event: event)) {
|
||||
if (activateNativeTextInput && enabled && event.isTvSelectEvent) {
|
||||
if (event is KeyDownEvent) activateNativeTextInputCallback();
|
||||
return finish(KeyEventResult.handled, 'activate-native-tv-text-input');
|
||||
}
|
||||
|
||||
if (_shouldPassNativeTvKeyToPlatform(
|
||||
usesTvKeyboard: usesTvKeyboard,
|
||||
nativeTextInputActive: !activateNativeTextInput,
|
||||
enabled: enabled,
|
||||
event: event,
|
||||
)) {
|
||||
return finish(KeyEventResult.skipRemainingHandlers, 'pass-native-tv-key-to-platform');
|
||||
}
|
||||
|
||||
@@ -247,11 +278,17 @@ KeyEventResult _handleInputKey({
|
||||
return finish(KeyEventResult.ignored, 'fall-through');
|
||||
}
|
||||
|
||||
bool _shouldPassNativeTvKeyToPlatform({required bool usesTvKeyboard, required bool enabled, required KeyEvent event}) {
|
||||
if (!enabled || usesTvKeyboard || !PlatformDetector.isTV()) {
|
||||
bool _shouldPassNativeTvKeyToPlatform({
|
||||
required bool usesTvKeyboard,
|
||||
required bool nativeTextInputActive,
|
||||
required bool enabled,
|
||||
required KeyEvent event,
|
||||
}) {
|
||||
if (!enabled || usesTvKeyboard || !nativeTextInputActive || !PlatformDetector.isTV()) {
|
||||
if (TextInputDiagnostics.enabled) {
|
||||
_logTvTextInput(
|
||||
'native-pass=false reason=disabled-or-custom-keyboard enabled=$enabled usesTvKeyboard=$usesTvKeyboard '
|
||||
'native-pass=false reason=inactive-disabled-or-custom enabled=$enabled '
|
||||
'usesTvKeyboard=$usesTvKeyboard nativeTextInputActive=$nativeTextInputActive '
|
||||
'isTv=${PlatformDetector.isTV()} key=(${_describeTextInputKey(event)})',
|
||||
);
|
||||
}
|
||||
@@ -546,9 +583,9 @@ abstract class _FocusableTextInputBase extends StatelessWidget {
|
||||
final VoidCallback? onBack;
|
||||
final bool autofocus;
|
||||
final bool enabled;
|
||||
final bool enableTvKeyboard;
|
||||
final TvKeyboardAutoOpenBehavior tvKeyboardAutoOpenBehavior;
|
||||
final TvKeyboardController? tvKeyboardController;
|
||||
final TvTextInputPresentation tvTextInputPresentation;
|
||||
final TvTextInputAutoOpenBehavior tvTextInputAutoOpenBehavior;
|
||||
final TvTextInputController? tvTextInputController;
|
||||
final bool obscureText;
|
||||
final bool autocorrect;
|
||||
final bool enableSuggestions;
|
||||
@@ -580,9 +617,9 @@ abstract class _FocusableTextInputBase extends StatelessWidget {
|
||||
this.onBack,
|
||||
this.autofocus = false,
|
||||
this.enabled = true,
|
||||
this.enableTvKeyboard = true,
|
||||
this.tvKeyboardAutoOpenBehavior = TvKeyboardAutoOpenBehavior.onFocus,
|
||||
this.tvKeyboardController,
|
||||
this.tvTextInputPresentation = TvTextInputPresentation.automatic,
|
||||
this.tvTextInputAutoOpenBehavior = TvTextInputAutoOpenBehavior.onFocus,
|
||||
this.tvTextInputController,
|
||||
this.obscureText = false,
|
||||
this.autocorrect = true,
|
||||
this.enableSuggestions = true,
|
||||
@@ -599,7 +636,8 @@ abstract class _FocusableTextInputBase extends StatelessWidget {
|
||||
this.onNavigateDown,
|
||||
});
|
||||
|
||||
bool get _hasTvKeyboard => _usesTvKeyboard(enableTvKeyboard);
|
||||
bool get _hasTvKeyboard =>
|
||||
_usesTvKeyboard(presentation: tvTextInputPresentation, keyboardType: keyboardType, maxLines: maxLines);
|
||||
bool get _usesNativeTvKeyboard => PlatformDetector.isTV() && !_hasTvKeyboard;
|
||||
|
||||
VoidCallback? get _effectiveOnEditingComplete {
|
||||
@@ -627,23 +665,41 @@ abstract class _FocusableTextInputBase extends StatelessWidget {
|
||||
bool? enableInteractiveSelection,
|
||||
VoidCallback? onTap,
|
||||
})
|
||||
_tvInputConfiguration(bool usesTvKeyboard, VoidCallback openKeyboard) {
|
||||
_tvInputConfiguration({
|
||||
required bool usesTvKeyboard,
|
||||
required bool nativeTextInputReadOnly,
|
||||
required VoidCallback openKeyboard,
|
||||
required VoidCallback activateNativeTextInput,
|
||||
}) {
|
||||
return (
|
||||
keyboardType: usesTvKeyboard ? TextInputType.none : keyboardType,
|
||||
readOnly: usesTvKeyboard,
|
||||
showCursor: usesTvKeyboard ? true : null,
|
||||
readOnly: usesTvKeyboard || nativeTextInputReadOnly,
|
||||
showCursor: usesTvKeyboard || nativeTextInputReadOnly ? true : null,
|
||||
enableInteractiveSelection: usesTvKeyboard ? false : enableInteractiveSelection,
|
||||
onTap: usesTvKeyboard ? openKeyboard : null,
|
||||
onTap: usesTvKeyboard
|
||||
? openKeyboard
|
||||
: nativeTextInputReadOnly
|
||||
? activateNativeTextInput
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
KeyEventResult _handleKey(BuildContext context, FocusNode node, KeyEvent event, VoidCallback openKeyboard) {
|
||||
KeyEventResult _handleKey(
|
||||
BuildContext context,
|
||||
FocusNode node,
|
||||
KeyEvent event,
|
||||
VoidCallback openKeyboard, {
|
||||
required bool activateNativeTextInput,
|
||||
required VoidCallback activateNativeTextInputCallback,
|
||||
}) {
|
||||
return _handleInputKey(
|
||||
controller: controller,
|
||||
node: node,
|
||||
usesTvKeyboard: _hasTvKeyboard,
|
||||
enabled: enabled,
|
||||
openKeyboard: openKeyboard,
|
||||
activateNativeTextInput: activateNativeTextInput,
|
||||
activateNativeTextInputCallback: activateNativeTextInputCallback,
|
||||
event: event,
|
||||
keyboardType: keyboardType,
|
||||
textInputAction: textInputAction,
|
||||
@@ -662,16 +718,23 @@ abstract class _FocusableTextInputBase extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildFocusableInput(
|
||||
Widget Function(bool usesTvKeyboard, FocusNode focusNode, VoidCallback openKeyboard) builder,
|
||||
) {
|
||||
Widget buildFocusableInput(_FocusableTextInputBuilder builder) {
|
||||
return _FocusableTextInputHost(input: this, builder: builder);
|
||||
}
|
||||
}
|
||||
|
||||
typedef _FocusableTextInputBuilder =
|
||||
Widget Function({
|
||||
required bool usesTvKeyboard,
|
||||
required bool nativeTextInputReadOnly,
|
||||
required FocusNode focusNode,
|
||||
required VoidCallback openKeyboard,
|
||||
required VoidCallback activateNativeTextInput,
|
||||
});
|
||||
|
||||
class _FocusableTextInputHost extends StatefulWidget {
|
||||
final _FocusableTextInputBase input;
|
||||
final Widget Function(bool usesTvKeyboard, FocusNode focusNode, VoidCallback openKeyboard) builder;
|
||||
final _FocusableTextInputBuilder builder;
|
||||
|
||||
const _FocusableTextInputHost({required this.input, required this.builder});
|
||||
|
||||
@@ -693,6 +756,9 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
||||
bool _suppressTvKeyboardAutoOpen = false;
|
||||
bool _hasSeenTvKeyboardFocus = false;
|
||||
bool _suppressTvKeyboardForCurrentFocus = false;
|
||||
bool _nativeTextInputActivated = false;
|
||||
bool _hasSeenNativeTextInputFocus = false;
|
||||
bool _suppressNativeTextInputForCurrentFocus = false;
|
||||
|
||||
FocusNode get _effectiveFocusNode => _focusNodeBinding.node;
|
||||
|
||||
@@ -700,15 +766,15 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_focusNodeBinding.bind(externalNode: widget.input.focusNode, debugLabel: 'FocusableTextInput');
|
||||
widget.input.tvKeyboardController?._attach(this);
|
||||
widget.input.tvTextInputController?._attach(this);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(_FocusableTextInputHost oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (!identical(oldWidget.input.tvKeyboardController, widget.input.tvKeyboardController)) {
|
||||
oldWidget.input.tvKeyboardController?._detach(this);
|
||||
widget.input.tvKeyboardController?._attach(this);
|
||||
if (!identical(oldWidget.input.tvTextInputController, widget.input.tvTextInputController)) {
|
||||
oldWidget.input.tvTextInputController?._detach(this);
|
||||
widget.input.tvTextInputController?._attach(this);
|
||||
}
|
||||
if (oldWidget.input.focusNode != widget.input.focusNode) {
|
||||
// An open keyboard dialog intentionally survives rebuilds and focusNode
|
||||
@@ -719,13 +785,24 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
||||
_tvKeyboardOpenScheduled = false;
|
||||
_hasSeenTvKeyboardFocus = false;
|
||||
_suppressTvKeyboardForCurrentFocus = false;
|
||||
_nativeTextInputActivated = false;
|
||||
_hasSeenNativeTextInputFocus = false;
|
||||
_suppressNativeTextInputForCurrentFocus = false;
|
||||
}
|
||||
_handleFocusChanged();
|
||||
if (oldWidget.input.tvTextInputPresentation != widget.input.tvTextInputPresentation ||
|
||||
oldWidget.input.tvTextInputAutoOpenBehavior != widget.input.tvTextInputAutoOpenBehavior) {
|
||||
_nativeTextInputActivated = false;
|
||||
_hasSeenNativeTextInputFocus = false;
|
||||
_suppressNativeTextInputForCurrentFocus = false;
|
||||
}
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _handleFocusChanged();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.input.tvKeyboardController?._detach(this);
|
||||
widget.input.tvTextInputController?._detach(this);
|
||||
_restoreInstalledHandler();
|
||||
// The keyboard is a navigator route — it must not outlive the field that
|
||||
// opened it (e.g. a form section swapped out while the keyboard is up).
|
||||
@@ -739,10 +816,53 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
||||
}
|
||||
|
||||
void _handleFocusChanged() {
|
||||
_syncNativeTextInputActivation();
|
||||
_syncNativeTextInputFocus();
|
||||
_syncTvKeyboardAutoOpen();
|
||||
}
|
||||
|
||||
void _syncNativeTextInputActivation() {
|
||||
final input = widget.input;
|
||||
final focused = _installedFocusNode?.hasFocus == true && input.enabled && input._usesNativeTvKeyboard;
|
||||
if (!focused) {
|
||||
_suppressNativeTextInputForCurrentFocus = false;
|
||||
_setNativeTextInputActivated(false);
|
||||
return;
|
||||
}
|
||||
if (_suppressNativeTextInputForCurrentFocus) return;
|
||||
|
||||
switch (input.tvTextInputAutoOpenBehavior) {
|
||||
case TvTextInputAutoOpenBehavior.onFocus:
|
||||
_hasSeenNativeTextInputFocus = true;
|
||||
_setNativeTextInputActivated(true);
|
||||
case TvTextInputAutoOpenBehavior.afterFirstFocus:
|
||||
if (!_hasSeenNativeTextInputFocus) {
|
||||
_hasSeenNativeTextInputFocus = true;
|
||||
_suppressNativeTextInputForCurrentFocus = true;
|
||||
return;
|
||||
}
|
||||
_setNativeTextInputActivated(true);
|
||||
case TvTextInputAutoOpenBehavior.never:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void _setNativeTextInputActivated(bool activated) {
|
||||
if (_nativeTextInputActivated == activated) return;
|
||||
if (!mounted) {
|
||||
_nativeTextInputActivated = activated;
|
||||
return;
|
||||
}
|
||||
setState(() => _nativeTextInputActivated = activated);
|
||||
}
|
||||
|
||||
void _activateNativeTextInput() {
|
||||
if (!widget.input.enabled || !widget.input._usesNativeTvKeyboard) return;
|
||||
_hasSeenNativeTextInputFocus = true;
|
||||
_suppressNativeTextInputForCurrentFocus = false;
|
||||
_setNativeTextInputActivated(true);
|
||||
}
|
||||
|
||||
void _syncNativeTextInputFocus() {
|
||||
final focused = _installedFocusNode?.hasFocus == true && widget.input.enabled && widget.input._usesNativeTvKeyboard;
|
||||
if (TextInputDiagnostics.enabled) {
|
||||
@@ -761,7 +881,7 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
||||
if (TextInputDiagnostics.enabled) {
|
||||
_logTvTextInput(
|
||||
'Host.syncTvKeyboardAutoOpen focused=$focused open=$_tvKeyboardOpen scheduled=$_tvKeyboardOpenScheduled '
|
||||
'suppressed=$_suppressTvKeyboardAutoOpen behavior=${widget.input.tvKeyboardAutoOpenBehavior} '
|
||||
'suppressed=$_suppressTvKeyboardAutoOpen behavior=${widget.input.tvTextInputAutoOpenBehavior} '
|
||||
'seenFocus=$_hasSeenTvKeyboardFocus suppressCurrent=$_suppressTvKeyboardForCurrentFocus '
|
||||
'installed=${_installedFocusNode?.debugLabel} '
|
||||
'hasFocus=${_installedFocusNode?.hasFocus} enabled=${widget.input.enabled} '
|
||||
@@ -797,17 +917,17 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
||||
}
|
||||
|
||||
bool _shouldAutoOpenTvKeyboardForCurrentFocus() {
|
||||
switch (widget.input.tvKeyboardAutoOpenBehavior) {
|
||||
case TvKeyboardAutoOpenBehavior.onFocus:
|
||||
switch (widget.input.tvTextInputAutoOpenBehavior) {
|
||||
case TvTextInputAutoOpenBehavior.onFocus:
|
||||
return true;
|
||||
case TvKeyboardAutoOpenBehavior.afterFirstFocus:
|
||||
case TvTextInputAutoOpenBehavior.afterFirstFocus:
|
||||
if (!_hasSeenTvKeyboardFocus) {
|
||||
_hasSeenTvKeyboardFocus = true;
|
||||
_suppressTvKeyboardForCurrentFocus = true;
|
||||
return false;
|
||||
}
|
||||
return !_suppressTvKeyboardForCurrentFocus;
|
||||
case TvKeyboardAutoOpenBehavior.never:
|
||||
case TvTextInputAutoOpenBehavior.never:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -886,19 +1006,22 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
||||
_tvKeyboardHandle?.close();
|
||||
}
|
||||
|
||||
/// Focus the field without opening the OSK for this focus entry. Suppress is
|
||||
/// set BEFORE requestFocus so the resulting focus-change sync already sees it;
|
||||
/// the flag persists for this focus and resets on the next unfocus. If a
|
||||
/// same-turn focus request supersedes this one, clear the unused suppression
|
||||
/// after Flutter resolves its pending focus change.
|
||||
/// Focus the field without opening either native or Flutter text input for
|
||||
/// this focus entry. Suppression is set before requestFocus so the resulting
|
||||
/// focus-change callback cannot open either presentation.
|
||||
void _focusWithoutKeyboard() {
|
||||
_suppressTvKeyboardAutoOpen = true;
|
||||
_suppressNativeTextInputForCurrentFocus = true;
|
||||
_tvKeyboardOpenScheduled = false;
|
||||
if (widget.input._usesNativeTvKeyboard) {
|
||||
_setNativeTextInputActivated(false);
|
||||
}
|
||||
final focusNode = _installedFocusNode ?? _effectiveFocusNode;
|
||||
focusNode.requestFocus();
|
||||
scheduleMicrotask(() {
|
||||
if (!mounted || focusNode.hasFocus || _tvKeyboardOpen || _tvKeyboardOpenScheduled) return;
|
||||
_suppressTvKeyboardAutoOpen = false;
|
||||
_suppressNativeTextInputForCurrentFocus = false;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -921,7 +1044,41 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
||||
);
|
||||
if (result != KeyEventResult.ignored) return result;
|
||||
}
|
||||
return widget.input._handleKey(context, node, event, _openTvKeyboard);
|
||||
var activateNativeTextInput = widget.input._usesNativeTvKeyboard && !_nativeTextInputActivated;
|
||||
final isRemoteNavigation = event.logicalKey.isDpadDirection || event.logicalKey.isBackKey || event.isTvSelectEvent;
|
||||
if (PlatformDetector.isAppleTV() &&
|
||||
widget.input._usesNativeTvKeyboard &&
|
||||
_nativeTextInputActivated &&
|
||||
event is KeyDownEvent &&
|
||||
isRemoteNavigation) {
|
||||
// Remote navigation events are system-owned while the native keyboard
|
||||
// is active. Receiving one here proves that UIKit has dismissed the
|
||||
// keyboard while Flutter focus stayed on the field. Restore the
|
||||
// read-only gate so this press navigates Flutter instead of reopening
|
||||
// the input connection.
|
||||
_suppressNativeTextInputForCurrentFocus = true;
|
||||
_setNativeTextInputActivated(false);
|
||||
activateNativeTextInput = true;
|
||||
if (event.logicalKey.isBackKey) {
|
||||
// This is the Menu press that dismissed UIKit's keyboard. Consume its
|
||||
// Flutter continuation so one press cannot also pop the app route.
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (event.isTvSelectEvent) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _activateNativeTextInput();
|
||||
});
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
}
|
||||
return widget.input._handleKey(
|
||||
context,
|
||||
node,
|
||||
event,
|
||||
_openTvKeyboard,
|
||||
activateNativeTextInput: activateNativeTextInput,
|
||||
activateNativeTextInputCallback: _activateNativeTextInput,
|
||||
);
|
||||
}
|
||||
|
||||
void _installKeyHandler(FocusNode node) {
|
||||
@@ -946,6 +1103,8 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
||||
void _restoreInstalledHandler() {
|
||||
_logTvTextInput('Host.restoreInstalledHandler node=${_installedFocusNode?.debugLabel}');
|
||||
_setNativeTextInputFocused(false);
|
||||
_nativeTextInputActivated = false;
|
||||
_suppressNativeTextInputForCurrentFocus = false;
|
||||
final node = _installedFocusNode;
|
||||
if (node != null) {
|
||||
node.removeListener(_focusListener);
|
||||
@@ -961,8 +1120,19 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
||||
Widget build(BuildContext context) {
|
||||
final focusNode = _effectiveFocusNode;
|
||||
_installKeyHandler(focusNode);
|
||||
_handleFocusChanged();
|
||||
return widget.builder(widget.input._hasTvKeyboard, focusNode, _openTvKeyboard);
|
||||
_syncNativeTextInputFocus();
|
||||
_syncTvKeyboardAutoOpen();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _handleFocusChanged();
|
||||
});
|
||||
final usesTvKeyboard = widget.input._hasTvKeyboard;
|
||||
return widget.builder(
|
||||
usesTvKeyboard: usesTvKeyboard,
|
||||
nativeTextInputReadOnly: widget.input._usesNativeTvKeyboard && !_nativeTextInputActivated,
|
||||
focusNode: focusNode,
|
||||
openKeyboard: _openTvKeyboard,
|
||||
activateNativeTextInput: _activateNativeTextInput,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -989,9 +1159,9 @@ class FocusableTextField extends _FocusableTextInputBase {
|
||||
super.onBack,
|
||||
super.autofocus,
|
||||
super.enabled,
|
||||
super.enableTvKeyboard,
|
||||
super.tvKeyboardAutoOpenBehavior,
|
||||
super.tvKeyboardController,
|
||||
super.tvTextInputPresentation,
|
||||
super.tvTextInputAutoOpenBehavior,
|
||||
super.tvTextInputController,
|
||||
super.obscureText,
|
||||
super.autocorrect,
|
||||
super.enableSuggestions,
|
||||
@@ -1010,11 +1180,22 @@ class FocusableTextField extends _FocusableTextInputBase {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return buildFocusableInput((usesTvKeyboard, effectiveFocusNode, openKeyboard) {
|
||||
final tvInput = _tvInputConfiguration(usesTvKeyboard, openKeyboard);
|
||||
return buildFocusableInput(({
|
||||
required bool usesTvKeyboard,
|
||||
required bool nativeTextInputReadOnly,
|
||||
required FocusNode focusNode,
|
||||
required VoidCallback openKeyboard,
|
||||
required VoidCallback activateNativeTextInput,
|
||||
}) {
|
||||
final tvInput = _tvInputConfiguration(
|
||||
usesTvKeyboard: usesTvKeyboard,
|
||||
nativeTextInputReadOnly: nativeTextInputReadOnly,
|
||||
openKeyboard: openKeyboard,
|
||||
activateNativeTextInput: activateNativeTextInput,
|
||||
);
|
||||
return TextField(
|
||||
controller: controller,
|
||||
focusNode: effectiveFocusNode,
|
||||
focusNode: focusNode,
|
||||
enabled: enabled,
|
||||
decoration: decoration,
|
||||
keyboardType: tvInput.keyboardType,
|
||||
@@ -1066,9 +1247,9 @@ class FocusableTextFormField extends _FocusableTextInputBase {
|
||||
this.onSaved,
|
||||
super.autofocus,
|
||||
super.enabled,
|
||||
super.enableTvKeyboard,
|
||||
super.tvKeyboardAutoOpenBehavior,
|
||||
super.tvKeyboardController,
|
||||
super.tvTextInputPresentation,
|
||||
super.tvTextInputAutoOpenBehavior,
|
||||
super.tvTextInputController,
|
||||
super.obscureText,
|
||||
super.autocorrect,
|
||||
super.enableSuggestions,
|
||||
@@ -1087,11 +1268,22 @@ class FocusableTextFormField extends _FocusableTextInputBase {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return buildFocusableInput((usesTvKeyboard, effectiveFocusNode, openKeyboard) {
|
||||
final tvInput = _tvInputConfiguration(usesTvKeyboard, openKeyboard);
|
||||
return buildFocusableInput(({
|
||||
required bool usesTvKeyboard,
|
||||
required bool nativeTextInputReadOnly,
|
||||
required FocusNode focusNode,
|
||||
required VoidCallback openKeyboard,
|
||||
required VoidCallback activateNativeTextInput,
|
||||
}) {
|
||||
final tvInput = _tvInputConfiguration(
|
||||
usesTvKeyboard: usesTvKeyboard,
|
||||
nativeTextInputReadOnly: nativeTextInputReadOnly,
|
||||
openKeyboard: openKeyboard,
|
||||
activateNativeTextInput: activateNativeTextInput,
|
||||
);
|
||||
return TextFormField(
|
||||
controller: controller,
|
||||
focusNode: effectiveFocusNode,
|
||||
focusNode: focusNode,
|
||||
enabled: enabled,
|
||||
decoration: decoration,
|
||||
keyboardType: tvInput.keyboardType,
|
||||
|
||||
@@ -72,6 +72,7 @@ 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,
|
||||
|
||||
@@ -33,7 +33,7 @@ class SearchScreen extends StatefulWidget {
|
||||
class _SearchScreenState extends State<SearchScreen>
|
||||
with Refreshable, FullRefreshable, SearchInputFocusable, FocusableTab, MountedSetStateMixin, DebouncedMediaSearch {
|
||||
String? _focusResultsForQuery;
|
||||
final _tvKeyboardController = TvKeyboardController();
|
||||
final _tvTextInputController = TvTextInputController();
|
||||
final _clearFocusNode = FocusNode(debugLabel: 'Search.clear');
|
||||
|
||||
@override
|
||||
@@ -130,13 +130,13 @@ class _SearchScreenState extends State<SearchScreen>
|
||||
// Focusing the field normally auto-opens the OSK; a remote search must not
|
||||
// show it, and must dismiss one the TV user already had open (the phone's
|
||||
// Search chip sends tabSearch before the query arrives).
|
||||
_tvKeyboardController.closeKeyboard();
|
||||
_tvTextInputController.closeTextInput();
|
||||
if (trimmed.isEmpty) return;
|
||||
|
||||
// Land focus on the (visible) input immediately so the D-pad remote is
|
||||
// never stranded on the hidden previous tab — while the search is in
|
||||
// flight, when it fails, and when it returns nothing.
|
||||
_tvKeyboardController.focusInputWithoutKeyboard();
|
||||
_tvTextInputController.focusInputWithoutOpening();
|
||||
|
||||
// Same path as the OSK Search key: jumps straight to already-matching
|
||||
// results, or cancels the debounce and runs now; the screen override arms
|
||||
@@ -225,7 +225,8 @@ class _SearchScreenState extends State<SearchScreen>
|
||||
FocusableTextField(
|
||||
controller: searchController,
|
||||
focusNode: searchFocusNode,
|
||||
tvKeyboardController: _tvKeyboardController,
|
||||
tvTextInputController: _tvTextInputController,
|
||||
tvTextInputPresentation: TvTextInputPresentation.flutterOverlay,
|
||||
textInputAction: TextInputAction.search,
|
||||
onNavigateLeft: _navigateToSidebar,
|
||||
onNavigateRight: searchController.text.isNotEmpty ? _clearFocusNode.requestFocus : null,
|
||||
|
||||
@@ -487,8 +487,9 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
|
||||
FocusableTextFormField(
|
||||
controller: _urlController,
|
||||
focusNode: _urlFocus,
|
||||
tvTextInputPresentation: TvTextInputPresentation.flutterOverlay,
|
||||
autofocus: true,
|
||||
tvKeyboardAutoOpenBehavior: TvKeyboardAutoOpenBehavior.afterFirstFocus,
|
||||
tvTextInputAutoOpenBehavior: TvTextInputAutoOpenBehavior.afterFirstFocus,
|
||||
keyboardType: TextInputType.url,
|
||||
minLines: 1,
|
||||
maxLines: 4,
|
||||
|
||||
@@ -99,6 +99,7 @@ class _EditJellyfinConnectionScreenState extends State<EditJellyfinConnectionScr
|
||||
FocusableTextFormField(
|
||||
controller: _urlsController,
|
||||
focusNode: _urlsFocus,
|
||||
tvTextInputPresentation: TvTextInputPresentation.flutterOverlay,
|
||||
autofocus: true,
|
||||
keyboardType: TextInputType.url,
|
||||
minLines: 1,
|
||||
|
||||
@@ -312,6 +312,7 @@ class _MpvConfigScreenState extends State<MpvConfigScreen> with SettingsEffectMi
|
||||
controller: _textController,
|
||||
focusNode: _textFieldFocusNode,
|
||||
keyboardType: TextInputType.multiline,
|
||||
tvTextInputPresentation: TvTextInputPresentation.flutterOverlay,
|
||||
maxLines: null,
|
||||
minLines: 12,
|
||||
decoration: InputDecoration(
|
||||
|
||||
@@ -184,7 +184,7 @@ class _SeerrConnectScreenState extends State<SeerrConnectScreen> with AsyncFormS
|
||||
controller: _urlController,
|
||||
focusNode: _urlFocus,
|
||||
autofocus: true,
|
||||
tvKeyboardAutoOpenBehavior: TvKeyboardAutoOpenBehavior.afterFirstFocus,
|
||||
tvTextInputAutoOpenBehavior: TvTextInputAutoOpenBehavior.afterFirstFocus,
|
||||
keyboardType: TextInputType.url,
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
|
||||
@@ -257,6 +257,9 @@ class _TextInputDialogState extends State<_TextInputDialog> with ControllerDispo
|
||||
controller: _controller,
|
||||
focusNode: _fieldFocusNode,
|
||||
autofocus: true,
|
||||
tvTextInputPresentation: widget.multiline
|
||||
? TvTextInputPresentation.flutterOverlay
|
||||
: TvTextInputPresentation.automatic,
|
||||
decoration: InputDecoration(labelText: widget.labelText, hintText: widget.hintText, errorText: _errorText),
|
||||
keyboardType: widget.keyboardType ?? (widget.multiline ? TextInputType.multiline : null),
|
||||
inputFormatters: widget.inputFormatters,
|
||||
|
||||
@@ -1905,7 +1905,8 @@ class _PickerDialogScaffoldState<T> extends State<_PickerDialogScaffold<T>> {
|
||||
FocusableTextField(
|
||||
controller: _filterController,
|
||||
focusNode: _filterFocusNode,
|
||||
tvKeyboardAutoOpenBehavior: TvKeyboardAutoOpenBehavior.afterFirstFocus,
|
||||
tvTextInputPresentation: TvTextInputPresentation.flutterOverlay,
|
||||
tvTextInputAutoOpenBehavior: TvTextInputAutoOpenBehavior.afterFirstFocus,
|
||||
onNavigateDown: _firstItemFocusNode.requestFocus,
|
||||
decoration: pillInputDecoration(
|
||||
context,
|
||||
|
||||
@@ -284,6 +284,7 @@ class _SubtitleSearchSheetState extends State<SubtitleSearchSheet> with Controll
|
||||
child: FocusableTextField(
|
||||
controller: _titleController,
|
||||
focusNode: _titleFocusNode,
|
||||
tvTextInputPresentation: TvTextInputPresentation.flutterOverlay,
|
||||
decoration: pillInputDecoration(
|
||||
context,
|
||||
hintText: widget.mediaTitle ?? t.metadataEdit.title,
|
||||
@@ -451,6 +452,7 @@ class _LanguagePickerViewState extends State<_LanguagePickerView> with Controlle
|
||||
child: FocusableTextField(
|
||||
controller: _filterController,
|
||||
focusNode: _filterFocusNode,
|
||||
tvTextInputPresentation: TvTextInputPresentation.flutterOverlay,
|
||||
autofocus: true,
|
||||
decoration: pillInputDecoration(
|
||||
context,
|
||||
|
||||
@@ -74,7 +74,11 @@ void main() {
|
||||
body: StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
rebuild = setState;
|
||||
return FocusableTextField(controller: controller, focusNode: node, enableTvKeyboard: false);
|
||||
return FocusableTextField(
|
||||
controller: controller,
|
||||
focusNode: node,
|
||||
tvTextInputPresentation: TvTextInputPresentation.platform,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
@@ -19,8 +19,16 @@ void main() {
|
||||
home: Scaffold(
|
||||
body: Column(
|
||||
children: [
|
||||
FocusableTextField(controller: c1, focusNode: first, enableTvKeyboard: false),
|
||||
FocusableTextField(controller: c2, focusNode: second, enableTvKeyboard: false),
|
||||
FocusableTextField(
|
||||
controller: c1,
|
||||
focusNode: first,
|
||||
tvTextInputPresentation: TvTextInputPresentation.platform,
|
||||
),
|
||||
FocusableTextField(
|
||||
controller: c2,
|
||||
focusNode: second,
|
||||
tvTextInputPresentation: TvTextInputPresentation.platform,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -56,8 +64,17 @@ void main() {
|
||||
home: Scaffold(
|
||||
body: Column(
|
||||
children: [
|
||||
FocusableTextField(controller: c1, focusNode: first, maxLines: 4, enableTvKeyboard: false),
|
||||
FocusableTextField(controller: c2, focusNode: second, enableTvKeyboard: false),
|
||||
FocusableTextField(
|
||||
controller: c1,
|
||||
focusNode: first,
|
||||
maxLines: 4,
|
||||
tvTextInputPresentation: TvTextInputPresentation.platform,
|
||||
),
|
||||
FocusableTextField(
|
||||
controller: c2,
|
||||
focusNode: second,
|
||||
tvTextInputPresentation: TvTextInputPresentation.platform,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -149,7 +149,11 @@ void main() {
|
||||
home: Scaffold(
|
||||
body: TickerMode(
|
||||
enabled: visible,
|
||||
child: FocusableTextField(controller: controller, focusNode: fieldFocusNode),
|
||||
child: FocusableTextField(
|
||||
controller: controller,
|
||||
focusNode: fieldFocusNode,
|
||||
tvTextInputPresentation: TvTextInputPresentation.flutterOverlay,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -181,7 +185,11 @@ void main() {
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: present
|
||||
? FocusableTextField(controller: controller, focusNode: fieldFocusNode)
|
||||
? FocusableTextField(
|
||||
controller: controller,
|
||||
focusNode: fieldFocusNode,
|
||||
tvTextInputPresentation: TvTextInputPresentation.flutterOverlay,
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
@@ -211,7 +219,11 @@ void main() {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: FocusableTextField(controller: controller, focusNode: fieldFocusNode),
|
||||
body: FocusableTextField(
|
||||
controller: controller,
|
||||
focusNode: fieldFocusNode,
|
||||
tvTextInputPresentation: TvTextInputPresentation.flutterOverlay,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -227,6 +239,196 @@ void main() {
|
||||
expect(find.byType(Dialog), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('Apple TV automatic single-line input uses the platform field', (tester) async {
|
||||
TvDetectionService.debugSetAppleTVOverride(true);
|
||||
final controller = TextEditingController();
|
||||
final fieldFocusNode = FocusNode(debugLabel: 'native_name_field');
|
||||
addTearDown(controller.dispose);
|
||||
addTearDown(fieldFocusNode.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: FocusableTextField(controller: controller, focusNode: fieldFocusNode),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
fieldFocusNode.requestFocus();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tester.widget<TextField>(find.byType(TextField)).readOnly, isFalse);
|
||||
expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('Apple TV navigation resumes after native keyboard dismissal', (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();
|
||||
expect(tester.widget<TextField>(find.byType(TextField)).readOnly, isFalse);
|
||||
|
||||
final result = fieldFocusNode.onKeyEvent!(fieldFocusNode, _remoteKey(LogicalKeyboardKey.arrowDown));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(result, KeyEventResult.handled);
|
||||
expect(nextFocusNode.hasPrimaryFocus, isTrue);
|
||||
expect(tester.widget<TextField>(find.byType(TextField)).readOnly, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('Apple TV Menu dismissal does not also invoke app back', (tester) async {
|
||||
TvDetectionService.debugSetAppleTVOverride(true);
|
||||
final controller = TextEditingController();
|
||||
final fieldFocusNode = FocusNode(debugLabel: 'native_url_field');
|
||||
var backCount = 0;
|
||||
addTearDown(controller.dispose);
|
||||
addTearDown(fieldFocusNode.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: FocusableTextField(controller: controller, focusNode: fieldFocusNode, onBack: () => backCount++),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
fieldFocusNode.requestFocus();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final result = fieldFocusNode.onKeyEvent!(fieldFocusNode, _remoteKey(LogicalKeyboardKey.goBack));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(result, KeyEventResult.handled);
|
||||
expect(backCount, 0);
|
||||
expect(fieldFocusNode.hasPrimaryFocus, isTrue);
|
||||
expect(tester.widget<TextField>(find.byType(TextField)).readOnly, isTrue);
|
||||
|
||||
final secondResult = fieldFocusNode.onKeyEvent!(fieldFocusNode, _remoteKey(LogicalKeyboardKey.goBack));
|
||||
await tester.pump();
|
||||
|
||||
expect(secondResult, KeyEventResult.handled);
|
||||
expect(backCount, 1);
|
||||
});
|
||||
|
||||
testWidgets('Apple TV Select reopens native input after dismissal', (tester) async {
|
||||
TvDetectionService.debugSetAppleTVOverride(true);
|
||||
final controller = TextEditingController();
|
||||
final fieldFocusNode = FocusNode(debugLabel: 'native_url_field');
|
||||
addTearDown(controller.dispose);
|
||||
addTearDown(fieldFocusNode.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: FocusableTextField(controller: controller, focusNode: fieldFocusNode),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
fieldFocusNode.requestFocus();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final result = fieldFocusNode.onKeyEvent!(fieldFocusNode, _remoteKey(LogicalKeyboardKey.select));
|
||||
await tester.pump();
|
||||
|
||||
expect(result, KeyEventResult.handled);
|
||||
expect(tester.widget<TextField>(find.byType(TextField)).readOnly, isTrue);
|
||||
|
||||
await tester.pump();
|
||||
expect(tester.widget<TextField>(find.byType(TextField)).readOnly, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('Apple TV after-first-focus waits for explicit Select', (tester) async {
|
||||
TvDetectionService.debugSetAppleTVOverride(true);
|
||||
final controller = TextEditingController();
|
||||
final fieldFocusNode = FocusNode(debugLabel: 'native_url_field');
|
||||
addTearDown(controller.dispose);
|
||||
addTearDown(fieldFocusNode.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: FocusableTextField(
|
||||
controller: controller,
|
||||
focusNode: fieldFocusNode,
|
||||
tvTextInputAutoOpenBehavior: TvTextInputAutoOpenBehavior.afterFirstFocus,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
fieldFocusNode.requestFocus();
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.widget<TextField>(find.byType(TextField)).readOnly, isTrue);
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.select);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tester.widget<TextField>(find.byType(TextField)).readOnly, isFalse);
|
||||
expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('Apple TV after-first-focus auto-opens on refocus', (tester) async {
|
||||
TvDetectionService.debugSetAppleTVOverride(true);
|
||||
final controller = TextEditingController();
|
||||
final fieldFocusNode = FocusNode(debugLabel: 'native_url_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: [
|
||||
FocusableTextField(
|
||||
controller: controller,
|
||||
focusNode: fieldFocusNode,
|
||||
tvTextInputAutoOpenBehavior: TvTextInputAutoOpenBehavior.afterFirstFocus,
|
||||
),
|
||||
Focus(focusNode: otherFocusNode, child: const SizedBox(width: 1, height: 1)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
fieldFocusNode.requestFocus();
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.widget<TextField>(find.byType(TextField)).readOnly, isTrue);
|
||||
|
||||
otherFocusNode.requestFocus();
|
||||
await tester.pumpAndSettle();
|
||||
fieldFocusNode.requestFocus();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tester.widget<TextField>(find.byType(TextField)).readOnly, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('Android TV native keyboard done uses D-pad navigation', (tester) async {
|
||||
TvDetectionService.debugSetAppleTVOverride(null);
|
||||
await TvDetectionService.getInstance(forceTv: true);
|
||||
@@ -246,7 +448,7 @@ void main() {
|
||||
FocusableTextField(
|
||||
controller: controller,
|
||||
focusNode: fieldFocusNode,
|
||||
enableTvKeyboard: false,
|
||||
tvTextInputPresentation: TvTextInputPresentation.platform,
|
||||
textInputAction: TextInputAction.done,
|
||||
onNavigateDown: nextFocusNode.requestFocus,
|
||||
),
|
||||
@@ -311,7 +513,7 @@ void main() {
|
||||
FocusableTextFormField(
|
||||
controller: controller,
|
||||
focusNode: fieldFocusNode,
|
||||
tvKeyboardAutoOpenBehavior: TvKeyboardAutoOpenBehavior.afterFirstFocus,
|
||||
tvTextInputAutoOpenBehavior: TvTextInputAutoOpenBehavior.afterFirstFocus,
|
||||
),
|
||||
Focus(focusNode: otherFocusNode, child: const SizedBox(width: 1, height: 1)),
|
||||
],
|
||||
@@ -353,7 +555,7 @@ void main() {
|
||||
body: FocusableTextFormField(
|
||||
controller: controller,
|
||||
focusNode: fieldFocusNode,
|
||||
tvKeyboardAutoOpenBehavior: TvKeyboardAutoOpenBehavior.afterFirstFocus,
|
||||
tvTextInputAutoOpenBehavior: TvTextInputAutoOpenBehavior.afterFirstFocus,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -392,7 +594,7 @@ void main() {
|
||||
FocusableTextFormField(
|
||||
controller: controller,
|
||||
focusNode: fieldFocusNode,
|
||||
enableTvKeyboard: false,
|
||||
tvTextInputPresentation: TvTextInputPresentation.platform,
|
||||
onNavigateDown: nextFocusNode.requestFocus,
|
||||
onSelect: () => selects++,
|
||||
onBack: () => backs++,
|
||||
@@ -467,7 +669,11 @@ void main() {
|
||||
home: Scaffold(
|
||||
body: Column(
|
||||
children: [
|
||||
FocusableTextFormField(controller: controller, focusNode: fieldFocusNode, enableTvKeyboard: false),
|
||||
FocusableTextFormField(
|
||||
controller: controller,
|
||||
focusNode: fieldFocusNode,
|
||||
tvTextInputPresentation: TvTextInputPresentation.platform,
|
||||
),
|
||||
Focus(focusNode: otherFocusNode, child: const SizedBox.shrink()),
|
||||
],
|
||||
),
|
||||
@@ -557,11 +763,11 @@ void main() {
|
||||
expect(find.byType(Dialog), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('tvOS engine-synthesized select is handled by the virtual keyboard', (tester) async {
|
||||
testWidgets('tvOS synthesized Select opens an explicit Flutter overlay', (tester) async {
|
||||
// The custom Flutter tvOS engine emits Siri Remote center-dpad presses
|
||||
// as `LogicalKeyboardKey.select` with `deviceType=keyboard` (via the
|
||||
// legacy `flutter/keyevent` Android DPAD_CENTER path). On Apple TV this
|
||||
// must open the on-screen keyboard, not submit the form. Previously
|
||||
// legacy `flutter/keyevent` path). Fields that explicitly retain Plezy's
|
||||
// Flutter overlay must open it rather than submit the form.
|
||||
// `isPhysicalKeyboardEnter` matched select+keyboard and routed through
|
||||
// `_submitTextInput`, which silently triggered form submit on every
|
||||
// dpad center press (e.g. immediate validation error on empty fields).
|
||||
@@ -579,6 +785,7 @@ void main() {
|
||||
body: FocusableTextField(
|
||||
controller: controller,
|
||||
focusNode: fieldFocusNode,
|
||||
tvTextInputPresentation: TvTextInputPresentation.flutterOverlay,
|
||||
onSubmitted: (value) => submitted = value,
|
||||
),
|
||||
),
|
||||
@@ -609,6 +816,7 @@ void main() {
|
||||
body: FocusableTextField(
|
||||
controller: controller,
|
||||
focusNode: fieldFocusNode,
|
||||
tvTextInputPresentation: TvTextInputPresentation.flutterOverlay,
|
||||
maxLength: 2,
|
||||
inputFormatters: [FilteringTextInputFormatter.allow(RegExp('[ab]'))],
|
||||
onChanged: changes.add,
|
||||
@@ -660,7 +868,11 @@ void main() {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: FocusableTextField(controller: controller, focusNode: fieldFocusNode),
|
||||
body: FocusableTextField(
|
||||
controller: controller,
|
||||
focusNode: fieldFocusNode,
|
||||
tvTextInputPresentation: TvTextInputPresentation.flutterOverlay,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -689,7 +901,7 @@ void main() {
|
||||
body: FocusableTextField(
|
||||
controller: controller,
|
||||
focusNode: fieldFocusNode,
|
||||
tvKeyboardAutoOpenBehavior: TvKeyboardAutoOpenBehavior.never,
|
||||
tvTextInputAutoOpenBehavior: TvTextInputAutoOpenBehavior.never,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -772,7 +984,7 @@ void main() {
|
||||
body: FocusableTextField(
|
||||
controller: controller,
|
||||
focusNode: fieldFocusNode,
|
||||
tvKeyboardAutoOpenBehavior: TvKeyboardAutoOpenBehavior.never,
|
||||
tvTextInputAutoOpenBehavior: TvTextInputAutoOpenBehavior.never,
|
||||
maxLength: 8,
|
||||
inputFormatters: [
|
||||
TextInputFormatter.withFunction((_, nextValue) {
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
35DB0C8FEF635A3BCA0B722A /* PackageInfoPlusPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9426EFA282CDA8E0E98EEE9 /* PackageInfoPlusPlugin.swift */; };
|
||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
||||
5C71F5F7B33075F2B007825B /* MpvPlayerPluginShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73645904F226A24585A092CE /* MpvPlayerPluginShared.swift */; };
|
||||
4F1000024F1000024F100002 /* FlutterNativeTextInputTests.mm in Sources */ = {isa = PBXBuildFile; fileRef = 4F1000014F1000014F100001 /* FlutterNativeTextInputTests.mm */; };
|
||||
65AC2C222043B3E6723E2076 /* ConnectivityPlusPluginTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 934BC4E316D2AC788C954766 /* ConnectivityPlusPluginTests.swift */; };
|
||||
691577F0EB3F4EB1A5160280 /* MpvPlayerCoreBase.swift in Sources */ = {isa = PBXBuildFile; fileRef = D52A3BDA46E79969EA1DF3AC /* MpvPlayerCoreBase.swift */; };
|
||||
6F3C0DD6F2F8DA14E7E2F386 /* MpvPlayerCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A12B8610AE5D580077264851 /* MpvPlayerCore.swift */; };
|
||||
@@ -96,6 +97,7 @@
|
||||
3CCA7E80F3A99D8759F2E59F /* ConnectivityProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConnectivityProvider.swift; sourceTree = "<group>"; };
|
||||
41369A2B262AECB35079F6CC /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
|
||||
420881FB6A648A2AFD39FFF2 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
|
||||
4F1000014F1000014F100001 /* FlutterNativeTextInputTests.mm */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.cpp.objcpp; path = FlutterNativeTextInputTests.mm; sourceTree = "<group>"; };
|
||||
5E1F41676FEF1AB57076F8B9 /* DeviceInfoPlusPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DeviceInfoPlusPlugin.swift; sourceTree = "<group>"; };
|
||||
66F56950138FF220CA079EB3 /* TvosEventDeliveryCoordinator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TvosEventDeliveryCoordinator.swift; sourceTree = "<group>"; };
|
||||
67C3193A50DFDECE2B92D075 /* ConnectivityPlusPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConnectivityPlusPlugin.swift; sourceTree = "<group>"; };
|
||||
@@ -337,6 +339,7 @@
|
||||
F9BDF6E09E3B9347861D2A50 /* RunnerTests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
4F1000014F1000014F100001 /* FlutterNativeTextInputTests.mm */,
|
||||
2A7C0B1D9E5F4A6381027C11 /* MpvPlayerContractTests.swift */,
|
||||
FE6C8125B201A8BC3261AE2B /* TvosEventDeliveryCoordinatorTests.swift */,
|
||||
934BC4E316D2AC788C954766 /* ConnectivityPlusPluginTests.swift */,
|
||||
@@ -590,6 +593,7 @@
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
4F1000024F1000024F100002 /* FlutterNativeTextInputTests.mm in Sources */,
|
||||
2A7C0B1D9E5F4A6381027C12 /* MpvPlayerContractTests.swift in Sources */,
|
||||
D2004D7BB4A40340AB7A01E0 /* TvosEventDeliveryCoordinatorTests.swift in Sources */,
|
||||
65AC2C222043B3E6723E2076 /* ConnectivityPlusPluginTests.swift in Sources */,
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
#import <Flutter/Flutter.h>
|
||||
#import <UIKit/UIKit.h>
|
||||
#import <XCTest/XCTest.h>
|
||||
|
||||
@interface FlutterTextInputView : UIView
|
||||
@property(nonatomic, readonly) UITextField* tvosTextInputProxy;
|
||||
@end
|
||||
|
||||
@interface FlutterTextInputPlugin : NSObject
|
||||
@property(nonatomic, weak) UIViewController* viewController;
|
||||
- (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result;
|
||||
- (UIView<UITextInput>*)textInputView;
|
||||
@end
|
||||
|
||||
@interface FlutterEngine (NativeTextInputTests)
|
||||
- (FlutterTextInputPlugin*)textInputPlugin;
|
||||
@end
|
||||
|
||||
@interface FlutterViewController (NativeTextInputTests)
|
||||
@property(nonatomic, assign, getter=isTvosNativeTextInputActive) BOOL tvosNativeTextInputActive;
|
||||
@end
|
||||
|
||||
@interface FlutterNativeTextInputTests : XCTestCase
|
||||
@end
|
||||
|
||||
@implementation FlutterNativeTextInputTests {
|
||||
FlutterEngine* _engine;
|
||||
FlutterViewController* _flutterViewController;
|
||||
UIWindow* _window;
|
||||
FlutterTextInputPlugin* _textInputPlugin;
|
||||
}
|
||||
|
||||
- (void)setUp {
|
||||
[super setUp];
|
||||
id<UIApplicationDelegate> appDelegate = UIApplication.sharedApplication.delegate;
|
||||
_window = appDelegate.window;
|
||||
_flutterViewController = (FlutterViewController*)_window.rootViewController;
|
||||
XCTAssertTrue([_flutterViewController isKindOfClass:[FlutterViewController class]]);
|
||||
_engine = _flutterViewController.engine;
|
||||
_textInputPlugin = [_engine textInputPlugin];
|
||||
XCTAssertNotNil(_textInputPlugin);
|
||||
}
|
||||
- (void)tearDown {
|
||||
[self invokeTextInput:@"TextInput.hide" arguments:nil];
|
||||
[self invokeTextInput:@"TextInput.clearClient" arguments:nil];
|
||||
_textInputPlugin = nil;
|
||||
_engine = nil;
|
||||
_flutterViewController = nil;
|
||||
_window = nil;
|
||||
[super tearDown];
|
||||
}
|
||||
|
||||
- (void)testPackagedEngineUsesRealUITextFieldProxyAndNativeSessionRouting {
|
||||
[self invokeTextInput:@"TextInput.setClient"
|
||||
arguments:@[
|
||||
@123,
|
||||
@{
|
||||
@"inputType" : @{@"name" : @"TextInputType.url"},
|
||||
@"keyboardAppearance" : @"Brightness.dark",
|
||||
@"obscureText" : @NO,
|
||||
@"inputAction" : @"TextInputAction.done",
|
||||
@"smartDashesType" : @"0",
|
||||
@"smartQuotesType" : @"0",
|
||||
@"autocorrect" : @NO,
|
||||
@"enableInteractiveSelection" : @YES,
|
||||
},
|
||||
]];
|
||||
[self invokeTextInput:@"TextInput.setEditingState"
|
||||
arguments:@{
|
||||
@"text" : @"plezy.local",
|
||||
@"selectionBase" : @2,
|
||||
@"selectionExtent" : @7,
|
||||
@"selectionAffinity" : @"TextAffinity.downstream",
|
||||
@"composingBase" : @(-1),
|
||||
@"composingExtent" : @(-1),
|
||||
}];
|
||||
|
||||
FlutterTextInputView* inputView = (FlutterTextInputView*)[_textInputPlugin textInputView];
|
||||
UITextField* proxy = inputView.tvosTextInputProxy;
|
||||
XCTAssertNotNil(proxy);
|
||||
XCTAssertEqualObjects(proxy.text, @"plezy.local");
|
||||
XCTAssertEqual(proxy.keyboardType, UIKeyboardTypeURL);
|
||||
XCTAssertEqual(proxy.returnKeyType, UIReturnKeyDone);
|
||||
XCTAssertEqual(proxy.autocorrectionType, UITextAutocorrectionTypeNo);
|
||||
XCTAssertEqual([proxy offsetFromPosition:proxy.beginningOfDocument toPosition:proxy.selectedTextRange.start], 2);
|
||||
XCTAssertEqual([proxy offsetFromPosition:proxy.beginningOfDocument toPosition:proxy.selectedTextRange.end], 7);
|
||||
|
||||
[self invokeTextInput:@"TextInput.show" arguments:nil];
|
||||
[self waitForMainRunLoop];
|
||||
XCTAssertTrue(proxy.isFirstResponder);
|
||||
XCTAssertTrue(_flutterViewController.tvosNativeTextInputActive);
|
||||
|
||||
[self invokeTextInput:@"TextInput.hide" arguments:nil];
|
||||
[self waitForMainRunLoop];
|
||||
XCTAssertFalse(proxy.isFirstResponder);
|
||||
XCTAssertFalse(_flutterViewController.tvosNativeTextInputActive);
|
||||
}
|
||||
|
||||
- (void)invokeTextInput:(NSString*)method arguments:(id)arguments {
|
||||
FlutterMethodCall* call = [FlutterMethodCall methodCallWithMethodName:method arguments:arguments];
|
||||
[_textInputPlugin handleMethodCall:call
|
||||
result:^(id result){
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)waitForMainRunLoop {
|
||||
XCTestExpectation* expectation = [self expectationWithDescription:@"main run loop"];
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
[expectation fulfill];
|
||||
});
|
||||
[self waitForExpectations:@[ expectation ] timeout:2];
|
||||
}
|
||||
|
||||
@end
|
||||
+1
-1
@@ -1 +1 @@
|
||||
278584b13cf4def805f9ceaa97ee4e318d5e5f75c9222286295135e5691d23ef
|
||||
21dcdb1b8e369e8385cd41b7844e7248923680a22f4ea303a976494724a0829d
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
3.44.0+4
|
||||
3.44.0+5
|
||||
|
||||
Reference in New Issue
Block a user