fix(tvos): raise the system keyboard on arrival, not on every focus
Apple TV single-line fields moved to the engine's UITextField proxy in
2.10.0 (71735354), which made three focus behaviours user-visible.
Submitting re-attached the input connection. EditableText schedules a
restart when a submit action fires with a non-null onSubmitted, and that
microtask runs before the setState flipping readOnly, so the field
re-showed a keyboard the form had just dismissed. The native path now
withholds onSubmitted from EditableText and invokes it from the host,
independently of onEditingComplete as _finalizeEditing does.
Auto-open fired on every focus entry, so D-pad traversal of a multi-field
form raised and dismissed the modal system keyboard on each step.
TvTextInputAutoOpenBehavior gains onFirstFocus, and the new `automatic`
default resolves to it on Apple TV: arriving at a field opens it once,
returning to it does not. Android TV keeps its docked-IME auto-open, and
explicit modes stay literal on both. The autofocused Jellyfin and Seerr
URL fields keep an explicit exception so entering the screen still does
not bury the form (#1217).
EditableText.connectionClosed unfocuses the field outright, so a UIKit
keyboard dismissal left nothing focused at all. The host takes focus back,
keyed on identity with the field's own enclosing scope so a dialog or
route claiming focus meanwhile is left alone.
close #1728
This commit is contained in:
@@ -36,10 +36,25 @@ bool _usesTvKeyboard({required TvTextInputPresentation presentation, TextInputTy
|
|||||||
String? _keyboardHint(InputDecoration? decoration) => decoration?.hintText ?? decoration?.labelText;
|
String? _keyboardHint(InputDecoration? decoration) => decoration?.hintText ?? decoration?.labelText;
|
||||||
|
|
||||||
enum TvTextInputAutoOpenBehavior {
|
enum TvTextInputAutoOpenBehavior {
|
||||||
|
/// Resolve per presentation: [onFirstFocus] for the native tvOS keyboard —
|
||||||
|
/// arriving at a field opens it once, but returning to it during D-pad
|
||||||
|
/// traversal does not, since it is a modal full-screen surface and
|
||||||
|
/// re-raising it on every pass makes a form untraversable. [onFocus] for the
|
||||||
|
/// in-app Flutter overlay, which is cheap, non-modal, and involves no UIKit
|
||||||
|
/// first responder.
|
||||||
|
automatic,
|
||||||
|
|
||||||
/// Open the selected TV text input presentation whenever the field receives
|
/// Open the selected TV text input presentation whenever the field receives
|
||||||
/// focus.
|
/// focus. On Apple TV this raises the system keyboard on every focus entry,
|
||||||
|
/// including plain D-pad traversal — prefer [automatic] unless the field is
|
||||||
|
/// the sole purpose of its screen.
|
||||||
onFocus,
|
onFocus,
|
||||||
|
|
||||||
|
/// Open on the field's first focus, then stay closed on later focus entries.
|
||||||
|
/// Explicit tap/select still opens it, as does the first focus after a
|
||||||
|
/// focus-node or presentation change.
|
||||||
|
onFirstFocus,
|
||||||
|
|
||||||
/// Keep initial focus on the field without opening text input, then open it
|
/// 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,
|
||||||
@@ -48,6 +63,17 @@ enum TvTextInputAutoOpenBehavior {
|
|||||||
never,
|
never,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Auto-open policy for an autofocused server-URL field (#1217): entering the
|
||||||
|
/// screen must not bury the form under a keyboard the user did not ask for.
|
||||||
|
///
|
||||||
|
/// 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
|
||||||
|
/// 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
|
||||||
|
/// return.
|
||||||
|
TvTextInputAutoOpenBehavior get deferredUrlFieldAutoOpen =>
|
||||||
|
PlatformDetector.isAppleTV() ? TvTextInputAutoOpenBehavior.never : TvTextInputAutoOpenBehavior.afterFirstFocus;
|
||||||
|
|
||||||
/// Imperative handle to TV text input for 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
|
||||||
/// `tvTextInputController`; the field's host attaches itself on mount.
|
/// `tvTextInputController`; the field's host attaches itself on mount.
|
||||||
@@ -618,7 +644,7 @@ abstract class _FocusableTextInputBase extends StatelessWidget {
|
|||||||
this.autofocus = false,
|
this.autofocus = false,
|
||||||
this.enabled = true,
|
this.enabled = true,
|
||||||
this.tvTextInputPresentation = TvTextInputPresentation.automatic,
|
this.tvTextInputPresentation = TvTextInputPresentation.automatic,
|
||||||
this.tvTextInputAutoOpenBehavior = TvTextInputAutoOpenBehavior.onFocus,
|
this.tvTextInputAutoOpenBehavior = TvTextInputAutoOpenBehavior.automatic,
|
||||||
this.tvTextInputController,
|
this.tvTextInputController,
|
||||||
this.obscureText = false,
|
this.obscureText = false,
|
||||||
this.autocorrect = true,
|
this.autocorrect = true,
|
||||||
@@ -818,11 +844,46 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _handleFocusChanged() {
|
void _handleFocusChanged() {
|
||||||
|
_restoreFocusAfterPlatformDismissal();
|
||||||
_syncNativeTextInputActivation();
|
_syncNativeTextInputActivation();
|
||||||
_syncNativeTextInputFocus();
|
_syncNativeTextInputFocus();
|
||||||
_syncTvKeyboardAutoOpen();
|
_syncTvKeyboardAutoOpen();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// [EditableText.connectionClosed] unfocuses the field outright
|
||||||
|
/// (editable_text.dart:4138-4145). On tvOS that fires whenever UIKit
|
||||||
|
/// dismisses the system keyboard, which is a dismissal, not a navigation —
|
||||||
|
/// left alone it strands the user with nothing focused and no way back.
|
||||||
|
///
|
||||||
|
/// Deliberate exits never reach here: every path that moves focus off an
|
||||||
|
/// active field (D-pad escape, Menu, [TvTextInputController.closeTextInput],
|
||||||
|
/// submit) deactivates first. `unfocus()` parks focus on the field's own
|
||||||
|
/// enclosing scope, so that exact node — not merely "some scope" — is the
|
||||||
|
/// signature. A dialog or route opening in the post-frame gap makes *its*
|
||||||
|
/// scope primary, which must not be mistaken for our dismissal.
|
||||||
|
void _restoreFocusAfterPlatformDismissal() {
|
||||||
|
final node = _installedFocusNode;
|
||||||
|
if (node == null || node.hasFocus || !_nativeTextInputActivated) return;
|
||||||
|
// Apple TV only: Android TV's IME close keeps its historical semantics,
|
||||||
|
// and no production field selects the native path there anyway.
|
||||||
|
if (!PlatformDetector.isAppleTV()) return;
|
||||||
|
if (!widget.input.enabled || !widget.input._usesNativeTvKeyboard) return;
|
||||||
|
final scope = node.enclosingScope;
|
||||||
|
if (scope == null || !identical(FocusManager.instance.primaryFocus, scope)) return;
|
||||||
|
|
||||||
|
_setNativeTextInputActivated(false);
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
final target = _installedFocusNode;
|
||||||
|
if (target == null || target.hasFocus || !target.canRequestFocus) return;
|
||||||
|
if (!identical(FocusManager.instance.primaryFocus, scope)) return;
|
||||||
|
// Set before requesting focus so the resulting focus-change callback
|
||||||
|
// cannot reopen the keyboard we were just dismissed out of.
|
||||||
|
_suppressNativeTextInputForCurrentFocus = true;
|
||||||
|
target.requestFocus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
void _syncNativeTextInputActivation() {
|
void _syncNativeTextInputActivation() {
|
||||||
final input = widget.input;
|
final input = widget.input;
|
||||||
final focused = _installedFocusNode?.hasFocus == true && input.enabled && input._usesNativeTvKeyboard;
|
final focused = _installedFocusNode?.hasFocus == true && input.enabled && input._usesNativeTvKeyboard;
|
||||||
@@ -834,6 +895,22 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
|||||||
if (_suppressNativeTextInputForCurrentFocus) return;
|
if (_suppressNativeTextInputForCurrentFocus) return;
|
||||||
|
|
||||||
switch (input.tvTextInputAutoOpenBehavior) {
|
switch (input.tvTextInputAutoOpenBehavior) {
|
||||||
|
// Apple TV's system keyboard is modal and full-screen. Arriving at a
|
||||||
|
// field should still open it — otherwise typing always costs two
|
||||||
|
// presses — but re-raising it every time D-pad traversal passes back
|
||||||
|
// over the field makes a multi-field form unusable, so `automatic`
|
||||||
|
// resolves to `onFirstFocus` there. Android TV's native IME is a docked
|
||||||
|
// soft keyboard that does not take over the screen, so it keeps the
|
||||||
|
// historical auto-open. Explicit modes stay literal on both: a caller
|
||||||
|
// that asks for onFocus gets onFocus.
|
||||||
|
case TvTextInputAutoOpenBehavior.automatic:
|
||||||
|
if (PlatformDetector.isAppleTV() && _hasSeenNativeTextInputFocus) return;
|
||||||
|
_hasSeenNativeTextInputFocus = true;
|
||||||
|
_setNativeTextInputActivated(true);
|
||||||
|
case TvTextInputAutoOpenBehavior.onFirstFocus:
|
||||||
|
if (_hasSeenNativeTextInputFocus) return;
|
||||||
|
_hasSeenNativeTextInputFocus = true;
|
||||||
|
_setNativeTextInputActivated(true);
|
||||||
case TvTextInputAutoOpenBehavior.onFocus:
|
case TvTextInputAutoOpenBehavior.onFocus:
|
||||||
_hasSeenNativeTextInputFocus = true;
|
_hasSeenNativeTextInputFocus = true;
|
||||||
_setNativeTextInputActivated(true);
|
_setNativeTextInputActivated(true);
|
||||||
@@ -880,15 +957,24 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
|||||||
|
|
||||||
final input = widget.input;
|
final input = widget.input;
|
||||||
final callback = input._effectiveOnEditingComplete;
|
final callback = input._effectiveOnEditingComplete;
|
||||||
|
final onSubmitted = input.onSubmitted;
|
||||||
if (callback != null) {
|
if (callback != null) {
|
||||||
callback();
|
callback();
|
||||||
} else if (input.onSubmitted == null) {
|
} else if (onSubmitted == null) {
|
||||||
// Supplying this wrapper replaces EditableText's default completion.
|
// Supplying this wrapper replaces EditableText's default completion.
|
||||||
// Preserve it when there is no submit callback; submitted TV fields keep
|
// Preserve it when there is no submit callback; submitted TV fields keep
|
||||||
// focus until their callback chooses the next target so D-pad navigation
|
// focus until their callback chooses the next target so D-pad navigation
|
||||||
// cannot dead-end while asynchronous work runs.
|
// cannot dead-end while asynchronous work runs.
|
||||||
_defaultEditingComplete(input.textInputAction);
|
_defaultEditingComplete(input.textInputAction);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EditableText invokes onEditingComplete and onSubmitted independently
|
||||||
|
// (_finalizeEditing, editable_text.dart:3841-3898), so both must fire when
|
||||||
|
// both are supplied. The native path withholds onSubmitted from the widget
|
||||||
|
// — letting EditableText own it would schedule a connection restart that
|
||||||
|
// re-attaches and re-shows the input we just dismissed, which on tvOS
|
||||||
|
// tears the system keyboard down and back up mid-submit — so call it here.
|
||||||
|
onSubmitted?.call(input.controller.text);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _syncNativeTextInputFocus() {
|
void _syncNativeTextInputFocus() {
|
||||||
@@ -946,8 +1032,15 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
|||||||
|
|
||||||
bool _shouldAutoOpenTvKeyboardForCurrentFocus() {
|
bool _shouldAutoOpenTvKeyboardForCurrentFocus() {
|
||||||
switch (widget.input.tvTextInputAutoOpenBehavior) {
|
switch (widget.input.tvTextInputAutoOpenBehavior) {
|
||||||
|
// The Flutter overlay is an in-app, non-modal widget with no UIKit first
|
||||||
|
// responder behind it, so opening it on focus costs nothing.
|
||||||
|
case TvTextInputAutoOpenBehavior.automatic:
|
||||||
case TvTextInputAutoOpenBehavior.onFocus:
|
case TvTextInputAutoOpenBehavior.onFocus:
|
||||||
return true;
|
return true;
|
||||||
|
case TvTextInputAutoOpenBehavior.onFirstFocus:
|
||||||
|
if (_hasSeenTvKeyboardFocus) return false;
|
||||||
|
_hasSeenTvKeyboardFocus = true;
|
||||||
|
return true;
|
||||||
case TvTextInputAutoOpenBehavior.afterFirstFocus:
|
case TvTextInputAutoOpenBehavior.afterFirstFocus:
|
||||||
if (!_hasSeenTvKeyboardFocus) {
|
if (!_hasSeenTvKeyboardFocus) {
|
||||||
_hasSeenTvKeyboardFocus = true;
|
_hasSeenTvKeyboardFocus = true;
|
||||||
@@ -1237,7 +1330,10 @@ class FocusableTextField extends _FocusableTextInputBase {
|
|||||||
textInputAction: textInputAction,
|
textInputAction: textInputAction,
|
||||||
inputFormatters: inputFormatters,
|
inputFormatters: inputFormatters,
|
||||||
onChanged: onChanged,
|
onChanged: onChanged,
|
||||||
onSubmitted: onSubmitted,
|
// Withheld on the native TV path: the host invokes it from
|
||||||
|
// _handleNativeEditingComplete so EditableText cannot schedule a
|
||||||
|
// connection restart that re-shows the dismissed input.
|
||||||
|
onSubmitted: _usesNativeTvKeyboard ? null : onSubmitted,
|
||||||
onEditingComplete: onEditingComplete,
|
onEditingComplete: onEditingComplete,
|
||||||
autofocus: autofocus,
|
autofocus: autofocus,
|
||||||
autocorrect: autocorrect,
|
autocorrect: autocorrect,
|
||||||
@@ -1326,7 +1422,8 @@ class FocusableTextFormField extends _FocusableTextInputBase {
|
|||||||
textInputAction: textInputAction,
|
textInputAction: textInputAction,
|
||||||
inputFormatters: inputFormatters,
|
inputFormatters: inputFormatters,
|
||||||
onChanged: onChanged,
|
onChanged: onChanged,
|
||||||
onFieldSubmitted: onFieldSubmitted,
|
// Withheld on the native TV path — see FocusableTextField.build.
|
||||||
|
onFieldSubmitted: _usesNativeTvKeyboard ? null : onFieldSubmitted,
|
||||||
onEditingComplete: onEditingComplete,
|
onEditingComplete: onEditingComplete,
|
||||||
validator: validator,
|
validator: validator,
|
||||||
autovalidateMode: autovalidateMode,
|
autovalidateMode: autovalidateMode,
|
||||||
|
|||||||
@@ -488,7 +488,7 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
|
|||||||
? TvTextInputPresentation.platform
|
? TvTextInputPresentation.platform
|
||||||
: TvTextInputPresentation.automatic,
|
: TvTextInputPresentation.automatic,
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
tvTextInputAutoOpenBehavior: TvTextInputAutoOpenBehavior.afterFirstFocus,
|
tvTextInputAutoOpenBehavior: deferredUrlFieldAutoOpen,
|
||||||
keyboardType: TextInputType.url,
|
keyboardType: TextInputType.url,
|
||||||
minLines: 1,
|
minLines: 1,
|
||||||
maxLines: 4,
|
maxLines: 4,
|
||||||
|
|||||||
@@ -184,7 +184,7 @@ class _SeerrConnectScreenState extends State<SeerrConnectScreen> with AsyncFormS
|
|||||||
controller: _urlController,
|
controller: _urlController,
|
||||||
focusNode: _urlFocus,
|
focusNode: _urlFocus,
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
tvTextInputAutoOpenBehavior: TvTextInputAutoOpenBehavior.afterFirstFocus,
|
tvTextInputAutoOpenBehavior: deferredUrlFieldAutoOpen,
|
||||||
keyboardType: TextInputType.url,
|
keyboardType: TextInputType.url,
|
||||||
autocorrect: false,
|
autocorrect: false,
|
||||||
enableSuggestions: false,
|
enableSuggestions: false,
|
||||||
|
|||||||
@@ -144,9 +144,16 @@ void main() {
|
|||||||
expect(FocusManager.instance.primaryFocus?.debugLabel, 'SearchFirstResult');
|
expect(FocusManager.instance.primaryFocus?.debugLabel, 'SearchFirstResult');
|
||||||
expect(client.queries, ['movie']);
|
expect(client.queries, ['movie']);
|
||||||
|
|
||||||
// A deliberate return to the input starts a fresh native session.
|
// Returning to the input is the D-pad-up path from the first result
|
||||||
|
// (search_screen.dart onNavigateUp), so it must not re-raise the system
|
||||||
|
// keyboard; Select does. The field's first focus already opened it once.
|
||||||
searchInput.focusSearchInput();
|
searchInput.focusSearchInput();
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
expect(FocusManager.instance.primaryFocus?.debugLabel, 'SearchInput');
|
||||||
|
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(tester.widget<TextField>(find.byType(TextField)).readOnly, isFalse);
|
||||||
expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsNothing);
|
expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsNothing);
|
||||||
|
|
||||||
|
|||||||
@@ -420,9 +420,106 @@ void main() {
|
|||||||
|
|
||||||
expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddJellyfin:Url');
|
expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddJellyfin:Url');
|
||||||
expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsNothing);
|
expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsNothing);
|
||||||
|
// Returning to the URL field by D-pad must not raise the system keyboard;
|
||||||
|
// only an explicit Select does. Auto-opening on focus made the form
|
||||||
|
// untraversable on Apple TV (#1728).
|
||||||
|
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(tester.widget<TextField>(find.byType(TextField)).readOnly, isFalse);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Drives the Apple TV Add Jellyfin flow up to the credentials step and
|
||||||
|
/// leaves focus on the username field, as the probe does.
|
||||||
|
Future<void> pumpAppleTvCredentialsStep(WidgetTester tester) async {
|
||||||
|
TvDetectionService.debugSetAppleTVOverride(true);
|
||||||
|
PlatformDetector.debugSetIsDesktopOSOverride(false);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
InputModeTracker(
|
||||||
|
child: _testApp(
|
||||||
|
AddJellyfinScreen(authServiceFactory: () => _jellyfinAuthService(), localDiscoveryFactory: _noLocalServers),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.select);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
tester.testTextInput.updateEditingValue(const TextEditingValue(text: 'https://jf.example.com'));
|
||||||
|
await tester.pump();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> drainTextInput(WidgetTester tester) {
|
||||||
|
final methods = tester.testTextInput.log.map((call) => call.method).toList();
|
||||||
|
tester.testTextInput.log.clear();
|
||||||
|
return methods;
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('Apple TV probe handoff attaches text input exactly once', (tester) async {
|
||||||
|
await pumpAppleTvCredentialsStep(tester);
|
||||||
|
|
||||||
|
drainTextInput(tester);
|
||||||
|
await tester.testTextInput.receiveAction(TextInputAction.go);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
final handoff = drainTextInput(tester);
|
||||||
|
|
||||||
|
// The username field's first focus legitimately raises input once. What
|
||||||
|
// must not happen is a second attach: EditableText used to schedule a
|
||||||
|
// connection restart on submit (submit action + non-null onFieldSubmitted)
|
||||||
|
// and re-show the URL field it had just dismissed, so the handoff carried
|
||||||
|
// two setClient/show pairs. On tvOS that tears the system keyboard down
|
||||||
|
// and re-presents it while the next field is claiming it.
|
||||||
|
expect(handoff.where((m) => m == 'TextInput.setClient'), hasLength(1));
|
||||||
|
expect(handoff.where((m) => m == 'TextInput.show'), hasLength(1));
|
||||||
|
|
||||||
|
expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddJellyfin:Username');
|
||||||
|
expect(tester.widget<TextField>(find.byType(TextField).at(1)).readOnly, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Apple TV D-pad traversal stops raising the keyboard after each field is seen', (tester) async {
|
||||||
|
await pumpAppleTvCredentialsStep(tester);
|
||||||
|
await tester.testTextInput.receiveAction(TextInputAction.go);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// On device the D-pad belongs to the tvOS keyboard while it is up, so a
|
||||||
|
// user can only traverse after dismissing it. Model that: dismiss via the
|
||||||
|
// platform (as UIKit does), which must leave focus on the field, and only
|
||||||
|
// then send the arrow.
|
||||||
|
Future<void> dismissIfOpen() async {
|
||||||
|
final open = tester.widgetList<TextField>(find.byType(TextField)).any((field) => !field.readOnly);
|
||||||
|
if (!open) return;
|
||||||
|
final focused = FocusManager.instance.primaryFocus?.debugLabel;
|
||||||
|
tester.testTextInput.closeConnection();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(FocusManager.instance.primaryFocus?.debugLabel, focused, reason: 'dismissal must not move focus');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> walk() async {
|
||||||
|
for (final key in [LogicalKeyboardKey.arrowDown, LogicalKeyboardKey.arrowUp]) {
|
||||||
|
for (var step = 0; step < 3; step++) {
|
||||||
|
await dismissIfOpen();
|
||||||
|
await tester.sendKeyEvent(key);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// First pass may raise input once per field it has never focused before —
|
||||||
|
// arriving at a field is an intent to type.
|
||||||
|
await walk();
|
||||||
|
|
||||||
|
// Every later pass must be silent. `onFocus` re-raised the system keyboard
|
||||||
|
// on every single traversal step, which made the form unusable.
|
||||||
|
for (var pass = 2; pass <= 3; pass++) {
|
||||||
|
drainTextInput(tester);
|
||||||
|
await walk();
|
||||||
|
final traversal = drainTextInput(tester);
|
||||||
|
expect(traversal, isNot(contains('TextInput.setClient')), reason: 'pass $pass');
|
||||||
|
expect(traversal, isNot(contains('TextInput.show')), reason: 'pass $pass');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('D-pad moves from URL through Change to credentials after server is found', (tester) async {
|
testWidgets('D-pad moves from URL through Change to credentials after server is found', (tester) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
_testApp(
|
_testApp(
|
||||||
|
|||||||
@@ -256,6 +256,7 @@ void main() {
|
|||||||
|
|
||||||
fieldFocusNode.requestFocus();
|
fieldFocusNode.requestFocus();
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
await _raiseNativeInput(tester);
|
||||||
|
|
||||||
expect(tester.widget<TextField>(find.byType(TextField)).readOnly, isFalse);
|
expect(tester.widget<TextField>(find.byType(TextField)).readOnly, isFalse);
|
||||||
expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsNothing);
|
expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsNothing);
|
||||||
@@ -292,6 +293,7 @@ void main() {
|
|||||||
|
|
||||||
fieldFocusNode.requestFocus();
|
fieldFocusNode.requestFocus();
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
await _raiseNativeInput(tester);
|
||||||
await tester.showKeyboard(find.byType(TextField));
|
await tester.showKeyboard(find.byType(TextField));
|
||||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
@@ -331,6 +333,7 @@ void main() {
|
|||||||
|
|
||||||
fieldFocusNode.requestFocus();
|
fieldFocusNode.requestFocus();
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
await _raiseNativeInput(tester);
|
||||||
await tester.showKeyboard(find.byType(TextField));
|
await tester.showKeyboard(find.byType(TextField));
|
||||||
await tester.testTextInput.receiveAction(TextInputAction.go);
|
await tester.testTextInput.receiveAction(TextInputAction.go);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
@@ -344,6 +347,192 @@ void main() {
|
|||||||
expect(submissions, ['https://jellyfin.example.com']);
|
expect(submissions, ['https://jellyfin.example.com']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('Apple TV native submit fires both onEditingComplete and onSubmitted', (tester) async {
|
||||||
|
TvDetectionService.debugSetAppleTVOverride(true);
|
||||||
|
final controller = TextEditingController(text: 'https://jellyfin.example.com');
|
||||||
|
final fieldFocusNode = FocusNode(debugLabel: 'native_url_field');
|
||||||
|
final submissions = <String>[];
|
||||||
|
var completed = 0;
|
||||||
|
addTearDown(controller.dispose);
|
||||||
|
addTearDown(fieldFocusNode.dispose);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: FocusableTextFormField(
|
||||||
|
controller: controller,
|
||||||
|
focusNode: fieldFocusNode,
|
||||||
|
tvTextInputPresentation: TvTextInputPresentation.platform,
|
||||||
|
textInputAction: TextInputAction.go,
|
||||||
|
onEditingComplete: () => completed++,
|
||||||
|
onFieldSubmitted: submissions.add,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
fieldFocusNode.requestFocus();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await _raiseNativeInput(tester);
|
||||||
|
await tester.showKeyboard(find.byType(TextField));
|
||||||
|
await tester.testTextInput.receiveAction(TextInputAction.go);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
// EditableText calls the two independently; withholding onSubmitted from
|
||||||
|
// the widget on the native path must not collapse them into an either/or.
|
||||||
|
expect(completed, 1);
|
||||||
|
expect(submissions, ['https://jellyfin.example.com']);
|
||||||
|
expect(tester.widget<TextField>(find.byType(TextField)).readOnly, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Apple TV keeps field focus when the platform dismisses the keyboard', (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();
|
||||||
|
await _raiseNativeInput(tester);
|
||||||
|
expect(tester.widget<TextField>(find.byType(TextField)).readOnly, isFalse);
|
||||||
|
|
||||||
|
// UIKit dismissing the tvOS keyboard closes the connection with no
|
||||||
|
// performAction; EditableText.connectionClosed then unfocuses the field.
|
||||||
|
tester.testTextInput.log.clear();
|
||||||
|
tester.testTextInput.closeConnection();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(fieldFocusNode.hasPrimaryFocus, isTrue, reason: 'dismissal must not strand focus');
|
||||||
|
expect(tester.widget<TextField>(find.byType(TextField)).readOnly, isTrue);
|
||||||
|
final afterDismissal = tester.testTextInput.log.map((call) => call.method);
|
||||||
|
expect(afterDismissal, isNot(contains('TextInput.setClient')));
|
||||||
|
expect(afterDismissal, isNot(contains('TextInput.show')));
|
||||||
|
|
||||||
|
// The field is still usable: Select raises input again, D-pad still leaves.
|
||||||
|
await _raiseNativeInput(tester);
|
||||||
|
expect(tester.widget<TextField>(find.byType(TextField)).readOnly, isFalse);
|
||||||
|
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(nextFocusNode.hasPrimaryFocus, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Apple TV dismissal does not steal focus from a scope claimed meanwhile', (tester) async {
|
||||||
|
TvDetectionService.debugSetAppleTVOverride(true);
|
||||||
|
final controller = TextEditingController();
|
||||||
|
final fieldFocusNode = FocusNode(debugLabel: 'native_url_field');
|
||||||
|
final rivalScope = FocusScopeNode(debugLabel: 'rival_sheet_scope');
|
||||||
|
addTearDown(controller.dispose);
|
||||||
|
addTearDown(fieldFocusNode.dispose);
|
||||||
|
addTearDown(rivalScope.dispose);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
FocusableTextField(controller: controller, focusNode: fieldFocusNode),
|
||||||
|
FocusScope(node: rivalScope, child: const SizedBox.shrink()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
fieldFocusNode.requestFocus();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(tester.widget<TextField>(find.byType(TextField)).readOnly, isFalse);
|
||||||
|
|
||||||
|
// The connection closes and another scope — a sheet or dialog — becomes
|
||||||
|
// primary before the restore callback runs. `primaryFocus is FocusScopeNode`
|
||||||
|
// would be satisfied by that rival scope; only identity with the field's
|
||||||
|
// own enclosing scope may trigger a restore.
|
||||||
|
tester.testTextInput.closeConnection();
|
||||||
|
rivalScope.requestFocus();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(rivalScope.hasFocus, isTrue);
|
||||||
|
expect(fieldFocusNode.hasPrimaryFocus, isFalse, reason: 'must not steal focus from the rival scope');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Apple TV first focus opens once, dismissal and refocus stay closed, Select reopens', (tester) async {
|
||||||
|
TvDetectionService.debugSetAppleTVOverride(true);
|
||||||
|
final controller = TextEditingController();
|
||||||
|
final fieldFocusNode = FocusNode(debugLabel: 'native_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: [
|
||||||
|
// Defaulted: exercises `automatic` resolving to onFirstFocus.
|
||||||
|
FocusableTextField(controller: controller, focusNode: fieldFocusNode),
|
||||||
|
Focus(focusNode: otherFocusNode, child: const SizedBox.shrink()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
bool readOnly() => tester.widget<TextField>(find.byType(TextField)).readOnly;
|
||||||
|
List<String> drain() {
|
||||||
|
final methods = tester.testTextInput.log.map((call) => call.method).toList();
|
||||||
|
tester.testTextInput.log.clear();
|
||||||
|
return methods;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. First focus raises input once.
|
||||||
|
drain();
|
||||||
|
fieldFocusNode.requestFocus();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(readOnly(), isFalse, reason: 'first focus should open');
|
||||||
|
expect(drain().where((m) => m == 'TextInput.show'), hasLength(1));
|
||||||
|
|
||||||
|
// 2. UIKit dismissal keeps focus and does not reopen.
|
||||||
|
tester.testTextInput.closeConnection();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(fieldFocusNode.hasPrimaryFocus, isTrue, reason: 'dismissal must not strand focus');
|
||||||
|
expect(readOnly(), isTrue);
|
||||||
|
expect(drain(), isNot(contains('TextInput.show')));
|
||||||
|
|
||||||
|
// 3. Navigating away and back stays closed.
|
||||||
|
otherFocusNode.requestFocus();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
drain();
|
||||||
|
fieldFocusNode.requestFocus();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(readOnly(), isTrue, reason: 'refocus must not reopen');
|
||||||
|
expect(drain(), isNot(contains('TextInput.show')));
|
||||||
|
|
||||||
|
// 4. Select always reopens.
|
||||||
|
await _raiseNativeInput(tester);
|
||||||
|
expect(readOnly(), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('Apple TV controller closes native input without losing field focus', (tester) async {
|
testWidgets('Apple TV controller closes native input without losing field focus', (tester) async {
|
||||||
TvDetectionService.debugSetAppleTVOverride(true);
|
TvDetectionService.debugSetAppleTVOverride(true);
|
||||||
final controller = TextEditingController();
|
final controller = TextEditingController();
|
||||||
@@ -366,6 +555,7 @@ void main() {
|
|||||||
|
|
||||||
fieldFocusNode.requestFocus();
|
fieldFocusNode.requestFocus();
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
await _raiseNativeInput(tester);
|
||||||
expect(tester.widget<TextField>(find.byType(TextField)).readOnly, isFalse);
|
expect(tester.widget<TextField>(find.byType(TextField)).readOnly, isFalse);
|
||||||
|
|
||||||
textInputController.closeTextInput();
|
textInputController.closeTextInput();
|
||||||
@@ -403,6 +593,7 @@ void main() {
|
|||||||
|
|
||||||
fieldFocusNode.requestFocus();
|
fieldFocusNode.requestFocus();
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
await _raiseNativeInput(tester);
|
||||||
expect(tester.widget<TextField>(find.byType(TextField)).readOnly, isFalse);
|
expect(tester.widget<TextField>(find.byType(TextField)).readOnly, isFalse);
|
||||||
|
|
||||||
final result = fieldFocusNode.onKeyEvent!(fieldFocusNode, _remoteKey(LogicalKeyboardKey.arrowDown));
|
final result = fieldFocusNode.onKeyEvent!(fieldFocusNode, _remoteKey(LogicalKeyboardKey.arrowDown));
|
||||||
@@ -431,6 +622,7 @@ void main() {
|
|||||||
|
|
||||||
fieldFocusNode.requestFocus();
|
fieldFocusNode.requestFocus();
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
await _raiseNativeInput(tester);
|
||||||
|
|
||||||
final result = fieldFocusNode.onKeyEvent!(fieldFocusNode, _remoteKey(LogicalKeyboardKey.goBack));
|
final result = fieldFocusNode.onKeyEvent!(fieldFocusNode, _remoteKey(LogicalKeyboardKey.goBack));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
@@ -464,6 +656,9 @@ void main() {
|
|||||||
|
|
||||||
fieldFocusNode.requestFocus();
|
fieldFocusNode.requestFocus();
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
// Raise input first; the Select below is the one that reproduces a UIKit
|
||||||
|
// dismissal arriving while Flutter still believes input is active.
|
||||||
|
await _raiseNativeInput(tester);
|
||||||
|
|
||||||
final result = fieldFocusNode.onKeyEvent!(fieldFocusNode, _remoteKey(LogicalKeyboardKey.select));
|
final result = fieldFocusNode.onKeyEvent!(fieldFocusNode, _remoteKey(LogicalKeyboardKey.select));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
@@ -543,6 +738,51 @@ void main() {
|
|||||||
expect(tester.widget<TextField>(find.byType(TextField)).readOnly, isFalse);
|
expect(tester.widget<TextField>(find.byType(TextField)).readOnly, isFalse);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('Apple TV automatic auto-open fires once, then stays closed on every refocus', (tester) async {
|
||||||
|
TvDetectionService.debugSetAppleTVOverride(true);
|
||||||
|
final controller = TextEditingController();
|
||||||
|
final fieldFocusNode = FocusNode(debugLabel: 'native_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: [
|
||||||
|
// No tvTextInputAutoOpenBehavior: exercises the `automatic`
|
||||||
|
// default, which resolves to `onFirstFocus` on native tvOS.
|
||||||
|
FocusableTextField(controller: controller, focusNode: fieldFocusNode),
|
||||||
|
Focus(focusNode: otherFocusNode, child: const SizedBox.shrink()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
bool readOnly() => tester.widget<TextField>(find.byType(TextField)).readOnly;
|
||||||
|
|
||||||
|
fieldFocusNode.requestFocus();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(readOnly(), isFalse, reason: 'first focus should raise input');
|
||||||
|
|
||||||
|
// Every later entry stays closed. `onFocus` reopened on all of them, which
|
||||||
|
// is what made D-pad traversal of a form unusable; `afterFirstFocus` would
|
||||||
|
// also reopen from entry 2 onwards.
|
||||||
|
for (var entry = 2; entry <= 4; entry++) {
|
||||||
|
otherFocusNode.requestFocus();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
fieldFocusNode.requestFocus();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(readOnly(), isTrue, reason: 'focus entry $entry must not raise the system keyboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
await _raiseNativeInput(tester);
|
||||||
|
expect(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);
|
||||||
@@ -1300,6 +1540,14 @@ Finder _tvKeyboardDoneKey(IconData icon) {
|
|||||||
return find.descendant(of: find.byKey(const Key('tv_virtual_keyboard_panel')), matching: find.byIcon(icon));
|
return find.descendant(of: find.byKey(const Key('tv_virtual_keyboard_panel')), matching: find.byIcon(icon));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Native tvOS input no longer auto-opens on focus — `automatic` resolves to
|
||||||
|
/// `never` there, because the system keyboard is modal and would make D-pad
|
||||||
|
/// traversal of a form impossible. An explicit Select raises it, as on device.
|
||||||
|
Future<void> _raiseNativeInput(WidgetTester tester) async {
|
||||||
|
await tester.sendKeyEvent(LogicalKeyboardKey.select);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
|
|
||||||
KeyDownEvent _remoteKey(LogicalKeyboardKey key) {
|
KeyDownEvent _remoteKey(LogicalKeyboardKey key) {
|
||||||
return KeyDownEvent(
|
return KeyDownEvent(
|
||||||
physicalKey: _physicalKeyFor(key),
|
physicalKey: _physicalKeyFor(key),
|
||||||
|
|||||||
Reference in New Issue
Block a user