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 'key_event_utils.dart';
|
||||||
import 'owned_focus_node_binding.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;
|
String? _keyboardHint(InputDecoration? decoration) => decoration?.hintText ?? decoration?.labelText;
|
||||||
|
|
||||||
enum TvKeyboardAutoOpenBehavior {
|
enum TvTextInputAutoOpenBehavior {
|
||||||
/// Open the TV virtual keyboard whenever the field receives focus.
|
/// Open the selected TV text input presentation whenever the field receives
|
||||||
|
/// focus.
|
||||||
onFocus,
|
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.
|
/// automatically on later focus entries. Explicit tap/select still opens it.
|
||||||
afterFirstFocus,
|
afterFirstFocus,
|
||||||
|
|
||||||
/// Never auto-open the TV virtual keyboard on focus. Explicit tap/select
|
/// Never auto-open text input on focus. Explicit tap/select still opens it.
|
||||||
/// still opens it.
|
|
||||||
never,
|
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
|
/// [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
|
/// Only meaningful on TV. On other platforms every method is an effective
|
||||||
/// method is an effective no-op.
|
/// no-op.
|
||||||
class TvKeyboardController {
|
class TvTextInputController {
|
||||||
_FocusableTextInputHostState? _host;
|
_FocusableTextInputHostState? _host;
|
||||||
|
|
||||||
void _attach(_FocusableTextInputHostState host) => _host = host;
|
void _attach(_FocusableTextInputHostState host) => _host = host;
|
||||||
@@ -42,14 +62,13 @@ class TvKeyboardController {
|
|||||||
if (identical(_host, host)) _host = null;
|
if (identical(_host, host)) _host = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Dismiss the OSK if it is open (and prevent it from auto-reopening while
|
/// Dismiss the Flutter overlay if it is open and prevent it from reopening
|
||||||
/// the field keeps focus). No-op when nothing is open.
|
/// while the field keeps focus.
|
||||||
void closeKeyboard() => _host?._dismissTvKeyboard();
|
void closeTextInput() => _host?._dismissTvKeyboard();
|
||||||
|
|
||||||
/// Focus the field but do NOT open the OSK for this focus entry. Used as a
|
/// Focus the field without opening either native or Flutter text input for
|
||||||
/// fallback landing spot (e.g. a remote search that returned no results) so
|
/// this focus entry.
|
||||||
/// the remote isn't stranded on an off-screen element.
|
void focusInputWithoutOpening() => _host?._focusWithoutKeyboard();
|
||||||
void focusInputWithoutKeyboard() => _host?._focusWithoutKeyboard();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
String _describeTextInputKey(KeyEvent event) {
|
String _describeTextInputKey(KeyEvent event) {
|
||||||
@@ -117,6 +136,8 @@ KeyEventResult _handleInputKey({
|
|||||||
required bool usesTvKeyboard,
|
required bool usesTvKeyboard,
|
||||||
required bool enabled,
|
required bool enabled,
|
||||||
required VoidCallback openKeyboard,
|
required VoidCallback openKeyboard,
|
||||||
|
required bool activateNativeTextInput,
|
||||||
|
required VoidCallback activateNativeTextInputCallback,
|
||||||
required KeyEvent event,
|
required KeyEvent event,
|
||||||
TextInputType? keyboardType,
|
TextInputType? keyboardType,
|
||||||
TextInputAction? textInputAction,
|
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');
|
return finish(KeyEventResult.skipRemainingHandlers, 'pass-native-tv-key-to-platform');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,11 +278,17 @@ KeyEventResult _handleInputKey({
|
|||||||
return finish(KeyEventResult.ignored, 'fall-through');
|
return finish(KeyEventResult.ignored, 'fall-through');
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _shouldPassNativeTvKeyToPlatform({required bool usesTvKeyboard, required bool enabled, required KeyEvent event}) {
|
bool _shouldPassNativeTvKeyToPlatform({
|
||||||
if (!enabled || usesTvKeyboard || !PlatformDetector.isTV()) {
|
required bool usesTvKeyboard,
|
||||||
|
required bool nativeTextInputActive,
|
||||||
|
required bool enabled,
|
||||||
|
required KeyEvent event,
|
||||||
|
}) {
|
||||||
|
if (!enabled || usesTvKeyboard || !nativeTextInputActive || !PlatformDetector.isTV()) {
|
||||||
if (TextInputDiagnostics.enabled) {
|
if (TextInputDiagnostics.enabled) {
|
||||||
_logTvTextInput(
|
_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)})',
|
'isTv=${PlatformDetector.isTV()} key=(${_describeTextInputKey(event)})',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -546,9 +583,9 @@ abstract class _FocusableTextInputBase extends StatelessWidget {
|
|||||||
final VoidCallback? onBack;
|
final VoidCallback? onBack;
|
||||||
final bool autofocus;
|
final bool autofocus;
|
||||||
final bool enabled;
|
final bool enabled;
|
||||||
final bool enableTvKeyboard;
|
final TvTextInputPresentation tvTextInputPresentation;
|
||||||
final TvKeyboardAutoOpenBehavior tvKeyboardAutoOpenBehavior;
|
final TvTextInputAutoOpenBehavior tvTextInputAutoOpenBehavior;
|
||||||
final TvKeyboardController? tvKeyboardController;
|
final TvTextInputController? tvTextInputController;
|
||||||
final bool obscureText;
|
final bool obscureText;
|
||||||
final bool autocorrect;
|
final bool autocorrect;
|
||||||
final bool enableSuggestions;
|
final bool enableSuggestions;
|
||||||
@@ -580,9 +617,9 @@ abstract class _FocusableTextInputBase extends StatelessWidget {
|
|||||||
this.onBack,
|
this.onBack,
|
||||||
this.autofocus = false,
|
this.autofocus = false,
|
||||||
this.enabled = true,
|
this.enabled = true,
|
||||||
this.enableTvKeyboard = true,
|
this.tvTextInputPresentation = TvTextInputPresentation.automatic,
|
||||||
this.tvKeyboardAutoOpenBehavior = TvKeyboardAutoOpenBehavior.onFocus,
|
this.tvTextInputAutoOpenBehavior = TvTextInputAutoOpenBehavior.onFocus,
|
||||||
this.tvKeyboardController,
|
this.tvTextInputController,
|
||||||
this.obscureText = false,
|
this.obscureText = false,
|
||||||
this.autocorrect = true,
|
this.autocorrect = true,
|
||||||
this.enableSuggestions = true,
|
this.enableSuggestions = true,
|
||||||
@@ -599,7 +636,8 @@ abstract class _FocusableTextInputBase extends StatelessWidget {
|
|||||||
this.onNavigateDown,
|
this.onNavigateDown,
|
||||||
});
|
});
|
||||||
|
|
||||||
bool get _hasTvKeyboard => _usesTvKeyboard(enableTvKeyboard);
|
bool get _hasTvKeyboard =>
|
||||||
|
_usesTvKeyboard(presentation: tvTextInputPresentation, keyboardType: keyboardType, maxLines: maxLines);
|
||||||
bool get _usesNativeTvKeyboard => PlatformDetector.isTV() && !_hasTvKeyboard;
|
bool get _usesNativeTvKeyboard => PlatformDetector.isTV() && !_hasTvKeyboard;
|
||||||
|
|
||||||
VoidCallback? get _effectiveOnEditingComplete {
|
VoidCallback? get _effectiveOnEditingComplete {
|
||||||
@@ -627,23 +665,41 @@ abstract class _FocusableTextInputBase extends StatelessWidget {
|
|||||||
bool? enableInteractiveSelection,
|
bool? enableInteractiveSelection,
|
||||||
VoidCallback? onTap,
|
VoidCallback? onTap,
|
||||||
})
|
})
|
||||||
_tvInputConfiguration(bool usesTvKeyboard, VoidCallback openKeyboard) {
|
_tvInputConfiguration({
|
||||||
|
required bool usesTvKeyboard,
|
||||||
|
required bool nativeTextInputReadOnly,
|
||||||
|
required VoidCallback openKeyboard,
|
||||||
|
required VoidCallback activateNativeTextInput,
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
keyboardType: usesTvKeyboard ? TextInputType.none : keyboardType,
|
keyboardType: usesTvKeyboard ? TextInputType.none : keyboardType,
|
||||||
readOnly: usesTvKeyboard,
|
readOnly: usesTvKeyboard || nativeTextInputReadOnly,
|
||||||
showCursor: usesTvKeyboard ? true : null,
|
showCursor: usesTvKeyboard || nativeTextInputReadOnly ? true : null,
|
||||||
enableInteractiveSelection: usesTvKeyboard ? false : enableInteractiveSelection,
|
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(
|
return _handleInputKey(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
node: node,
|
node: node,
|
||||||
usesTvKeyboard: _hasTvKeyboard,
|
usesTvKeyboard: _hasTvKeyboard,
|
||||||
enabled: enabled,
|
enabled: enabled,
|
||||||
openKeyboard: openKeyboard,
|
openKeyboard: openKeyboard,
|
||||||
|
activateNativeTextInput: activateNativeTextInput,
|
||||||
|
activateNativeTextInputCallback: activateNativeTextInputCallback,
|
||||||
event: event,
|
event: event,
|
||||||
keyboardType: keyboardType,
|
keyboardType: keyboardType,
|
||||||
textInputAction: textInputAction,
|
textInputAction: textInputAction,
|
||||||
@@ -662,16 +718,23 @@ abstract class _FocusableTextInputBase extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget buildFocusableInput(
|
Widget buildFocusableInput(_FocusableTextInputBuilder builder) {
|
||||||
Widget Function(bool usesTvKeyboard, FocusNode focusNode, VoidCallback openKeyboard) builder,
|
|
||||||
) {
|
|
||||||
return _FocusableTextInputHost(input: this, builder: 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 {
|
class _FocusableTextInputHost extends StatefulWidget {
|
||||||
final _FocusableTextInputBase input;
|
final _FocusableTextInputBase input;
|
||||||
final Widget Function(bool usesTvKeyboard, FocusNode focusNode, VoidCallback openKeyboard) builder;
|
final _FocusableTextInputBuilder builder;
|
||||||
|
|
||||||
const _FocusableTextInputHost({required this.input, required this.builder});
|
const _FocusableTextInputHost({required this.input, required this.builder});
|
||||||
|
|
||||||
@@ -693,6 +756,9 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
|||||||
bool _suppressTvKeyboardAutoOpen = false;
|
bool _suppressTvKeyboardAutoOpen = false;
|
||||||
bool _hasSeenTvKeyboardFocus = false;
|
bool _hasSeenTvKeyboardFocus = false;
|
||||||
bool _suppressTvKeyboardForCurrentFocus = false;
|
bool _suppressTvKeyboardForCurrentFocus = false;
|
||||||
|
bool _nativeTextInputActivated = false;
|
||||||
|
bool _hasSeenNativeTextInputFocus = false;
|
||||||
|
bool _suppressNativeTextInputForCurrentFocus = false;
|
||||||
|
|
||||||
FocusNode get _effectiveFocusNode => _focusNodeBinding.node;
|
FocusNode get _effectiveFocusNode => _focusNodeBinding.node;
|
||||||
|
|
||||||
@@ -700,15 +766,15 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_focusNodeBinding.bind(externalNode: widget.input.focusNode, debugLabel: 'FocusableTextInput');
|
_focusNodeBinding.bind(externalNode: widget.input.focusNode, debugLabel: 'FocusableTextInput');
|
||||||
widget.input.tvKeyboardController?._attach(this);
|
widget.input.tvTextInputController?._attach(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didUpdateWidget(_FocusableTextInputHost oldWidget) {
|
void didUpdateWidget(_FocusableTextInputHost oldWidget) {
|
||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
if (!identical(oldWidget.input.tvKeyboardController, widget.input.tvKeyboardController)) {
|
if (!identical(oldWidget.input.tvTextInputController, widget.input.tvTextInputController)) {
|
||||||
oldWidget.input.tvKeyboardController?._detach(this);
|
oldWidget.input.tvTextInputController?._detach(this);
|
||||||
widget.input.tvKeyboardController?._attach(this);
|
widget.input.tvTextInputController?._attach(this);
|
||||||
}
|
}
|
||||||
if (oldWidget.input.focusNode != widget.input.focusNode) {
|
if (oldWidget.input.focusNode != widget.input.focusNode) {
|
||||||
// An open keyboard dialog intentionally survives rebuilds and focusNode
|
// An open keyboard dialog intentionally survives rebuilds and focusNode
|
||||||
@@ -719,13 +785,24 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
|||||||
_tvKeyboardOpenScheduled = false;
|
_tvKeyboardOpenScheduled = false;
|
||||||
_hasSeenTvKeyboardFocus = false;
|
_hasSeenTvKeyboardFocus = false;
|
||||||
_suppressTvKeyboardForCurrentFocus = 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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
widget.input.tvKeyboardController?._detach(this);
|
widget.input.tvTextInputController?._detach(this);
|
||||||
_restoreInstalledHandler();
|
_restoreInstalledHandler();
|
||||||
// The keyboard is a navigator route — it must not outlive the field that
|
// 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).
|
// 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() {
|
void _handleFocusChanged() {
|
||||||
|
_syncNativeTextInputActivation();
|
||||||
_syncNativeTextInputFocus();
|
_syncNativeTextInputFocus();
|
||||||
_syncTvKeyboardAutoOpen();
|
_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() {
|
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) {
|
||||||
@@ -761,7 +881,7 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
|||||||
if (TextInputDiagnostics.enabled) {
|
if (TextInputDiagnostics.enabled) {
|
||||||
_logTvTextInput(
|
_logTvTextInput(
|
||||||
'Host.syncTvKeyboardAutoOpen focused=$focused open=$_tvKeyboardOpen scheduled=$_tvKeyboardOpenScheduled '
|
'Host.syncTvKeyboardAutoOpen focused=$focused open=$_tvKeyboardOpen scheduled=$_tvKeyboardOpenScheduled '
|
||||||
'suppressed=$_suppressTvKeyboardAutoOpen behavior=${widget.input.tvKeyboardAutoOpenBehavior} '
|
'suppressed=$_suppressTvKeyboardAutoOpen behavior=${widget.input.tvTextInputAutoOpenBehavior} '
|
||||||
'seenFocus=$_hasSeenTvKeyboardFocus suppressCurrent=$_suppressTvKeyboardForCurrentFocus '
|
'seenFocus=$_hasSeenTvKeyboardFocus suppressCurrent=$_suppressTvKeyboardForCurrentFocus '
|
||||||
'installed=${_installedFocusNode?.debugLabel} '
|
'installed=${_installedFocusNode?.debugLabel} '
|
||||||
'hasFocus=${_installedFocusNode?.hasFocus} enabled=${widget.input.enabled} '
|
'hasFocus=${_installedFocusNode?.hasFocus} enabled=${widget.input.enabled} '
|
||||||
@@ -797,17 +917,17 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool _shouldAutoOpenTvKeyboardForCurrentFocus() {
|
bool _shouldAutoOpenTvKeyboardForCurrentFocus() {
|
||||||
switch (widget.input.tvKeyboardAutoOpenBehavior) {
|
switch (widget.input.tvTextInputAutoOpenBehavior) {
|
||||||
case TvKeyboardAutoOpenBehavior.onFocus:
|
case TvTextInputAutoOpenBehavior.onFocus:
|
||||||
return true;
|
return true;
|
||||||
case TvKeyboardAutoOpenBehavior.afterFirstFocus:
|
case TvTextInputAutoOpenBehavior.afterFirstFocus:
|
||||||
if (!_hasSeenTvKeyboardFocus) {
|
if (!_hasSeenTvKeyboardFocus) {
|
||||||
_hasSeenTvKeyboardFocus = true;
|
_hasSeenTvKeyboardFocus = true;
|
||||||
_suppressTvKeyboardForCurrentFocus = true;
|
_suppressTvKeyboardForCurrentFocus = true;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return !_suppressTvKeyboardForCurrentFocus;
|
return !_suppressTvKeyboardForCurrentFocus;
|
||||||
case TvKeyboardAutoOpenBehavior.never:
|
case TvTextInputAutoOpenBehavior.never:
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -886,19 +1006,22 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
|||||||
_tvKeyboardHandle?.close();
|
_tvKeyboardHandle?.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Focus the field without opening the OSK for this focus entry. Suppress is
|
/// Focus the field without opening either native or Flutter text input for
|
||||||
/// set BEFORE requestFocus so the resulting focus-change sync already sees it;
|
/// this focus entry. Suppression is set before requestFocus so the resulting
|
||||||
/// the flag persists for this focus and resets on the next unfocus. If a
|
/// focus-change callback cannot open either presentation.
|
||||||
/// same-turn focus request supersedes this one, clear the unused suppression
|
|
||||||
/// after Flutter resolves its pending focus change.
|
|
||||||
void _focusWithoutKeyboard() {
|
void _focusWithoutKeyboard() {
|
||||||
_suppressTvKeyboardAutoOpen = true;
|
_suppressTvKeyboardAutoOpen = true;
|
||||||
|
_suppressNativeTextInputForCurrentFocus = true;
|
||||||
_tvKeyboardOpenScheduled = false;
|
_tvKeyboardOpenScheduled = false;
|
||||||
|
if (widget.input._usesNativeTvKeyboard) {
|
||||||
|
_setNativeTextInputActivated(false);
|
||||||
|
}
|
||||||
final focusNode = _installedFocusNode ?? _effectiveFocusNode;
|
final focusNode = _installedFocusNode ?? _effectiveFocusNode;
|
||||||
focusNode.requestFocus();
|
focusNode.requestFocus();
|
||||||
scheduleMicrotask(() {
|
scheduleMicrotask(() {
|
||||||
if (!mounted || focusNode.hasFocus || _tvKeyboardOpen || _tvKeyboardOpenScheduled) return;
|
if (!mounted || focusNode.hasFocus || _tvKeyboardOpen || _tvKeyboardOpenScheduled) return;
|
||||||
_suppressTvKeyboardAutoOpen = false;
|
_suppressTvKeyboardAutoOpen = false;
|
||||||
|
_suppressNativeTextInputForCurrentFocus = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -921,7 +1044,41 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
|||||||
);
|
);
|
||||||
if (result != KeyEventResult.ignored) return result;
|
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) {
|
void _installKeyHandler(FocusNode node) {
|
||||||
@@ -946,6 +1103,8 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
|||||||
void _restoreInstalledHandler() {
|
void _restoreInstalledHandler() {
|
||||||
_logTvTextInput('Host.restoreInstalledHandler node=${_installedFocusNode?.debugLabel}');
|
_logTvTextInput('Host.restoreInstalledHandler node=${_installedFocusNode?.debugLabel}');
|
||||||
_setNativeTextInputFocused(false);
|
_setNativeTextInputFocused(false);
|
||||||
|
_nativeTextInputActivated = false;
|
||||||
|
_suppressNativeTextInputForCurrentFocus = false;
|
||||||
final node = _installedFocusNode;
|
final node = _installedFocusNode;
|
||||||
if (node != null) {
|
if (node != null) {
|
||||||
node.removeListener(_focusListener);
|
node.removeListener(_focusListener);
|
||||||
@@ -961,8 +1120,19 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final focusNode = _effectiveFocusNode;
|
final focusNode = _effectiveFocusNode;
|
||||||
_installKeyHandler(focusNode);
|
_installKeyHandler(focusNode);
|
||||||
_handleFocusChanged();
|
_syncNativeTextInputFocus();
|
||||||
return widget.builder(widget.input._hasTvKeyboard, focusNode, _openTvKeyboard);
|
_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.onBack,
|
||||||
super.autofocus,
|
super.autofocus,
|
||||||
super.enabled,
|
super.enabled,
|
||||||
super.enableTvKeyboard,
|
super.tvTextInputPresentation,
|
||||||
super.tvKeyboardAutoOpenBehavior,
|
super.tvTextInputAutoOpenBehavior,
|
||||||
super.tvKeyboardController,
|
super.tvTextInputController,
|
||||||
super.obscureText,
|
super.obscureText,
|
||||||
super.autocorrect,
|
super.autocorrect,
|
||||||
super.enableSuggestions,
|
super.enableSuggestions,
|
||||||
@@ -1010,11 +1180,22 @@ class FocusableTextField extends _FocusableTextInputBase {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return buildFocusableInput((usesTvKeyboard, effectiveFocusNode, openKeyboard) {
|
return buildFocusableInput(({
|
||||||
final tvInput = _tvInputConfiguration(usesTvKeyboard, openKeyboard);
|
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(
|
return TextField(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
focusNode: effectiveFocusNode,
|
focusNode: focusNode,
|
||||||
enabled: enabled,
|
enabled: enabled,
|
||||||
decoration: decoration,
|
decoration: decoration,
|
||||||
keyboardType: tvInput.keyboardType,
|
keyboardType: tvInput.keyboardType,
|
||||||
@@ -1066,9 +1247,9 @@ class FocusableTextFormField extends _FocusableTextInputBase {
|
|||||||
this.onSaved,
|
this.onSaved,
|
||||||
super.autofocus,
|
super.autofocus,
|
||||||
super.enabled,
|
super.enabled,
|
||||||
super.enableTvKeyboard,
|
super.tvTextInputPresentation,
|
||||||
super.tvKeyboardAutoOpenBehavior,
|
super.tvTextInputAutoOpenBehavior,
|
||||||
super.tvKeyboardController,
|
super.tvTextInputController,
|
||||||
super.obscureText,
|
super.obscureText,
|
||||||
super.autocorrect,
|
super.autocorrect,
|
||||||
super.enableSuggestions,
|
super.enableSuggestions,
|
||||||
@@ -1087,11 +1268,22 @@ class FocusableTextFormField extends _FocusableTextInputBase {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return buildFocusableInput((usesTvKeyboard, effectiveFocusNode, openKeyboard) {
|
return buildFocusableInput(({
|
||||||
final tvInput = _tvInputConfiguration(usesTvKeyboard, openKeyboard);
|
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(
|
return TextFormField(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
focusNode: effectiveFocusNode,
|
focusNode: focusNode,
|
||||||
enabled: enabled,
|
enabled: enabled,
|
||||||
decoration: decoration,
|
decoration: decoration,
|
||||||
keyboardType: tvInput.keyboardType,
|
keyboardType: tvInput.keyboardType,
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ 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,
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ class SearchScreen extends StatefulWidget {
|
|||||||
class _SearchScreenState extends State<SearchScreen>
|
class _SearchScreenState extends State<SearchScreen>
|
||||||
with Refreshable, FullRefreshable, SearchInputFocusable, FocusableTab, MountedSetStateMixin, DebouncedMediaSearch {
|
with Refreshable, FullRefreshable, SearchInputFocusable, FocusableTab, MountedSetStateMixin, DebouncedMediaSearch {
|
||||||
String? _focusResultsForQuery;
|
String? _focusResultsForQuery;
|
||||||
final _tvKeyboardController = TvKeyboardController();
|
final _tvTextInputController = TvTextInputController();
|
||||||
final _clearFocusNode = FocusNode(debugLabel: 'Search.clear');
|
final _clearFocusNode = FocusNode(debugLabel: 'Search.clear');
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -130,13 +130,13 @@ class _SearchScreenState extends State<SearchScreen>
|
|||||||
// Focusing the field normally auto-opens the OSK; a remote search must not
|
// 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
|
// show it, and must dismiss one the TV user already had open (the phone's
|
||||||
// Search chip sends tabSearch before the query arrives).
|
// Search chip sends tabSearch before the query arrives).
|
||||||
_tvKeyboardController.closeKeyboard();
|
_tvTextInputController.closeTextInput();
|
||||||
if (trimmed.isEmpty) return;
|
if (trimmed.isEmpty) return;
|
||||||
|
|
||||||
// Land focus on the (visible) input immediately so the D-pad remote is
|
// 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
|
// never stranded on the hidden previous tab — while the search is in
|
||||||
// flight, when it fails, and when it returns nothing.
|
// 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
|
// Same path as the OSK Search key: jumps straight to already-matching
|
||||||
// results, or cancels the debounce and runs now; the screen override arms
|
// results, or cancels the debounce and runs now; the screen override arms
|
||||||
@@ -225,7 +225,8 @@ class _SearchScreenState extends State<SearchScreen>
|
|||||||
FocusableTextField(
|
FocusableTextField(
|
||||||
controller: searchController,
|
controller: searchController,
|
||||||
focusNode: searchFocusNode,
|
focusNode: searchFocusNode,
|
||||||
tvKeyboardController: _tvKeyboardController,
|
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,8 +487,9 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
|
|||||||
FocusableTextFormField(
|
FocusableTextFormField(
|
||||||
controller: _urlController,
|
controller: _urlController,
|
||||||
focusNode: _urlFocus,
|
focusNode: _urlFocus,
|
||||||
|
tvTextInputPresentation: TvTextInputPresentation.flutterOverlay,
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
tvKeyboardAutoOpenBehavior: TvKeyboardAutoOpenBehavior.afterFirstFocus,
|
tvTextInputAutoOpenBehavior: TvTextInputAutoOpenBehavior.afterFirstFocus,
|
||||||
keyboardType: TextInputType.url,
|
keyboardType: TextInputType.url,
|
||||||
minLines: 1,
|
minLines: 1,
|
||||||
maxLines: 4,
|
maxLines: 4,
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ class _EditJellyfinConnectionScreenState extends State<EditJellyfinConnectionScr
|
|||||||
FocusableTextFormField(
|
FocusableTextFormField(
|
||||||
controller: _urlsController,
|
controller: _urlsController,
|
||||||
focusNode: _urlsFocus,
|
focusNode: _urlsFocus,
|
||||||
|
tvTextInputPresentation: TvTextInputPresentation.flutterOverlay,
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
keyboardType: TextInputType.url,
|
keyboardType: TextInputType.url,
|
||||||
minLines: 1,
|
minLines: 1,
|
||||||
|
|||||||
@@ -312,6 +312,7 @@ class _MpvConfigScreenState extends State<MpvConfigScreen> with SettingsEffectMi
|
|||||||
controller: _textController,
|
controller: _textController,
|
||||||
focusNode: _textFieldFocusNode,
|
focusNode: _textFieldFocusNode,
|
||||||
keyboardType: TextInputType.multiline,
|
keyboardType: TextInputType.multiline,
|
||||||
|
tvTextInputPresentation: TvTextInputPresentation.flutterOverlay,
|
||||||
maxLines: null,
|
maxLines: null,
|
||||||
minLines: 12,
|
minLines: 12,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
|
|||||||
@@ -184,7 +184,7 @@ class _SeerrConnectScreenState extends State<SeerrConnectScreen> with AsyncFormS
|
|||||||
controller: _urlController,
|
controller: _urlController,
|
||||||
focusNode: _urlFocus,
|
focusNode: _urlFocus,
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
tvKeyboardAutoOpenBehavior: TvKeyboardAutoOpenBehavior.afterFirstFocus,
|
tvTextInputAutoOpenBehavior: TvTextInputAutoOpenBehavior.afterFirstFocus,
|
||||||
keyboardType: TextInputType.url,
|
keyboardType: TextInputType.url,
|
||||||
autocorrect: false,
|
autocorrect: false,
|
||||||
enableSuggestions: false,
|
enableSuggestions: false,
|
||||||
|
|||||||
@@ -257,6 +257,9 @@ class _TextInputDialogState extends State<_TextInputDialog> with ControllerDispo
|
|||||||
controller: _controller,
|
controller: _controller,
|
||||||
focusNode: _fieldFocusNode,
|
focusNode: _fieldFocusNode,
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
|
tvTextInputPresentation: widget.multiline
|
||||||
|
? TvTextInputPresentation.flutterOverlay
|
||||||
|
: TvTextInputPresentation.automatic,
|
||||||
decoration: InputDecoration(labelText: widget.labelText, hintText: widget.hintText, errorText: _errorText),
|
decoration: InputDecoration(labelText: widget.labelText, hintText: widget.hintText, errorText: _errorText),
|
||||||
keyboardType: widget.keyboardType ?? (widget.multiline ? TextInputType.multiline : null),
|
keyboardType: widget.keyboardType ?? (widget.multiline ? TextInputType.multiline : null),
|
||||||
inputFormatters: widget.inputFormatters,
|
inputFormatters: widget.inputFormatters,
|
||||||
|
|||||||
@@ -1905,7 +1905,8 @@ class _PickerDialogScaffoldState<T> extends State<_PickerDialogScaffold<T>> {
|
|||||||
FocusableTextField(
|
FocusableTextField(
|
||||||
controller: _filterController,
|
controller: _filterController,
|
||||||
focusNode: _filterFocusNode,
|
focusNode: _filterFocusNode,
|
||||||
tvKeyboardAutoOpenBehavior: TvKeyboardAutoOpenBehavior.afterFirstFocus,
|
tvTextInputPresentation: TvTextInputPresentation.flutterOverlay,
|
||||||
|
tvTextInputAutoOpenBehavior: TvTextInputAutoOpenBehavior.afterFirstFocus,
|
||||||
onNavigateDown: _firstItemFocusNode.requestFocus,
|
onNavigateDown: _firstItemFocusNode.requestFocus,
|
||||||
decoration: pillInputDecoration(
|
decoration: pillInputDecoration(
|
||||||
context,
|
context,
|
||||||
|
|||||||
@@ -284,6 +284,7 @@ class _SubtitleSearchSheetState extends State<SubtitleSearchSheet> with Controll
|
|||||||
child: FocusableTextField(
|
child: FocusableTextField(
|
||||||
controller: _titleController,
|
controller: _titleController,
|
||||||
focusNode: _titleFocusNode,
|
focusNode: _titleFocusNode,
|
||||||
|
tvTextInputPresentation: TvTextInputPresentation.flutterOverlay,
|
||||||
decoration: pillInputDecoration(
|
decoration: pillInputDecoration(
|
||||||
context,
|
context,
|
||||||
hintText: widget.mediaTitle ?? t.metadataEdit.title,
|
hintText: widget.mediaTitle ?? t.metadataEdit.title,
|
||||||
@@ -451,6 +452,7 @@ class _LanguagePickerViewState extends State<_LanguagePickerView> with Controlle
|
|||||||
child: FocusableTextField(
|
child: FocusableTextField(
|
||||||
controller: _filterController,
|
controller: _filterController,
|
||||||
focusNode: _filterFocusNode,
|
focusNode: _filterFocusNode,
|
||||||
|
tvTextInputPresentation: TvTextInputPresentation.flutterOverlay,
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
decoration: pillInputDecoration(
|
decoration: pillInputDecoration(
|
||||||
context,
|
context,
|
||||||
|
|||||||
@@ -74,7 +74,11 @@ void main() {
|
|||||||
body: StatefulBuilder(
|
body: StatefulBuilder(
|
||||||
builder: (context, setState) {
|
builder: (context, setState) {
|
||||||
rebuild = 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(
|
home: Scaffold(
|
||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
FocusableTextField(controller: c1, focusNode: first, enableTvKeyboard: false),
|
FocusableTextField(
|
||||||
FocusableTextField(controller: c2, focusNode: second, enableTvKeyboard: false),
|
controller: c1,
|
||||||
|
focusNode: first,
|
||||||
|
tvTextInputPresentation: TvTextInputPresentation.platform,
|
||||||
|
),
|
||||||
|
FocusableTextField(
|
||||||
|
controller: c2,
|
||||||
|
focusNode: second,
|
||||||
|
tvTextInputPresentation: TvTextInputPresentation.platform,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -56,8 +64,17 @@ void main() {
|
|||||||
home: Scaffold(
|
home: Scaffold(
|
||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
FocusableTextField(controller: c1, focusNode: first, maxLines: 4, enableTvKeyboard: false),
|
FocusableTextField(
|
||||||
FocusableTextField(controller: c2, focusNode: second, enableTvKeyboard: false),
|
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(
|
home: Scaffold(
|
||||||
body: TickerMode(
|
body: TickerMode(
|
||||||
enabled: visible,
|
enabled: visible,
|
||||||
child: FocusableTextField(controller: controller, focusNode: fieldFocusNode),
|
child: FocusableTextField(
|
||||||
|
controller: controller,
|
||||||
|
focusNode: fieldFocusNode,
|
||||||
|
tvTextInputPresentation: TvTextInputPresentation.flutterOverlay,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -181,7 +185,11 @@ void main() {
|
|||||||
MaterialApp(
|
MaterialApp(
|
||||||
home: Scaffold(
|
home: Scaffold(
|
||||||
body: present
|
body: present
|
||||||
? FocusableTextField(controller: controller, focusNode: fieldFocusNode)
|
? FocusableTextField(
|
||||||
|
controller: controller,
|
||||||
|
focusNode: fieldFocusNode,
|
||||||
|
tvTextInputPresentation: TvTextInputPresentation.flutterOverlay,
|
||||||
|
)
|
||||||
: const SizedBox.shrink(),
|
: const SizedBox.shrink(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -211,7 +219,11 @@ void main() {
|
|||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
MaterialApp(
|
MaterialApp(
|
||||||
home: Scaffold(
|
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);
|
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 {
|
testWidgets('Android TV native keyboard done uses D-pad navigation', (tester) async {
|
||||||
TvDetectionService.debugSetAppleTVOverride(null);
|
TvDetectionService.debugSetAppleTVOverride(null);
|
||||||
await TvDetectionService.getInstance(forceTv: true);
|
await TvDetectionService.getInstance(forceTv: true);
|
||||||
@@ -246,7 +448,7 @@ void main() {
|
|||||||
FocusableTextField(
|
FocusableTextField(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
focusNode: fieldFocusNode,
|
focusNode: fieldFocusNode,
|
||||||
enableTvKeyboard: false,
|
tvTextInputPresentation: TvTextInputPresentation.platform,
|
||||||
textInputAction: TextInputAction.done,
|
textInputAction: TextInputAction.done,
|
||||||
onNavigateDown: nextFocusNode.requestFocus,
|
onNavigateDown: nextFocusNode.requestFocus,
|
||||||
),
|
),
|
||||||
@@ -311,7 +513,7 @@ void main() {
|
|||||||
FocusableTextFormField(
|
FocusableTextFormField(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
focusNode: fieldFocusNode,
|
focusNode: fieldFocusNode,
|
||||||
tvKeyboardAutoOpenBehavior: TvKeyboardAutoOpenBehavior.afterFirstFocus,
|
tvTextInputAutoOpenBehavior: TvTextInputAutoOpenBehavior.afterFirstFocus,
|
||||||
),
|
),
|
||||||
Focus(focusNode: otherFocusNode, child: const SizedBox(width: 1, height: 1)),
|
Focus(focusNode: otherFocusNode, child: const SizedBox(width: 1, height: 1)),
|
||||||
],
|
],
|
||||||
@@ -353,7 +555,7 @@ void main() {
|
|||||||
body: FocusableTextFormField(
|
body: FocusableTextFormField(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
focusNode: fieldFocusNode,
|
focusNode: fieldFocusNode,
|
||||||
tvKeyboardAutoOpenBehavior: TvKeyboardAutoOpenBehavior.afterFirstFocus,
|
tvTextInputAutoOpenBehavior: TvTextInputAutoOpenBehavior.afterFirstFocus,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -392,7 +594,7 @@ void main() {
|
|||||||
FocusableTextFormField(
|
FocusableTextFormField(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
focusNode: fieldFocusNode,
|
focusNode: fieldFocusNode,
|
||||||
enableTvKeyboard: false,
|
tvTextInputPresentation: TvTextInputPresentation.platform,
|
||||||
onNavigateDown: nextFocusNode.requestFocus,
|
onNavigateDown: nextFocusNode.requestFocus,
|
||||||
onSelect: () => selects++,
|
onSelect: () => selects++,
|
||||||
onBack: () => backs++,
|
onBack: () => backs++,
|
||||||
@@ -467,7 +669,11 @@ void main() {
|
|||||||
home: Scaffold(
|
home: Scaffold(
|
||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
FocusableTextFormField(controller: controller, focusNode: fieldFocusNode, enableTvKeyboard: false),
|
FocusableTextFormField(
|
||||||
|
controller: controller,
|
||||||
|
focusNode: fieldFocusNode,
|
||||||
|
tvTextInputPresentation: TvTextInputPresentation.platform,
|
||||||
|
),
|
||||||
Focus(focusNode: otherFocusNode, child: const SizedBox.shrink()),
|
Focus(focusNode: otherFocusNode, child: const SizedBox.shrink()),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -557,11 +763,11 @@ void main() {
|
|||||||
expect(find.byType(Dialog), findsNothing);
|
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
|
// The custom Flutter tvOS engine emits Siri Remote center-dpad presses
|
||||||
// as `LogicalKeyboardKey.select` with `deviceType=keyboard` (via the
|
// as `LogicalKeyboardKey.select` with `deviceType=keyboard` (via the
|
||||||
// legacy `flutter/keyevent` Android DPAD_CENTER path). On Apple TV this
|
// legacy `flutter/keyevent` path). Fields that explicitly retain Plezy's
|
||||||
// must open the on-screen keyboard, not submit the form. Previously
|
// Flutter overlay must open it rather than submit the form.
|
||||||
// `isPhysicalKeyboardEnter` matched select+keyboard and routed through
|
// `isPhysicalKeyboardEnter` matched select+keyboard and routed through
|
||||||
// `_submitTextInput`, which silently triggered form submit on every
|
// `_submitTextInput`, which silently triggered form submit on every
|
||||||
// dpad center press (e.g. immediate validation error on empty fields).
|
// dpad center press (e.g. immediate validation error on empty fields).
|
||||||
@@ -579,6 +785,7 @@ void main() {
|
|||||||
body: FocusableTextField(
|
body: FocusableTextField(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
focusNode: fieldFocusNode,
|
focusNode: fieldFocusNode,
|
||||||
|
tvTextInputPresentation: TvTextInputPresentation.flutterOverlay,
|
||||||
onSubmitted: (value) => submitted = value,
|
onSubmitted: (value) => submitted = value,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -609,6 +816,7 @@ void main() {
|
|||||||
body: FocusableTextField(
|
body: FocusableTextField(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
focusNode: fieldFocusNode,
|
focusNode: fieldFocusNode,
|
||||||
|
tvTextInputPresentation: TvTextInputPresentation.flutterOverlay,
|
||||||
maxLength: 2,
|
maxLength: 2,
|
||||||
inputFormatters: [FilteringTextInputFormatter.allow(RegExp('[ab]'))],
|
inputFormatters: [FilteringTextInputFormatter.allow(RegExp('[ab]'))],
|
||||||
onChanged: changes.add,
|
onChanged: changes.add,
|
||||||
@@ -660,7 +868,11 @@ void main() {
|
|||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
MaterialApp(
|
MaterialApp(
|
||||||
home: Scaffold(
|
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(
|
body: FocusableTextField(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
focusNode: fieldFocusNode,
|
focusNode: fieldFocusNode,
|
||||||
tvKeyboardAutoOpenBehavior: TvKeyboardAutoOpenBehavior.never,
|
tvTextInputAutoOpenBehavior: TvTextInputAutoOpenBehavior.never,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -772,7 +984,7 @@ void main() {
|
|||||||
body: FocusableTextField(
|
body: FocusableTextField(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
focusNode: fieldFocusNode,
|
focusNode: fieldFocusNode,
|
||||||
tvKeyboardAutoOpenBehavior: TvKeyboardAutoOpenBehavior.never,
|
tvTextInputAutoOpenBehavior: TvTextInputAutoOpenBehavior.never,
|
||||||
maxLength: 8,
|
maxLength: 8,
|
||||||
inputFormatters: [
|
inputFormatters: [
|
||||||
TextInputFormatter.withFunction((_, nextValue) {
|
TextInputFormatter.withFunction((_, nextValue) {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
35DB0C8FEF635A3BCA0B722A /* PackageInfoPlusPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9426EFA282CDA8E0E98EEE9 /* PackageInfoPlusPlugin.swift */; };
|
35DB0C8FEF635A3BCA0B722A /* PackageInfoPlusPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9426EFA282CDA8E0E98EEE9 /* PackageInfoPlusPlugin.swift */; };
|
||||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
||||||
5C71F5F7B33075F2B007825B /* MpvPlayerPluginShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73645904F226A24585A092CE /* MpvPlayerPluginShared.swift */; };
|
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 */; };
|
65AC2C222043B3E6723E2076 /* ConnectivityPlusPluginTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 934BC4E316D2AC788C954766 /* ConnectivityPlusPluginTests.swift */; };
|
||||||
691577F0EB3F4EB1A5160280 /* MpvPlayerCoreBase.swift in Sources */ = {isa = PBXBuildFile; fileRef = D52A3BDA46E79969EA1DF3AC /* MpvPlayerCoreBase.swift */; };
|
691577F0EB3F4EB1A5160280 /* MpvPlayerCoreBase.swift in Sources */ = {isa = PBXBuildFile; fileRef = D52A3BDA46E79969EA1DF3AC /* MpvPlayerCoreBase.swift */; };
|
||||||
6F3C0DD6F2F8DA14E7E2F386 /* MpvPlayerCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A12B8610AE5D580077264851 /* MpvPlayerCore.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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
67C3193A50DFDECE2B92D075 /* ConnectivityPlusPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConnectivityPlusPlugin.swift; sourceTree = "<group>"; };
|
||||||
@@ -337,6 +339,7 @@
|
|||||||
F9BDF6E09E3B9347861D2A50 /* RunnerTests */ = {
|
F9BDF6E09E3B9347861D2A50 /* RunnerTests */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
4F1000014F1000014F100001 /* FlutterNativeTextInputTests.mm */,
|
||||||
2A7C0B1D9E5F4A6381027C11 /* MpvPlayerContractTests.swift */,
|
2A7C0B1D9E5F4A6381027C11 /* MpvPlayerContractTests.swift */,
|
||||||
FE6C8125B201A8BC3261AE2B /* TvosEventDeliveryCoordinatorTests.swift */,
|
FE6C8125B201A8BC3261AE2B /* TvosEventDeliveryCoordinatorTests.swift */,
|
||||||
934BC4E316D2AC788C954766 /* ConnectivityPlusPluginTests.swift */,
|
934BC4E316D2AC788C954766 /* ConnectivityPlusPluginTests.swift */,
|
||||||
@@ -590,6 +593,7 @@
|
|||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
4F1000024F1000024F100002 /* FlutterNativeTextInputTests.mm in Sources */,
|
||||||
2A7C0B1D9E5F4A6381027C12 /* MpvPlayerContractTests.swift in Sources */,
|
2A7C0B1D9E5F4A6381027C12 /* MpvPlayerContractTests.swift in Sources */,
|
||||||
D2004D7BB4A40340AB7A01E0 /* TvosEventDeliveryCoordinatorTests.swift in Sources */,
|
D2004D7BB4A40340AB7A01E0 /* TvosEventDeliveryCoordinatorTests.swift in Sources */,
|
||||||
65AC2C222043B3E6723E2076 /* ConnectivityPlusPluginTests.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