fix(tv): restore the native Android IME for single-line text input
Android TV returns to the platform keyboard for single-line fields; the Flutter overlay stays for multiline and explicit call sites. The bugs that forced the overlay (#1051, #1079) were an engine show/bind ordering race, now repaired at the app level: - MainActivity retries a soft-input show the engine dropped while the FlutterView was not yet served (flutter/flutter#177360), rebinds the IME key session once at first show, and consumes leaked D-pad keys while the keyboard is visible (bounded restartInput budget) so focus cannot wander behind a stuck keyboard. - The platform text-input hint is activation-based, so gamepad pause and the pre-IME D-pad intercept track a live session instead of mere field focus. - While a session is live with the keyboard away, Back closes it and is consumed once, Select re-raises the keyboard, and arrows keep caret-aware edge-escape navigation instead of dead-ending.
This commit is contained in:
@@ -10,13 +10,18 @@ import android.content.res.Configuration
|
|||||||
import android.media.AudioManager
|
import android.media.AudioManager
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
import android.os.Process
|
import android.os.Process
|
||||||
|
import android.os.SystemClock
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.util.Rational
|
import android.util.Rational
|
||||||
import android.view.InputDevice
|
import android.view.InputDevice
|
||||||
import android.view.KeyEvent
|
import android.view.KeyEvent
|
||||||
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import android.view.ViewTreeObserver
|
||||||
import android.view.WindowInsets
|
import android.view.WindowInsets
|
||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
import android.view.inputmethod.InputMethodManager
|
import android.view.inputmethod.InputMethodManager
|
||||||
@@ -46,6 +51,20 @@ class MainActivity : FlutterActivity() {
|
|||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "MainActivity"
|
private const val TAG = "MainActivity"
|
||||||
private const val TEXT_INPUT_DIAGNOSTICS_ENABLED = false
|
private const val TEXT_INPUT_DIAGNOSTICS_ENABLED = false
|
||||||
|
|
||||||
|
// Flutter's TextInputPlugin issues showSoftInput before the FlutterView is
|
||||||
|
// the IMM's served view (the InputConnection restart is deferred to the
|
||||||
|
// next channel message), so on TV the D-pad-driven first open is dropped
|
||||||
|
// with "Ignoring showSoftInput() as view ... is not served" and never
|
||||||
|
// retried (flutter/flutter#177360). These bounded retries re-issue the
|
||||||
|
// show once the view is served; the restart budget repairs the sibling
|
||||||
|
// failure mode where the keyboard shows but its key session never bound
|
||||||
|
// ("Ignoring onBind: cur seq=-1"), leaving Gboard blind to D-pad
|
||||||
|
// (#1051, #1079).
|
||||||
|
private const val IME_SHOW_RETRY_LIMIT = 4
|
||||||
|
private const val IME_SHOW_RETRY_INTERVAL_MS = 300L
|
||||||
|
private const val IME_LEAK_RESTART_BUDGET = 2
|
||||||
|
private const val IME_LEAK_RESTART_MIN_INTERVAL_MS = 1000L
|
||||||
private const val EXIT_DIAGNOSTICS_PREFS = "plezy_exit_diagnostics"
|
private const val EXIT_DIAGNOSTICS_PREFS = "plezy_exit_diagnostics"
|
||||||
private const val LAST_EXIT_DEDUPE_KEY = "last_reported_exit"
|
private const val LAST_EXIT_DEDUPE_KEY = "last_reported_exit"
|
||||||
private const val LAST_STARTUP_PHASE_KEY = "last_startup_phase"
|
private const val LAST_STARTUP_PHASE_KEY = "last_startup_phase"
|
||||||
@@ -80,6 +99,13 @@ class MainActivity : FlutterActivity() {
|
|||||||
private var carRestrictions: CarRestrictionsMonitor? = null
|
private var carRestrictions: CarRestrictionsMonitor? = null
|
||||||
private var carRestrictionsChannel: MethodChannel? = null
|
private var carRestrictionsChannel: MethodChannel? = null
|
||||||
private var nativeTextInputFocused = false
|
private var nativeTextInputFocused = false
|
||||||
|
private val imeRecoveryHandler = Handler(Looper.getMainLooper())
|
||||||
|
private var imeShowAttempts = 0
|
||||||
|
private var imeLeakRestartBudget = 0
|
||||||
|
private var imeRestartedOnShow = false
|
||||||
|
private var imeWasVisible = false
|
||||||
|
private var lastImeLeakRestartUptime = 0L
|
||||||
|
private var imeVisibilityListener: ViewTreeObserver.OnGlobalLayoutListener? = null
|
||||||
private var originalWindowBrightness: Float? = null
|
private var originalWindowBrightness: Float? = null
|
||||||
private var flutterTextureView: FlutterTextureView? = null
|
private var flutterTextureView: FlutterTextureView? = null
|
||||||
private var flutterSurfaceReconnectPending = false
|
private var flutterSurfaceReconnectPending = false
|
||||||
@@ -163,6 +189,82 @@ class MainActivity : FlutterActivity() {
|
|||||||
return forward
|
return forward
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun inputMethodManager(): InputMethodManager = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||||
|
|
||||||
|
private fun flutterView(): View? = findViewById(FLUTTER_VIEW_ID)
|
||||||
|
|
||||||
|
// Re-issues a soft-input show that the engine dropped because the
|
||||||
|
// FlutterView was not yet the IMM's served view when TextInput.show ran
|
||||||
|
// (flutter/flutter#177360). Flutter never retries on its own — its Dart
|
||||||
|
// side believes the keyboard is already up — so without this the first
|
||||||
|
// D-pad-driven open on TV can silently do nothing.
|
||||||
|
private val imeShowRetry = object : Runnable {
|
||||||
|
override fun run() {
|
||||||
|
if (!nativeTextInputFocused) return
|
||||||
|
if (isImeVisible()) return
|
||||||
|
val view = flutterView()
|
||||||
|
val imm = inputMethodManager()
|
||||||
|
if (view != null && imm.isActive(view)) {
|
||||||
|
logTextInputDiag { "imeShowRetry re-showing attempt=$imeShowAttempts ${describeImeState()}" }
|
||||||
|
imm.showSoftInput(view, 0)
|
||||||
|
} else {
|
||||||
|
logTextInputDiag { "imeShowRetry waiting attempt=$imeShowAttempts served=${view != null && imm.isActive(view)}" }
|
||||||
|
}
|
||||||
|
imeShowAttempts++
|
||||||
|
if (imeShowAttempts < IME_SHOW_RETRY_LIMIT) {
|
||||||
|
imeRecoveryHandler.postDelayed(this, IME_SHOW_RETRY_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startNativeTextInputSession() {
|
||||||
|
imeShowAttempts = 0
|
||||||
|
imeLeakRestartBudget = IME_LEAK_RESTART_BUDGET
|
||||||
|
imeRestartedOnShow = false
|
||||||
|
imeRecoveryHandler.removeCallbacks(imeShowRetry)
|
||||||
|
imeRecoveryHandler.postDelayed(imeShowRetry, IME_SHOW_RETRY_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun endNativeTextInputSession() {
|
||||||
|
imeRecoveryHandler.removeCallbacks(imeShowRetry)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun restartNativeTextInput(reason: String) {
|
||||||
|
val view = flutterView() ?: return
|
||||||
|
logTextInputDiag { "restartInput reason=$reason ${describeImeState()}" }
|
||||||
|
inputMethodManager().restartInput(view)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A visible IME owns D-pad navigation: a healthy Gboard consumes these keys
|
||||||
|
// at the ImeInputStage, before the app. One arriving here therefore means
|
||||||
|
// the IME's key session never bound ("Ignoring onBind: cur seq=-1") — the
|
||||||
|
// Chromecast/Google TV failure of #1051/#1079. Repair by rebinding, and eat
|
||||||
|
// the press so Flutter focus cannot wander behind the stuck keyboard. The
|
||||||
|
// bounded budget guarantees keys flow again (and Flutter can close the
|
||||||
|
// session) if rebinding cannot heal the device.
|
||||||
|
private fun consumeLeakedImeNavigationKey(event: KeyEvent): Boolean {
|
||||||
|
if (!nativeTextInputFocused || imeLeakRestartBudget <= 0) return false
|
||||||
|
when (event.keyCode) {
|
||||||
|
KeyEvent.KEYCODE_DPAD_UP,
|
||||||
|
KeyEvent.KEYCODE_DPAD_DOWN,
|
||||||
|
KeyEvent.KEYCODE_DPAD_LEFT,
|
||||||
|
KeyEvent.KEYCODE_DPAD_RIGHT,
|
||||||
|
KeyEvent.KEYCODE_DPAD_CENTER -> Unit
|
||||||
|
else -> return false
|
||||||
|
}
|
||||||
|
if (!isImeVisible()) return false
|
||||||
|
if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) {
|
||||||
|
val now = SystemClock.uptimeMillis()
|
||||||
|
if (now - lastImeLeakRestartUptime >= IME_LEAK_RESTART_MIN_INTERVAL_MS) {
|
||||||
|
lastImeLeakRestartUptime = now
|
||||||
|
imeLeakRestartBudget--
|
||||||
|
restartNativeTextInput("leaked-dpad-while-ime-visible")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logTextInputDiag { "consuming leaked IME key ${describeKeyEvent(event)} budget=$imeLeakRestartBudget" }
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
private fun getAndroidTvDetection(): Map<String, Any> {
|
private fun getAndroidTvDetection(): Map<String, Any> {
|
||||||
val pm = packageManager
|
val pm = packageManager
|
||||||
val uiModeType = resources.configuration.uiMode and Configuration.UI_MODE_TYPE_MASK
|
val uiModeType = resources.configuration.uiMode and Configuration.UI_MODE_TYPE_MASK
|
||||||
@@ -433,6 +535,24 @@ class MainActivity : FlutterActivity() {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Watch IME visibility so a fresh session can be rebound the moment the
|
||||||
|
// keyboard first shows: on Chromecast-class devices the initial bind can
|
||||||
|
// land against a stale sequence, leaving the IME without a key session
|
||||||
|
// (D-pad dead, #1051/#1079). One restartInput at first-show — before the
|
||||||
|
// user has typed or moved the key highlight — repairs it invisibly.
|
||||||
|
val visibilityListener = ViewTreeObserver.OnGlobalLayoutListener {
|
||||||
|
val visible = isImeVisible()
|
||||||
|
if (visible == imeWasVisible) return@OnGlobalLayoutListener
|
||||||
|
imeWasVisible = visible
|
||||||
|
logTextInputDiag { "ime visibility changed visible=$visible ${describeImeState()}" }
|
||||||
|
if (visible && nativeTextInputFocused && !imeRestartedOnShow) {
|
||||||
|
imeRestartedOnShow = true
|
||||||
|
restartNativeTextInput("first-show-rebind")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.decorView.viewTreeObserver.addOnGlobalLayoutListener(visibilityListener)
|
||||||
|
imeVisibilityListener = visibilityListener
|
||||||
|
|
||||||
// Handle Watch Next deep link from initial launch
|
// Handle Watch Next deep link from initial launch
|
||||||
handleWatchNextIntent(intent)
|
handleWatchNextIntent(intent)
|
||||||
}
|
}
|
||||||
@@ -447,6 +567,9 @@ class MainActivity : FlutterActivity() {
|
|||||||
if (isDpadKeyCode(event.keyCode)) {
|
if (isDpadKeyCode(event.keyCode)) {
|
||||||
logTextInputDiag { "activity.dispatchKeyEvent before ${describeKeyEvent(event)} ${describeImeState()}" }
|
logTextInputDiag { "activity.dispatchKeyEvent before ${describeKeyEvent(event)} ${describeImeState()}" }
|
||||||
}
|
}
|
||||||
|
// Reaching the activity means the ImeInputStage already declined this
|
||||||
|
// key, so consumption below cannot starve a healthy IME.
|
||||||
|
if (consumeLeakedImeNavigationKey(event)) return true
|
||||||
val handled = super.dispatchKeyEvent(event)
|
val handled = super.dispatchKeyEvent(event)
|
||||||
if (isDpadKeyCode(event.keyCode)) {
|
if (isDpadKeyCode(event.keyCode)) {
|
||||||
logTextInputDiag {
|
logTextInputDiag {
|
||||||
@@ -464,6 +587,9 @@ class MainActivity : FlutterActivity() {
|
|||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
externalPlayerChannel.dispose()
|
externalPlayerChannel.dispose()
|
||||||
|
endNativeTextInputSession()
|
||||||
|
imeVisibilityListener?.let { window.decorView.viewTreeObserver.removeOnGlobalLayoutListener(it) }
|
||||||
|
imeVisibilityListener = null
|
||||||
carRestrictions?.release()
|
carRestrictions?.release()
|
||||||
carRestrictions = null
|
carRestrictions = null
|
||||||
carRestrictionsChannel = null
|
carRestrictionsChannel = null
|
||||||
@@ -666,6 +792,11 @@ class MainActivity : FlutterActivity() {
|
|||||||
logTextInputDiag {
|
logTextInputDiag {
|
||||||
"methodChannel setNativeTextInputFocused old=$oldValue new=$nativeTextInputFocused ${describeImeState()}"
|
"methodChannel setNativeTextInputFocused old=$oldValue new=$nativeTextInputFocused ${describeImeState()}"
|
||||||
}
|
}
|
||||||
|
if (nativeTextInputFocused && !oldValue) {
|
||||||
|
startNativeTextInputSession()
|
||||||
|
} else if (!nativeTextInputFocused && oldValue) {
|
||||||
|
endNativeTextInputSession()
|
||||||
|
}
|
||||||
result.success(null)
|
result.success(null)
|
||||||
}
|
}
|
||||||
else -> result.notImplemented()
|
else -> result.notImplemented()
|
||||||
|
|||||||
@@ -12,8 +12,9 @@ import 'key_event_utils.dart';
|
|||||||
import 'owned_focus_node_binding.dart';
|
import 'owned_focus_node_binding.dart';
|
||||||
|
|
||||||
enum TvTextInputPresentation {
|
enum TvTextInputPresentation {
|
||||||
/// Use the native platform keyboard for single-line Apple TV input and the
|
/// Use the native platform keyboard for single-line input on every TV and
|
||||||
/// Flutter overlay on other TVs or for multiline input.
|
/// the Flutter overlay for multiline input, whose newline/caret handling
|
||||||
|
/// the TV IMEs do not cover well.
|
||||||
automatic,
|
automatic,
|
||||||
|
|
||||||
/// Always use the platform text input implementation.
|
/// Always use the platform text input implementation.
|
||||||
@@ -26,8 +27,7 @@ enum TvTextInputPresentation {
|
|||||||
bool _usesTvKeyboard({required TvTextInputPresentation presentation, TextInputType? keyboardType, int? maxLines}) {
|
bool _usesTvKeyboard({required TvTextInputPresentation presentation, TextInputType? keyboardType, int? maxLines}) {
|
||||||
if (!PlatformDetector.isTV()) return false;
|
if (!PlatformDetector.isTV()) return false;
|
||||||
return switch (presentation) {
|
return switch (presentation) {
|
||||||
TvTextInputPresentation.automatic =>
|
TvTextInputPresentation.automatic => _isMultilineTextInput(keyboardType: keyboardType, maxLines: maxLines),
|
||||||
!PlatformDetector.isAppleTV() || _isMultilineTextInput(keyboardType: keyboardType, maxLines: maxLines),
|
|
||||||
TvTextInputPresentation.platform => false,
|
TvTextInputPresentation.platform => false,
|
||||||
TvTextInputPresentation.flutterOverlay => true,
|
TvTextInputPresentation.flutterOverlay => true,
|
||||||
};
|
};
|
||||||
@@ -69,8 +69,8 @@ enum TvTextInputAutoOpenBehavior {
|
|||||||
/// This is the one documented exception to the `automatic` rule that a field's
|
/// This is the one documented exception to the `automatic` rule that a field's
|
||||||
/// first focus opens text input — the URL field's first focus is the screen's
|
/// first focus opens text input — the URL field's first focus is the screen's
|
||||||
/// own `autofocus`, not the user arriving. Apple TV therefore waits for an
|
/// own `autofocus`, not the user arriving. Apple TV therefore waits for an
|
||||||
/// explicit Select; the in-app overlay is cheap enough to open on a deliberate
|
/// explicit Select; Android's docked IME is cheap enough to open on a
|
||||||
/// return.
|
/// deliberate return.
|
||||||
TvTextInputAutoOpenBehavior get deferredUrlFieldAutoOpen =>
|
TvTextInputAutoOpenBehavior get deferredUrlFieldAutoOpen =>
|
||||||
PlatformDetector.isAppleTV() ? TvTextInputAutoOpenBehavior.never : TvTextInputAutoOpenBehavior.afterFirstFocus;
|
PlatformDetector.isAppleTV() ? TvTextInputAutoOpenBehavior.never : TvTextInputAutoOpenBehavior.afterFirstFocus;
|
||||||
|
|
||||||
@@ -310,19 +310,23 @@ bool _shouldPassNativeTvKeyToPlatform({
|
|||||||
required bool enabled,
|
required bool enabled,
|
||||||
required KeyEvent event,
|
required KeyEvent event,
|
||||||
}) {
|
}) {
|
||||||
if (!enabled || usesTvKeyboard || !nativeTextInputActive || !PlatformDetector.isTV()) {
|
if (!enabled || usesTvKeyboard || !nativeTextInputActive || !PlatformDetector.isAppleTV()) {
|
||||||
if (TextInputDiagnostics.enabled) {
|
if (TextInputDiagnostics.enabled) {
|
||||||
_logTvTextInput(
|
_logTvTextInput(
|
||||||
'native-pass=false reason=inactive-disabled-or-custom enabled=$enabled '
|
'native-pass=false reason=inactive-disabled-or-custom enabled=$enabled '
|
||||||
'usesTvKeyboard=$usesTvKeyboard nativeTextInputActive=$nativeTextInputActive '
|
'usesTvKeyboard=$usesTvKeyboard nativeTextInputActive=$nativeTextInputActive '
|
||||||
'isTv=${PlatformDetector.isTV()} key=(${_describeTextInputKey(event)})',
|
'isAppleTV=${PlatformDetector.isAppleTV()} key=(${_describeTextInputKey(event)})',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Android TV provides its own IME. Remote keys must reach the platform so
|
// tvOS only: the custom engine routes remote keys through Flutter even
|
||||||
// users can move around that keyboard instead of escaping the app field.
|
// while UIKit text input is live, so they must be skipped back to the
|
||||||
|
// platform to drive the system keyboard. Android needs no such pass — the
|
||||||
|
// IME sees hardware keys *before* the app (ImeInputStage), so a navigation
|
||||||
|
// key arriving here was already declined by the IME and must keep its
|
||||||
|
// local caret/traversal semantics (see the host's Android branch).
|
||||||
// Some remotes (Chromecast) are reported by Flutter as keyboard events, so
|
// Some remotes (Chromecast) are reported by Flutter as keyboard events, so
|
||||||
// native TV navigation cannot rely on deviceType.
|
// native TV navigation cannot rely on deviceType.
|
||||||
final key = event.logicalKey;
|
final key = event.logicalKey;
|
||||||
@@ -864,8 +868,9 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
|||||||
void _restoreFocusAfterPlatformDismissal() {
|
void _restoreFocusAfterPlatformDismissal() {
|
||||||
final node = _installedFocusNode;
|
final node = _installedFocusNode;
|
||||||
if (node == null || node.hasFocus || !_nativeTextInputActivated) return;
|
if (node == null || node.hasFocus || !_nativeTextInputActivated) return;
|
||||||
// Apple TV only: Android TV's IME close keeps its historical semantics,
|
// Apple TV only: connectionClosed-driven unfocus is a behavior of the
|
||||||
// and no production field selects the native path there anyway.
|
// custom tvOS engine. Android's IME hide keeps the field focused and the
|
||||||
|
// connection alive, so there is nothing to restore there.
|
||||||
if (!PlatformDetector.isAppleTV()) return;
|
if (!PlatformDetector.isAppleTV()) return;
|
||||||
if (!widget.input.enabled || !widget.input._usesNativeTvKeyboard) return;
|
if (!widget.input.enabled || !widget.input._usesNativeTvKeyboard) return;
|
||||||
final scope = node.enclosingScope;
|
final scope = node.enclosingScope;
|
||||||
@@ -978,12 +983,22 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _syncNativeTextInputFocus() {
|
void _syncNativeTextInputFocus() {
|
||||||
final focused = _installedFocusNode?.hasFocus == true && widget.input.enabled && widget.input._usesNativeTvKeyboard;
|
// Activation-based, not focus-based: the platform hint pauses the gamepad
|
||||||
|
// bridge and defers the pre-IME D-pad intercept to the IME, and it arms
|
||||||
|
// MainActivity's soft-input show-retry/repair session — all of which must
|
||||||
|
// track a *live* text input session, not a merely focused (read-only
|
||||||
|
// gated) field. A dismissed keyboard therefore hands D-pad routing back
|
||||||
|
// to the app immediately.
|
||||||
|
final focused =
|
||||||
|
_installedFocusNode?.hasFocus == true &&
|
||||||
|
widget.input.enabled &&
|
||||||
|
widget.input._usesNativeTvKeyboard &&
|
||||||
|
_nativeTextInputActivated;
|
||||||
if (TextInputDiagnostics.enabled) {
|
if (TextInputDiagnostics.enabled) {
|
||||||
_logTvTextInput(
|
_logTvTextInput(
|
||||||
'Host.syncNativeTextInputFocus focused=$focused installed=${_installedFocusNode?.debugLabel} '
|
'Host.syncNativeTextInputFocus focused=$focused installed=${_installedFocusNode?.debugLabel} '
|
||||||
'hasFocus=${_installedFocusNode?.hasFocus} enabled=${widget.input.enabled} '
|
'hasFocus=${_installedFocusNode?.hasFocus} enabled=${widget.input.enabled} '
|
||||||
'usesNativeTvKeyboard=${widget.input._usesNativeTvKeyboard}',
|
'usesNativeTvKeyboard=${widget.input._usesNativeTvKeyboard} activated=$_nativeTextInputActivated',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
_setNativeTextInputFocused(focused);
|
_setNativeTextInputFocused(focused);
|
||||||
@@ -1172,30 +1187,56 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
|||||||
}
|
}
|
||||||
var activateNativeTextInput = widget.input._usesNativeTvKeyboard && !_nativeTextInputActivated;
|
var activateNativeTextInput = widget.input._usesNativeTvKeyboard && !_nativeTextInputActivated;
|
||||||
final isRemoteNavigation = event.logicalKey.isDpadDirection || event.logicalKey.isBackKey || event.isTvSelectEvent;
|
final isRemoteNavigation = event.logicalKey.isDpadDirection || event.logicalKey.isBackKey || event.isTvSelectEvent;
|
||||||
if (PlatformDetector.isAppleTV() &&
|
if (widget.input._usesNativeTvKeyboard &&
|
||||||
widget.input._usesNativeTvKeyboard &&
|
|
||||||
_nativeTextInputActivated &&
|
_nativeTextInputActivated &&
|
||||||
event is KeyDownEvent &&
|
event is KeyDownEvent &&
|
||||||
isRemoteNavigation) {
|
isRemoteNavigation) {
|
||||||
// Remote navigation events are system-owned while the native keyboard
|
if (PlatformDetector.isAppleTV()) {
|
||||||
// is active. Receiving one here proves that UIKit has dismissed the
|
// Remote navigation events are system-owned while the native keyboard
|
||||||
// keyboard while Flutter focus stayed on the field. Restore the
|
// is active. Receiving one here proves that UIKit has dismissed the
|
||||||
// read-only gate so this press navigates Flutter instead of reopening
|
// keyboard while Flutter focus stayed on the field. Restore the
|
||||||
// the input connection.
|
// read-only gate so this press navigates Flutter instead of reopening
|
||||||
_suppressNativeTextInputForCurrentFocus = true;
|
// the input connection.
|
||||||
_setNativeTextInputActivated(false);
|
_suppressNativeTextInputForCurrentFocus = true;
|
||||||
activateNativeTextInput = true;
|
_setNativeTextInputActivated(false);
|
||||||
if (event.logicalKey.isBackKey) {
|
activateNativeTextInput = true;
|
||||||
// This is the Menu press that dismissed UIKit's keyboard. Consume its
|
if (event.logicalKey.isBackKey) {
|
||||||
// Flutter continuation so one press cannot also pop the app route.
|
// 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;
|
||||||
|
}
|
||||||
|
} else if (event.logicalKey.isBackKey) {
|
||||||
|
// Android: a healthy IME consumes Back to dismiss itself before the
|
||||||
|
// app ever sees it. One arriving here means the keyboard is already
|
||||||
|
// gone (or its key session is broken and MainActivity's repair budget
|
||||||
|
// ran out): close the session and consume the press so it cannot also
|
||||||
|
// pop the route underneath.
|
||||||
|
_suppressNativeTextInputForCurrentFocus = true;
|
||||||
|
_setNativeTextInputActivated(false);
|
||||||
return KeyEventResult.handled;
|
return KeyEventResult.handled;
|
||||||
}
|
} else if (event.isTvSelectEvent) {
|
||||||
if (event.isTvSelectEvent) {
|
// Android: Select on a field whose keyboard was dismissed re-raises
|
||||||
|
// it (EditText parity). Toggle the connection so the engine issues a
|
||||||
|
// fresh TextInput.show; MainActivity's show-retry covers the
|
||||||
|
// served-view race (#1051/#1079).
|
||||||
|
_suppressNativeTextInputForCurrentFocus = true;
|
||||||
|
_setNativeTextInputActivated(false);
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (mounted) _activateNativeTextInput();
|
if (mounted) _activateNativeTextInput();
|
||||||
});
|
});
|
||||||
return KeyEventResult.handled;
|
return KeyEventResult.handled;
|
||||||
}
|
}
|
||||||
|
// Android arrows fall through deliberately: a healthy visible IME
|
||||||
|
// consumes them before the app, and leaked ones are repaired and eaten
|
||||||
|
// by MainActivity — so an arrow reaching this handler is real caret or
|
||||||
|
// traversal input (BT keyboards included) and keeps the caret-aware
|
||||||
|
// edge-escape semantics below.
|
||||||
}
|
}
|
||||||
return widget.input._handleKey(
|
return widget.input._handleKey(
|
||||||
context,
|
context,
|
||||||
|
|||||||
@@ -499,9 +499,10 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
|
|||||||
FocusableTextFormField(
|
FocusableTextFormField(
|
||||||
controller: _urlController,
|
controller: _urlController,
|
||||||
focusNode: _urlFocus,
|
focusNode: _urlFocus,
|
||||||
tvTextInputPresentation: PlatformDetector.isAppleTV()
|
// Native on every TV: `automatic` would route this wrap-to-4-lines
|
||||||
? TvTextInputPresentation.platform
|
// field to the Flutter overlay, but it is logically single-line URL
|
||||||
: TvTextInputPresentation.automatic,
|
// input and the platform IME handles it (#1051, #1079).
|
||||||
|
tvTextInputPresentation: TvTextInputPresentation.platform,
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
tvTextInputAutoOpenBehavior: deferredUrlFieldAutoOpen,
|
tvTextInputAutoOpenBehavior: deferredUrlFieldAutoOpen,
|
||||||
keyboardType: TextInputType.url,
|
keyboardType: TextInputType.url,
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
|
||||||
import 'package:plezy/focus/input_mode_tracker.dart';
|
import 'package:plezy/focus/input_mode_tracker.dart';
|
||||||
import 'package:plezy/i18n/strings.g.dart';
|
import 'package:plezy/i18n/strings.g.dart';
|
||||||
import 'package:plezy/screens/profile/add_local_profile_screen.dart';
|
import 'package:plezy/screens/profile/add_local_profile_screen.dart';
|
||||||
@@ -63,7 +62,7 @@ void main() {
|
|||||||
expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddLocalProfile:Cancel');
|
expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddLocalProfile:Cancel');
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('Android TV virtual keyboard done leaves profile name input', (tester) async {
|
testWidgets('Android TV native keyboard done leaves profile name input', (tester) async {
|
||||||
TvDetectionService.debugSetAppleTVOverride(null);
|
TvDetectionService.debugSetAppleTVOverride(null);
|
||||||
await TvDetectionService.getInstance(forceTv: true);
|
await TvDetectionService.getInstance(forceTv: true);
|
||||||
TvDetectionService.setForceTVSync(true);
|
TvDetectionService.setForceTVSync(true);
|
||||||
@@ -75,9 +74,12 @@ void main() {
|
|||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(FocusManager.instance.primaryFocus?.debugLabel, 'TvVirtualKeyboard');
|
// The native IME opens in place: focus stays on the field, no overlay.
|
||||||
|
expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddLocalProfile:Name');
|
||||||
|
expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsNothing);
|
||||||
|
|
||||||
await tester.tap(find.byIcon(Symbols.check_rounded));
|
await tester.showKeyboard(find.byType(TextField));
|
||||||
|
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddLocalProfile:SetPin');
|
expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddLocalProfile:SetPin');
|
||||||
|
|||||||
@@ -389,8 +389,12 @@ void main() {
|
|||||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
|
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(FocusManager.instance.primaryFocus?.debugLabel, 'TvVirtualKeyboard');
|
// A deliberate return to the field opens the native IME (afterFirstFocus):
|
||||||
expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsOneWidget);
|
// the field becomes editable in place — no Flutter overlay, focus stays on
|
||||||
|
// the field itself.
|
||||||
|
expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddJellyfin:Url');
|
||||||
|
expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsNothing);
|
||||||
|
expect(tester.widget<TextField>(find.byType(TextField)).readOnly, isFalse);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('TV discovery keeps initial URL focus and D-pad reaches discovered servers', (tester) async {
|
testWidgets('TV discovery keeps initial URL focus and D-pad reaches discovered servers', (tester) async {
|
||||||
|
|||||||
@@ -823,11 +823,10 @@ void main() {
|
|||||||
expect(find.byType(Dialog), findsNothing);
|
expect(find.byType(Dialog), findsNothing);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('Android TV focus opens the TV virtual keyboard', (tester) async {
|
testWidgets('Android TV automatic single-line input uses the platform field', (tester) async {
|
||||||
TvDetectionService.debugSetAppleTVOverride(null);
|
TvDetectionService.debugSetAppleTVOverride(null);
|
||||||
await TvDetectionService.getInstance(forceTv: true);
|
await TvDetectionService.getInstance(forceTv: true);
|
||||||
TvDetectionService.setForceTVSync(true);
|
TvDetectionService.setForceTVSync(true);
|
||||||
await _setTvSurfaceSize(tester);
|
|
||||||
final controller = TextEditingController();
|
final controller = TextEditingController();
|
||||||
final fieldFocusNode = FocusNode(debugLabel: 'server_url_field');
|
final fieldFocusNode = FocusNode(debugLabel: 'server_url_field');
|
||||||
addTearDown(controller.dispose);
|
addTearDown(controller.dispose);
|
||||||
@@ -844,7 +843,10 @@ void main() {
|
|||||||
fieldFocusNode.requestFocus();
|
fieldFocusNode.requestFocus();
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsOneWidget);
|
// `automatic` opens the docked native IME on focus: the read-only
|
||||||
|
// activation gate lifts and no Flutter overlay may appear.
|
||||||
|
expect(tester.widget<TextField>(find.byType(TextField)).readOnly, isFalse);
|
||||||
|
expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsNothing);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('Android TV after-first-focus skips initial auto-open and opens on refocus', (tester) async {
|
testWidgets('Android TV after-first-focus skips initial auto-open and opens on refocus', (tester) async {
|
||||||
@@ -867,6 +869,7 @@ void main() {
|
|||||||
FocusableTextFormField(
|
FocusableTextFormField(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
focusNode: fieldFocusNode,
|
focusNode: fieldFocusNode,
|
||||||
|
tvTextInputPresentation: TvTextInputPresentation.flutterOverlay,
|
||||||
tvTextInputAutoOpenBehavior: TvTextInputAutoOpenBehavior.afterFirstFocus,
|
tvTextInputAutoOpenBehavior: TvTextInputAutoOpenBehavior.afterFirstFocus,
|
||||||
),
|
),
|
||||||
Focus(focusNode: otherFocusNode, child: const SizedBox(width: 1, height: 1)),
|
Focus(focusNode: otherFocusNode, child: const SizedBox(width: 1, height: 1)),
|
||||||
@@ -909,6 +912,7 @@ void main() {
|
|||||||
body: FocusableTextFormField(
|
body: FocusableTextFormField(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
focusNode: fieldFocusNode,
|
focusNode: fieldFocusNode,
|
||||||
|
tvTextInputPresentation: TvTextInputPresentation.flutterOverlay,
|
||||||
tvTextInputAutoOpenBehavior: TvTextInputAutoOpenBehavior.afterFirstFocus,
|
tvTextInputAutoOpenBehavior: TvTextInputAutoOpenBehavior.afterFirstFocus,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -927,7 +931,7 @@ void main() {
|
|||||||
expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsOneWidget);
|
expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('Android TV remote keys are passed to native text input', (tester) async {
|
testWidgets('Android TV dismissed-keyboard remote keys navigate, reopen, and consume back', (tester) async {
|
||||||
TvDetectionService.debugSetAppleTVOverride(null);
|
TvDetectionService.debugSetAppleTVOverride(null);
|
||||||
await TvDetectionService.getInstance(forceTv: true);
|
await TvDetectionService.getInstance(forceTv: true);
|
||||||
TvDetectionService.setForceTVSync(true);
|
TvDetectionService.setForceTVSync(true);
|
||||||
@@ -960,14 +964,37 @@ void main() {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// `automatic` auto-open activates the native session on focus.
|
||||||
fieldFocusNode.requestFocus();
|
fieldFocusNode.requestFocus();
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
await tester.pump();
|
||||||
final handler = fieldFocusNode.onKeyEvent!;
|
final handler = fieldFocusNode.onKeyEvent!;
|
||||||
|
|
||||||
final downResult = handler(fieldFocusNode, _remoteKey(LogicalKeyboardKey.arrowDown));
|
// Remote keys reach Flutter only when the IME is not consuming them
|
||||||
final selectResult = handler(fieldFocusNode, _remoteKey(LogicalKeyboardKey.select));
|
// (keyboard dismissed, or a broken key session already repaired and eaten
|
||||||
|
// by MainActivity). Back closes the session and is consumed once so the
|
||||||
|
// same press cannot also pop the route underneath.
|
||||||
final backResult = handler(fieldFocusNode, _remoteKey(LogicalKeyboardKey.goBack));
|
final backResult = handler(fieldFocusNode, _remoteKey(LogicalKeyboardKey.goBack));
|
||||||
final keyboardDownResult = handler(fieldFocusNode, _keyboardDpadKey(LogicalKeyboardKey.arrowDown));
|
await tester.pump();
|
||||||
|
expect(backResult, KeyEventResult.handled);
|
||||||
|
expect(backs, 0);
|
||||||
|
expect(fieldFocusNode.hasPrimaryFocus, isTrue);
|
||||||
|
|
||||||
|
// Session closed: the next back reaches the field's own onBack.
|
||||||
|
final secondBackResult = handler(fieldFocusNode, _remoteKey(LogicalKeyboardKey.goBack));
|
||||||
|
await tester.pump();
|
||||||
|
expect(secondBackResult, KeyEventResult.handled);
|
||||||
|
expect(backs, 1);
|
||||||
|
|
||||||
|
// Select re-raises the keyboard (activation), not onSelect.
|
||||||
|
final selectResult = handler(fieldFocusNode, _remoteKey(LogicalKeyboardKey.select));
|
||||||
|
await tester.pump();
|
||||||
|
expect(selectResult, KeyEventResult.handled);
|
||||||
|
expect(selects, 0);
|
||||||
|
expect(tester.widget<TextField>(find.byType(TextField)).readOnly, isFalse);
|
||||||
|
|
||||||
|
// Chromecast remotes report Select with a keyboard deviceType; while the
|
||||||
|
// session is live it must still read as a reopen request, not onSelect.
|
||||||
final synthesizedSelectResult = handler(
|
final synthesizedSelectResult = handler(
|
||||||
fieldFocusNode,
|
fieldFocusNode,
|
||||||
const KeyDownEvent(
|
const KeyDownEvent(
|
||||||
@@ -977,22 +1004,82 @@ void main() {
|
|||||||
deviceType: ui.KeyEventDeviceType.keyboard,
|
deviceType: ui.KeyEventDeviceType.keyboard,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
final keyboardBackResult = handler(fieldFocusNode, _keyboardDpadKey(LogicalKeyboardKey.goBack));
|
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
await tester.pump();
|
||||||
expect(downResult, KeyEventResult.skipRemainingHandlers);
|
expect(synthesizedSelectResult, KeyEventResult.handled);
|
||||||
expect(selectResult, KeyEventResult.skipRemainingHandlers);
|
|
||||||
expect(backResult, KeyEventResult.skipRemainingHandlers);
|
|
||||||
expect(keyboardDownResult, KeyEventResult.skipRemainingHandlers);
|
|
||||||
expect(synthesizedSelectResult, KeyEventResult.skipRemainingHandlers);
|
|
||||||
expect(keyboardBackResult, KeyEventResult.skipRemainingHandlers);
|
|
||||||
expect(fieldFocusNode.hasPrimaryFocus, isTrue);
|
|
||||||
expect(nextFocusNode.hasFocus, isFalse);
|
|
||||||
expect(selects, 0);
|
expect(selects, 0);
|
||||||
expect(backs, 0);
|
expect(tester.widget<TextField>(find.byType(TextField)).readOnly, isFalse);
|
||||||
|
|
||||||
|
// Down while the session is live navigates instead of dead-ending behind
|
||||||
|
// a keyboard that is not there (#1079's trap).
|
||||||
|
final downResult = handler(fieldFocusNode, _remoteKey(LogicalKeyboardKey.arrowDown));
|
||||||
|
await tester.pump();
|
||||||
|
expect(downResult, KeyEventResult.handled);
|
||||||
|
expect(nextFocusNode.hasPrimaryFocus, isTrue);
|
||||||
expect(find.byType(Dialog), findsNothing);
|
expect(find.byType(Dialog), findsNothing);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('Android TV platform focus hint tracks activation, not focus', (tester) async {
|
||||||
|
TvDetectionService.debugSetAppleTVOverride(null);
|
||||||
|
await TvDetectionService.getInstance(forceTv: true);
|
||||||
|
TvDetectionService.setForceTVSync(true);
|
||||||
|
const channel = MethodChannel('com.plezy/text_input');
|
||||||
|
final sentStates = <bool>[];
|
||||||
|
GamepadService.debugNativeTextInputFocusHandler = (_) async {};
|
||||||
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(channel, (call) async {
|
||||||
|
if (call.method == 'setNativeTextInputFocused') sentStates.add(call.arguments as bool);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
addTearDown(
|
||||||
|
() => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(channel, null),
|
||||||
|
);
|
||||||
|
|
||||||
|
final controller = TextEditingController();
|
||||||
|
final fieldFocusNode = FocusNode(debugLabel: 'server_url_field');
|
||||||
|
final otherFocusNode = FocusNode(debugLabel: 'other');
|
||||||
|
addTearDown(controller.dispose);
|
||||||
|
addTearDown(fieldFocusNode.dispose);
|
||||||
|
addTearDown(otherFocusNode.dispose);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
FocusableTextFormField(
|
||||||
|
controller: controller,
|
||||||
|
focusNode: fieldFocusNode,
|
||||||
|
tvTextInputPresentation: TvTextInputPresentation.platform,
|
||||||
|
tvTextInputAutoOpenBehavior: TvTextInputAutoOpenBehavior.afterFirstFocus,
|
||||||
|
),
|
||||||
|
Focus(focusNode: otherFocusNode, child: const SizedBox.shrink()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// First focus is suppressed by afterFirstFocus: no live input session, so
|
||||||
|
// the platform hint stays silent — MainActivity keeps the pre-IME D-pad
|
||||||
|
// intercept and the gamepad bridge active for plain navigation.
|
||||||
|
fieldFocusNode.requestFocus();
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump();
|
||||||
|
expect(sentStates, isEmpty);
|
||||||
|
|
||||||
|
// An explicit Select opens the session: only now does the platform learn
|
||||||
|
// about it (arming the soft-input show-retry in MainActivity).
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.select);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump();
|
||||||
|
expect(sentStates, [true]);
|
||||||
|
|
||||||
|
otherFocusNode.requestFocus();
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump();
|
||||||
|
expect(sentStates, [true, false]);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('Android TV native text input focus is reported to platform', (tester) async {
|
testWidgets('Android TV native text input focus is reported to platform', (tester) async {
|
||||||
TvDetectionService.debugSetAppleTVOverride(null);
|
TvDetectionService.debugSetAppleTVOverride(null);
|
||||||
await TvDetectionService.getInstance(forceTv: true);
|
await TvDetectionService.getInstance(forceTv: true);
|
||||||
@@ -1068,6 +1155,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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1103,7 +1191,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,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -1255,6 +1347,7 @@ void main() {
|
|||||||
body: FocusableTextField(
|
body: FocusableTextField(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
focusNode: fieldFocusNode,
|
focusNode: fieldFocusNode,
|
||||||
|
tvTextInputPresentation: TvTextInputPresentation.flutterOverlay,
|
||||||
tvTextInputAutoOpenBehavior: TvTextInputAutoOpenBehavior.never,
|
tvTextInputAutoOpenBehavior: TvTextInputAutoOpenBehavior.never,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1338,6 +1431,7 @@ void main() {
|
|||||||
body: FocusableTextField(
|
body: FocusableTextField(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
focusNode: fieldFocusNode,
|
focusNode: fieldFocusNode,
|
||||||
|
tvTextInputPresentation: TvTextInputPresentation.flutterOverlay,
|
||||||
tvTextInputAutoOpenBehavior: TvTextInputAutoOpenBehavior.never,
|
tvTextInputAutoOpenBehavior: TvTextInputAutoOpenBehavior.never,
|
||||||
maxLength: 8,
|
maxLength: 8,
|
||||||
inputFormatters: [
|
inputFormatters: [
|
||||||
@@ -1384,6 +1478,7 @@ void main() {
|
|||||||
return FocusableTextField(
|
return FocusableTextField(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
focusNode: fieldFocusNode,
|
focusNode: fieldFocusNode,
|
||||||
|
tvTextInputPresentation: TvTextInputPresentation.flutterOverlay,
|
||||||
textInputAction: TextInputAction.search,
|
textInputAction: TextInputAction.search,
|
||||||
onNavigateDown: onNavigateDown,
|
onNavigateDown: onNavigateDown,
|
||||||
);
|
);
|
||||||
@@ -1434,6 +1529,7 @@ void main() {
|
|||||||
return FocusableTextField(
|
return FocusableTextField(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
focusNode: fieldFocusNode,
|
focusNode: fieldFocusNode,
|
||||||
|
tvTextInputPresentation: TvTextInputPresentation.flutterOverlay,
|
||||||
textInputAction: TextInputAction.search,
|
textInputAction: TextInputAction.search,
|
||||||
onSubmitted: onSubmitted,
|
onSubmitted: onSubmitted,
|
||||||
onNavigateDown: onNavigateDown,
|
onNavigateDown: onNavigateDown,
|
||||||
@@ -1477,6 +1573,7 @@ void main() {
|
|||||||
body: FocusableTextField(
|
body: FocusableTextField(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
focusNode: fieldFocusNode,
|
focusNode: fieldFocusNode,
|
||||||
|
tvTextInputPresentation: TvTextInputPresentation.flutterOverlay,
|
||||||
textInputAction: TextInputAction.search,
|
textInputAction: TextInputAction.search,
|
||||||
onEditingComplete: () {},
|
onEditingComplete: () {},
|
||||||
),
|
),
|
||||||
@@ -1557,15 +1654,6 @@ KeyDownEvent _remoteKey(LogicalKeyboardKey key) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
KeyDownEvent _keyboardDpadKey(LogicalKeyboardKey key) {
|
|
||||||
return KeyDownEvent(
|
|
||||||
physicalKey: _physicalKeyFor(key),
|
|
||||||
logicalKey: key,
|
|
||||||
timeStamp: Duration.zero,
|
|
||||||
deviceType: ui.KeyEventDeviceType.keyboard,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
PhysicalKeyboardKey _physicalKeyFor(LogicalKeyboardKey key) {
|
PhysicalKeyboardKey _physicalKeyFor(LogicalKeyboardKey key) {
|
||||||
if (key == LogicalKeyboardKey.arrowDown) return PhysicalKeyboardKey.arrowDown;
|
if (key == LogicalKeyboardKey.arrowDown) return PhysicalKeyboardKey.arrowDown;
|
||||||
if (key == LogicalKeyboardKey.goBack) return PhysicalKeyboardKey.escape;
|
if (key == LogicalKeyboardKey.goBack) return PhysicalKeyboardKey.escape;
|
||||||
|
|||||||
Reference in New Issue
Block a user