From 71735354b97b0812b865ff6f15bad3501a3df567 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 26 Jul 2026 06:02:43 +0200 Subject: [PATCH] fix(tvos): unify remote and text input ownership --- lib/focus/focusable_text_field.dart | 330 ++++++++++++++---- lib/screens/catalog_search_screen.dart | 1 + lib/screens/search_screen.dart | 9 +- lib/screens/settings/add_jellyfin_screen.dart | 3 +- .../edit_jellyfin_connection_screen.dart | 1 + lib/screens/settings/mpv_config_screen.dart | 1 + .../settings/seerr_connect_screen.dart | 2 +- lib/utils/dialogs.dart | 3 + lib/widgets/media_context_menu.dart | 3 +- .../sheets/subtitle_search_sheet.dart | 2 + test/focus/focus_node_ownership_test.dart | 6 +- test/focus/focusable_text_field_test.dart | 25 +- test/widgets/focusable_text_field_test.dart | 240 ++++++++++++- tvos/Runner.xcodeproj/project.pbxproj | 4 + .../FlutterNativeTextInputTests.mm | 114 ++++++ tvos/engine.sha256 | 2 +- tvos/engine.version | 2 +- 17 files changed, 651 insertions(+), 97 deletions(-) create mode 100644 tvos/RunnerTests/FlutterNativeTextInputTests.mm diff --git a/lib/focus/focusable_text_field.dart b/lib/focus/focusable_text_field.dart index f2f7ecd4..77111b7e 100644 --- a/lib/focus/focusable_text_field.dart +++ b/lib/focus/focusable_text_field.dart @@ -11,30 +11,50 @@ import 'dpad_navigator.dart'; import 'key_event_utils.dart'; import 'owned_focus_node_binding.dart'; -bool _usesTvKeyboard(bool enableTvKeyboard) => enableTvKeyboard && PlatformDetector.isTV(); +enum TvTextInputPresentation { + /// Use the native platform keyboard for single-line Apple TV input and the + /// Flutter overlay on other TVs or for multiline input. + automatic, + + /// Always use the platform text input implementation. + platform, + + /// Always use Plezy's in-app Flutter keyboard overlay. + flutterOverlay, +} + +bool _usesTvKeyboard({required TvTextInputPresentation presentation, TextInputType? keyboardType, int? maxLines}) { + if (!PlatformDetector.isTV()) return false; + return switch (presentation) { + TvTextInputPresentation.automatic => + !PlatformDetector.isAppleTV() || _isMultilineTextInput(keyboardType: keyboardType, maxLines: maxLines), + TvTextInputPresentation.platform => false, + TvTextInputPresentation.flutterOverlay => true, + }; +} String? _keyboardHint(InputDecoration? decoration) => decoration?.hintText ?? decoration?.labelText; -enum TvKeyboardAutoOpenBehavior { - /// Open the TV virtual keyboard whenever the field receives focus. +enum TvTextInputAutoOpenBehavior { + /// Open the selected TV text input presentation whenever the field receives + /// focus. onFocus, - /// Keep initial focus on the field without opening the keyboard, then open + /// Keep initial focus on the field without opening text input, then open it /// automatically on later focus entries. Explicit tap/select still opens it. afterFirstFocus, - /// Never auto-open the TV virtual keyboard on focus. Explicit tap/select - /// still opens it. + /// Never auto-open text input on focus. Explicit tap/select still opens it. never, } -/// Imperative handle to the TV on-screen keyboard of a [FocusableTextField] / +/// Imperative handle to TV text input for a [FocusableTextField] / /// [FocusableTextFormField]. Pass the same instance to the field's -/// `tvKeyboardController`; the field's host attaches itself on mount. +/// `tvTextInputController`; the field's host attaches itself on mount. /// -/// Only meaningful on TV. On other platforms the OSK never exists, so every -/// method is an effective no-op. -class TvKeyboardController { +/// Only meaningful on TV. On other platforms every method is an effective +/// no-op. +class TvTextInputController { _FocusableTextInputHostState? _host; void _attach(_FocusableTextInputHostState host) => _host = host; @@ -42,14 +62,13 @@ class TvKeyboardController { if (identical(_host, host)) _host = null; } - /// Dismiss the OSK if it is open (and prevent it from auto-reopening while - /// the field keeps focus). No-op when nothing is open. - void closeKeyboard() => _host?._dismissTvKeyboard(); + /// Dismiss the Flutter overlay if it is open and prevent it from reopening + /// while the field keeps focus. + void closeTextInput() => _host?._dismissTvKeyboard(); - /// Focus the field but do NOT open the OSK for this focus entry. Used as a - /// fallback landing spot (e.g. a remote search that returned no results) so - /// the remote isn't stranded on an off-screen element. - void focusInputWithoutKeyboard() => _host?._focusWithoutKeyboard(); + /// Focus the field without opening either native or Flutter text input for + /// this focus entry. + void focusInputWithoutOpening() => _host?._focusWithoutKeyboard(); } String _describeTextInputKey(KeyEvent event) { @@ -117,6 +136,8 @@ KeyEventResult _handleInputKey({ required bool usesTvKeyboard, required bool enabled, required VoidCallback openKeyboard, + required bool activateNativeTextInput, + required VoidCallback activateNativeTextInputCallback, required KeyEvent event, TextInputType? keyboardType, TextInputAction? textInputAction, @@ -154,7 +175,17 @@ KeyEventResult _handleInputKey({ ); } - if (_shouldPassNativeTvKeyToPlatform(usesTvKeyboard: usesTvKeyboard, enabled: enabled, event: event)) { + if (activateNativeTextInput && enabled && event.isTvSelectEvent) { + if (event is KeyDownEvent) activateNativeTextInputCallback(); + return finish(KeyEventResult.handled, 'activate-native-tv-text-input'); + } + + if (_shouldPassNativeTvKeyToPlatform( + usesTvKeyboard: usesTvKeyboard, + nativeTextInputActive: !activateNativeTextInput, + enabled: enabled, + event: event, + )) { return finish(KeyEventResult.skipRemainingHandlers, 'pass-native-tv-key-to-platform'); } @@ -247,11 +278,17 @@ KeyEventResult _handleInputKey({ return finish(KeyEventResult.ignored, 'fall-through'); } -bool _shouldPassNativeTvKeyToPlatform({required bool usesTvKeyboard, required bool enabled, required KeyEvent event}) { - if (!enabled || usesTvKeyboard || !PlatformDetector.isTV()) { +bool _shouldPassNativeTvKeyToPlatform({ + required bool usesTvKeyboard, + required bool nativeTextInputActive, + required bool enabled, + required KeyEvent event, +}) { + if (!enabled || usesTvKeyboard || !nativeTextInputActive || !PlatformDetector.isTV()) { if (TextInputDiagnostics.enabled) { _logTvTextInput( - 'native-pass=false reason=disabled-or-custom-keyboard enabled=$enabled usesTvKeyboard=$usesTvKeyboard ' + 'native-pass=false reason=inactive-disabled-or-custom enabled=$enabled ' + 'usesTvKeyboard=$usesTvKeyboard nativeTextInputActive=$nativeTextInputActive ' 'isTv=${PlatformDetector.isTV()} key=(${_describeTextInputKey(event)})', ); } @@ -546,9 +583,9 @@ abstract class _FocusableTextInputBase extends StatelessWidget { final VoidCallback? onBack; final bool autofocus; final bool enabled; - final bool enableTvKeyboard; - final TvKeyboardAutoOpenBehavior tvKeyboardAutoOpenBehavior; - final TvKeyboardController? tvKeyboardController; + final TvTextInputPresentation tvTextInputPresentation; + final TvTextInputAutoOpenBehavior tvTextInputAutoOpenBehavior; + final TvTextInputController? tvTextInputController; final bool obscureText; final bool autocorrect; final bool enableSuggestions; @@ -580,9 +617,9 @@ abstract class _FocusableTextInputBase extends StatelessWidget { this.onBack, this.autofocus = false, this.enabled = true, - this.enableTvKeyboard = true, - this.tvKeyboardAutoOpenBehavior = TvKeyboardAutoOpenBehavior.onFocus, - this.tvKeyboardController, + this.tvTextInputPresentation = TvTextInputPresentation.automatic, + this.tvTextInputAutoOpenBehavior = TvTextInputAutoOpenBehavior.onFocus, + this.tvTextInputController, this.obscureText = false, this.autocorrect = true, this.enableSuggestions = true, @@ -599,7 +636,8 @@ abstract class _FocusableTextInputBase extends StatelessWidget { this.onNavigateDown, }); - bool get _hasTvKeyboard => _usesTvKeyboard(enableTvKeyboard); + bool get _hasTvKeyboard => + _usesTvKeyboard(presentation: tvTextInputPresentation, keyboardType: keyboardType, maxLines: maxLines); bool get _usesNativeTvKeyboard => PlatformDetector.isTV() && !_hasTvKeyboard; VoidCallback? get _effectiveOnEditingComplete { @@ -627,23 +665,41 @@ abstract class _FocusableTextInputBase extends StatelessWidget { bool? enableInteractiveSelection, VoidCallback? onTap, }) - _tvInputConfiguration(bool usesTvKeyboard, VoidCallback openKeyboard) { + _tvInputConfiguration({ + required bool usesTvKeyboard, + required bool nativeTextInputReadOnly, + required VoidCallback openKeyboard, + required VoidCallback activateNativeTextInput, + }) { return ( keyboardType: usesTvKeyboard ? TextInputType.none : keyboardType, - readOnly: usesTvKeyboard, - showCursor: usesTvKeyboard ? true : null, + readOnly: usesTvKeyboard || nativeTextInputReadOnly, + showCursor: usesTvKeyboard || nativeTextInputReadOnly ? true : null, enableInteractiveSelection: usesTvKeyboard ? false : enableInteractiveSelection, - onTap: usesTvKeyboard ? openKeyboard : null, + onTap: usesTvKeyboard + ? openKeyboard + : nativeTextInputReadOnly + ? activateNativeTextInput + : null, ); } - KeyEventResult _handleKey(BuildContext context, FocusNode node, KeyEvent event, VoidCallback openKeyboard) { + KeyEventResult _handleKey( + BuildContext context, + FocusNode node, + KeyEvent event, + VoidCallback openKeyboard, { + required bool activateNativeTextInput, + required VoidCallback activateNativeTextInputCallback, + }) { return _handleInputKey( controller: controller, node: node, usesTvKeyboard: _hasTvKeyboard, enabled: enabled, openKeyboard: openKeyboard, + activateNativeTextInput: activateNativeTextInput, + activateNativeTextInputCallback: activateNativeTextInputCallback, event: event, keyboardType: keyboardType, textInputAction: textInputAction, @@ -662,16 +718,23 @@ abstract class _FocusableTextInputBase extends StatelessWidget { ); } - Widget buildFocusableInput( - Widget Function(bool usesTvKeyboard, FocusNode focusNode, VoidCallback openKeyboard) builder, - ) { + Widget buildFocusableInput(_FocusableTextInputBuilder builder) { return _FocusableTextInputHost(input: this, builder: builder); } } +typedef _FocusableTextInputBuilder = + Widget Function({ + required bool usesTvKeyboard, + required bool nativeTextInputReadOnly, + required FocusNode focusNode, + required VoidCallback openKeyboard, + required VoidCallback activateNativeTextInput, + }); + class _FocusableTextInputHost extends StatefulWidget { final _FocusableTextInputBase input; - final Widget Function(bool usesTvKeyboard, FocusNode focusNode, VoidCallback openKeyboard) builder; + final _FocusableTextInputBuilder builder; const _FocusableTextInputHost({required this.input, required this.builder}); @@ -693,6 +756,9 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> { bool _suppressTvKeyboardAutoOpen = false; bool _hasSeenTvKeyboardFocus = false; bool _suppressTvKeyboardForCurrentFocus = false; + bool _nativeTextInputActivated = false; + bool _hasSeenNativeTextInputFocus = false; + bool _suppressNativeTextInputForCurrentFocus = false; FocusNode get _effectiveFocusNode => _focusNodeBinding.node; @@ -700,15 +766,15 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> { void initState() { super.initState(); _focusNodeBinding.bind(externalNode: widget.input.focusNode, debugLabel: 'FocusableTextInput'); - widget.input.tvKeyboardController?._attach(this); + widget.input.tvTextInputController?._attach(this); } @override void didUpdateWidget(_FocusableTextInputHost oldWidget) { super.didUpdateWidget(oldWidget); - if (!identical(oldWidget.input.tvKeyboardController, widget.input.tvKeyboardController)) { - oldWidget.input.tvKeyboardController?._detach(this); - widget.input.tvKeyboardController?._attach(this); + if (!identical(oldWidget.input.tvTextInputController, widget.input.tvTextInputController)) { + oldWidget.input.tvTextInputController?._detach(this); + widget.input.tvTextInputController?._attach(this); } if (oldWidget.input.focusNode != widget.input.focusNode) { // An open keyboard dialog intentionally survives rebuilds and focusNode @@ -719,13 +785,24 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> { _tvKeyboardOpenScheduled = false; _hasSeenTvKeyboardFocus = false; _suppressTvKeyboardForCurrentFocus = false; + _nativeTextInputActivated = false; + _hasSeenNativeTextInputFocus = false; + _suppressNativeTextInputForCurrentFocus = false; } - _handleFocusChanged(); + if (oldWidget.input.tvTextInputPresentation != widget.input.tvTextInputPresentation || + oldWidget.input.tvTextInputAutoOpenBehavior != widget.input.tvTextInputAutoOpenBehavior) { + _nativeTextInputActivated = false; + _hasSeenNativeTextInputFocus = false; + _suppressNativeTextInputForCurrentFocus = false; + } + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _handleFocusChanged(); + }); } @override void dispose() { - widget.input.tvKeyboardController?._detach(this); + widget.input.tvTextInputController?._detach(this); _restoreInstalledHandler(); // The keyboard is a navigator route — it must not outlive the field that // opened it (e.g. a form section swapped out while the keyboard is up). @@ -739,10 +816,53 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> { } void _handleFocusChanged() { + _syncNativeTextInputActivation(); _syncNativeTextInputFocus(); _syncTvKeyboardAutoOpen(); } + void _syncNativeTextInputActivation() { + final input = widget.input; + final focused = _installedFocusNode?.hasFocus == true && input.enabled && input._usesNativeTvKeyboard; + if (!focused) { + _suppressNativeTextInputForCurrentFocus = false; + _setNativeTextInputActivated(false); + return; + } + if (_suppressNativeTextInputForCurrentFocus) return; + + switch (input.tvTextInputAutoOpenBehavior) { + case TvTextInputAutoOpenBehavior.onFocus: + _hasSeenNativeTextInputFocus = true; + _setNativeTextInputActivated(true); + case TvTextInputAutoOpenBehavior.afterFirstFocus: + if (!_hasSeenNativeTextInputFocus) { + _hasSeenNativeTextInputFocus = true; + _suppressNativeTextInputForCurrentFocus = true; + return; + } + _setNativeTextInputActivated(true); + case TvTextInputAutoOpenBehavior.never: + return; + } + } + + void _setNativeTextInputActivated(bool activated) { + if (_nativeTextInputActivated == activated) return; + if (!mounted) { + _nativeTextInputActivated = activated; + return; + } + setState(() => _nativeTextInputActivated = activated); + } + + void _activateNativeTextInput() { + if (!widget.input.enabled || !widget.input._usesNativeTvKeyboard) return; + _hasSeenNativeTextInputFocus = true; + _suppressNativeTextInputForCurrentFocus = false; + _setNativeTextInputActivated(true); + } + void _syncNativeTextInputFocus() { final focused = _installedFocusNode?.hasFocus == true && widget.input.enabled && widget.input._usesNativeTvKeyboard; if (TextInputDiagnostics.enabled) { @@ -761,7 +881,7 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> { if (TextInputDiagnostics.enabled) { _logTvTextInput( 'Host.syncTvKeyboardAutoOpen focused=$focused open=$_tvKeyboardOpen scheduled=$_tvKeyboardOpenScheduled ' - 'suppressed=$_suppressTvKeyboardAutoOpen behavior=${widget.input.tvKeyboardAutoOpenBehavior} ' + 'suppressed=$_suppressTvKeyboardAutoOpen behavior=${widget.input.tvTextInputAutoOpenBehavior} ' 'seenFocus=$_hasSeenTvKeyboardFocus suppressCurrent=$_suppressTvKeyboardForCurrentFocus ' 'installed=${_installedFocusNode?.debugLabel} ' 'hasFocus=${_installedFocusNode?.hasFocus} enabled=${widget.input.enabled} ' @@ -797,17 +917,17 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> { } bool _shouldAutoOpenTvKeyboardForCurrentFocus() { - switch (widget.input.tvKeyboardAutoOpenBehavior) { - case TvKeyboardAutoOpenBehavior.onFocus: + switch (widget.input.tvTextInputAutoOpenBehavior) { + case TvTextInputAutoOpenBehavior.onFocus: return true; - case TvKeyboardAutoOpenBehavior.afterFirstFocus: + case TvTextInputAutoOpenBehavior.afterFirstFocus: if (!_hasSeenTvKeyboardFocus) { _hasSeenTvKeyboardFocus = true; _suppressTvKeyboardForCurrentFocus = true; return false; } return !_suppressTvKeyboardForCurrentFocus; - case TvKeyboardAutoOpenBehavior.never: + case TvTextInputAutoOpenBehavior.never: return false; } } @@ -886,19 +1006,22 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> { _tvKeyboardHandle?.close(); } - /// Focus the field without opening the OSK for this focus entry. Suppress is - /// set BEFORE requestFocus so the resulting focus-change sync already sees it; - /// the flag persists for this focus and resets on the next unfocus. If a - /// same-turn focus request supersedes this one, clear the unused suppression - /// after Flutter resolves its pending focus change. + /// Focus the field without opening either native or Flutter text input for + /// this focus entry. Suppression is set before requestFocus so the resulting + /// focus-change callback cannot open either presentation. void _focusWithoutKeyboard() { _suppressTvKeyboardAutoOpen = true; + _suppressNativeTextInputForCurrentFocus = true; _tvKeyboardOpenScheduled = false; + if (widget.input._usesNativeTvKeyboard) { + _setNativeTextInputActivated(false); + } final focusNode = _installedFocusNode ?? _effectiveFocusNode; focusNode.requestFocus(); scheduleMicrotask(() { if (!mounted || focusNode.hasFocus || _tvKeyboardOpen || _tvKeyboardOpenScheduled) return; _suppressTvKeyboardAutoOpen = false; + _suppressNativeTextInputForCurrentFocus = false; }); } @@ -921,7 +1044,41 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> { ); if (result != KeyEventResult.ignored) return result; } - return widget.input._handleKey(context, node, event, _openTvKeyboard); + var activateNativeTextInput = widget.input._usesNativeTvKeyboard && !_nativeTextInputActivated; + final isRemoteNavigation = event.logicalKey.isDpadDirection || event.logicalKey.isBackKey || event.isTvSelectEvent; + if (PlatformDetector.isAppleTV() && + widget.input._usesNativeTvKeyboard && + _nativeTextInputActivated && + event is KeyDownEvent && + isRemoteNavigation) { + // Remote navigation events are system-owned while the native keyboard + // is active. Receiving one here proves that UIKit has dismissed the + // keyboard while Flutter focus stayed on the field. Restore the + // read-only gate so this press navigates Flutter instead of reopening + // the input connection. + _suppressNativeTextInputForCurrentFocus = true; + _setNativeTextInputActivated(false); + activateNativeTextInput = true; + if (event.logicalKey.isBackKey) { + // This is the Menu press that dismissed UIKit's keyboard. Consume its + // Flutter continuation so one press cannot also pop the app route. + return KeyEventResult.handled; + } + if (event.isTvSelectEvent) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _activateNativeTextInput(); + }); + return KeyEventResult.handled; + } + } + return widget.input._handleKey( + context, + node, + event, + _openTvKeyboard, + activateNativeTextInput: activateNativeTextInput, + activateNativeTextInputCallback: _activateNativeTextInput, + ); } void _installKeyHandler(FocusNode node) { @@ -946,6 +1103,8 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> { void _restoreInstalledHandler() { _logTvTextInput('Host.restoreInstalledHandler node=${_installedFocusNode?.debugLabel}'); _setNativeTextInputFocused(false); + _nativeTextInputActivated = false; + _suppressNativeTextInputForCurrentFocus = false; final node = _installedFocusNode; if (node != null) { node.removeListener(_focusListener); @@ -961,8 +1120,19 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> { Widget build(BuildContext context) { final focusNode = _effectiveFocusNode; _installKeyHandler(focusNode); - _handleFocusChanged(); - return widget.builder(widget.input._hasTvKeyboard, focusNode, _openTvKeyboard); + _syncNativeTextInputFocus(); + _syncTvKeyboardAutoOpen(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _handleFocusChanged(); + }); + final usesTvKeyboard = widget.input._hasTvKeyboard; + return widget.builder( + usesTvKeyboard: usesTvKeyboard, + nativeTextInputReadOnly: widget.input._usesNativeTvKeyboard && !_nativeTextInputActivated, + focusNode: focusNode, + openKeyboard: _openTvKeyboard, + activateNativeTextInput: _activateNativeTextInput, + ); } } @@ -989,9 +1159,9 @@ class FocusableTextField extends _FocusableTextInputBase { super.onBack, super.autofocus, super.enabled, - super.enableTvKeyboard, - super.tvKeyboardAutoOpenBehavior, - super.tvKeyboardController, + super.tvTextInputPresentation, + super.tvTextInputAutoOpenBehavior, + super.tvTextInputController, super.obscureText, super.autocorrect, super.enableSuggestions, @@ -1010,11 +1180,22 @@ class FocusableTextField extends _FocusableTextInputBase { @override Widget build(BuildContext context) { - return buildFocusableInput((usesTvKeyboard, effectiveFocusNode, openKeyboard) { - final tvInput = _tvInputConfiguration(usesTvKeyboard, openKeyboard); + return buildFocusableInput(({ + required bool usesTvKeyboard, + required bool nativeTextInputReadOnly, + required FocusNode focusNode, + required VoidCallback openKeyboard, + required VoidCallback activateNativeTextInput, + }) { + final tvInput = _tvInputConfiguration( + usesTvKeyboard: usesTvKeyboard, + nativeTextInputReadOnly: nativeTextInputReadOnly, + openKeyboard: openKeyboard, + activateNativeTextInput: activateNativeTextInput, + ); return TextField( controller: controller, - focusNode: effectiveFocusNode, + focusNode: focusNode, enabled: enabled, decoration: decoration, keyboardType: tvInput.keyboardType, @@ -1066,9 +1247,9 @@ class FocusableTextFormField extends _FocusableTextInputBase { this.onSaved, super.autofocus, super.enabled, - super.enableTvKeyboard, - super.tvKeyboardAutoOpenBehavior, - super.tvKeyboardController, + super.tvTextInputPresentation, + super.tvTextInputAutoOpenBehavior, + super.tvTextInputController, super.obscureText, super.autocorrect, super.enableSuggestions, @@ -1087,11 +1268,22 @@ class FocusableTextFormField extends _FocusableTextInputBase { @override Widget build(BuildContext context) { - return buildFocusableInput((usesTvKeyboard, effectiveFocusNode, openKeyboard) { - final tvInput = _tvInputConfiguration(usesTvKeyboard, openKeyboard); + return buildFocusableInput(({ + required bool usesTvKeyboard, + required bool nativeTextInputReadOnly, + required FocusNode focusNode, + required VoidCallback openKeyboard, + required VoidCallback activateNativeTextInput, + }) { + final tvInput = _tvInputConfiguration( + usesTvKeyboard: usesTvKeyboard, + nativeTextInputReadOnly: nativeTextInputReadOnly, + openKeyboard: openKeyboard, + activateNativeTextInput: activateNativeTextInput, + ); return TextFormField( controller: controller, - focusNode: effectiveFocusNode, + focusNode: focusNode, enabled: enabled, decoration: decoration, keyboardType: tvInput.keyboardType, diff --git a/lib/screens/catalog_search_screen.dart b/lib/screens/catalog_search_screen.dart index 072801a1..0747522d 100644 --- a/lib/screens/catalog_search_screen.dart +++ b/lib/screens/catalog_search_screen.dart @@ -72,6 +72,7 @@ class _CatalogSearchScreenState extends State with Debounce FocusableTextField( controller: searchController, focusNode: searchFocusNode, + tvTextInputPresentation: TvTextInputPresentation.flutterOverlay, textInputAction: TextInputAction.search, onNavigateDown: searchResults.isNotEmpty && !isSearching ? firstResultFocusNode.requestFocus : null, onNavigateRight: searchController.text.isNotEmpty ? _clearFocusNode.requestFocus : null, diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index 99f5875b..a6295c5c 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -33,7 +33,7 @@ class SearchScreen extends StatefulWidget { class _SearchScreenState extends State with Refreshable, FullRefreshable, SearchInputFocusable, FocusableTab, MountedSetStateMixin, DebouncedMediaSearch { String? _focusResultsForQuery; - final _tvKeyboardController = TvKeyboardController(); + final _tvTextInputController = TvTextInputController(); final _clearFocusNode = FocusNode(debugLabel: 'Search.clear'); @override @@ -130,13 +130,13 @@ class _SearchScreenState extends State // Focusing the field normally auto-opens the OSK; a remote search must not // show it, and must dismiss one the TV user already had open (the phone's // Search chip sends tabSearch before the query arrives). - _tvKeyboardController.closeKeyboard(); + _tvTextInputController.closeTextInput(); if (trimmed.isEmpty) return; // Land focus on the (visible) input immediately so the D-pad remote is // never stranded on the hidden previous tab — while the search is in // flight, when it fails, and when it returns nothing. - _tvKeyboardController.focusInputWithoutKeyboard(); + _tvTextInputController.focusInputWithoutOpening(); // Same path as the OSK Search key: jumps straight to already-matching // results, or cancels the debounce and runs now; the screen override arms @@ -225,7 +225,8 @@ class _SearchScreenState extends State FocusableTextField( controller: searchController, focusNode: searchFocusNode, - tvKeyboardController: _tvKeyboardController, + tvTextInputController: _tvTextInputController, + tvTextInputPresentation: TvTextInputPresentation.flutterOverlay, textInputAction: TextInputAction.search, onNavigateLeft: _navigateToSidebar, onNavigateRight: searchController.text.isNotEmpty ? _clearFocusNode.requestFocus : null, diff --git a/lib/screens/settings/add_jellyfin_screen.dart b/lib/screens/settings/add_jellyfin_screen.dart index d0a0f493..44922f52 100644 --- a/lib/screens/settings/add_jellyfin_screen.dart +++ b/lib/screens/settings/add_jellyfin_screen.dart @@ -487,8 +487,9 @@ class _AddJellyfinScreenState extends State with AsyncFormSta FocusableTextFormField( controller: _urlController, focusNode: _urlFocus, + tvTextInputPresentation: TvTextInputPresentation.flutterOverlay, autofocus: true, - tvKeyboardAutoOpenBehavior: TvKeyboardAutoOpenBehavior.afterFirstFocus, + tvTextInputAutoOpenBehavior: TvTextInputAutoOpenBehavior.afterFirstFocus, keyboardType: TextInputType.url, minLines: 1, maxLines: 4, diff --git a/lib/screens/settings/edit_jellyfin_connection_screen.dart b/lib/screens/settings/edit_jellyfin_connection_screen.dart index 7cb961fb..511baef6 100644 --- a/lib/screens/settings/edit_jellyfin_connection_screen.dart +++ b/lib/screens/settings/edit_jellyfin_connection_screen.dart @@ -99,6 +99,7 @@ class _EditJellyfinConnectionScreenState extends State with SettingsEffectMi controller: _textController, focusNode: _textFieldFocusNode, keyboardType: TextInputType.multiline, + tvTextInputPresentation: TvTextInputPresentation.flutterOverlay, maxLines: null, minLines: 12, decoration: InputDecoration( diff --git a/lib/screens/settings/seerr_connect_screen.dart b/lib/screens/settings/seerr_connect_screen.dart index 68059483..63c223b2 100644 --- a/lib/screens/settings/seerr_connect_screen.dart +++ b/lib/screens/settings/seerr_connect_screen.dart @@ -184,7 +184,7 @@ class _SeerrConnectScreenState extends State with AsyncFormS controller: _urlController, focusNode: _urlFocus, autofocus: true, - tvKeyboardAutoOpenBehavior: TvKeyboardAutoOpenBehavior.afterFirstFocus, + tvTextInputAutoOpenBehavior: TvTextInputAutoOpenBehavior.afterFirstFocus, keyboardType: TextInputType.url, autocorrect: false, enableSuggestions: false, diff --git a/lib/utils/dialogs.dart b/lib/utils/dialogs.dart index e5795f99..2eae5dc9 100644 --- a/lib/utils/dialogs.dart +++ b/lib/utils/dialogs.dart @@ -257,6 +257,9 @@ class _TextInputDialogState extends State<_TextInputDialog> with ControllerDispo controller: _controller, focusNode: _fieldFocusNode, autofocus: true, + tvTextInputPresentation: widget.multiline + ? TvTextInputPresentation.flutterOverlay + : TvTextInputPresentation.automatic, decoration: InputDecoration(labelText: widget.labelText, hintText: widget.hintText, errorText: _errorText), keyboardType: widget.keyboardType ?? (widget.multiline ? TextInputType.multiline : null), inputFormatters: widget.inputFormatters, diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index 1a92c4c2..3923beb1 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -1905,7 +1905,8 @@ class _PickerDialogScaffoldState extends State<_PickerDialogScaffold> { FocusableTextField( controller: _filterController, focusNode: _filterFocusNode, - tvKeyboardAutoOpenBehavior: TvKeyboardAutoOpenBehavior.afterFirstFocus, + tvTextInputPresentation: TvTextInputPresentation.flutterOverlay, + tvTextInputAutoOpenBehavior: TvTextInputAutoOpenBehavior.afterFirstFocus, onNavigateDown: _firstItemFocusNode.requestFocus, decoration: pillInputDecoration( context, diff --git a/lib/widgets/video_controls/sheets/subtitle_search_sheet.dart b/lib/widgets/video_controls/sheets/subtitle_search_sheet.dart index ec96d8be..18479e41 100644 --- a/lib/widgets/video_controls/sheets/subtitle_search_sheet.dart +++ b/lib/widgets/video_controls/sheets/subtitle_search_sheet.dart @@ -284,6 +284,7 @@ class _SubtitleSearchSheetState extends State with Controll child: FocusableTextField( controller: _titleController, focusNode: _titleFocusNode, + tvTextInputPresentation: TvTextInputPresentation.flutterOverlay, decoration: pillInputDecoration( context, hintText: widget.mediaTitle ?? t.metadataEdit.title, @@ -451,6 +452,7 @@ class _LanguagePickerViewState extends State<_LanguagePickerView> with Controlle child: FocusableTextField( controller: _filterController, focusNode: _filterFocusNode, + tvTextInputPresentation: TvTextInputPresentation.flutterOverlay, autofocus: true, decoration: pillInputDecoration( context, diff --git a/test/focus/focus_node_ownership_test.dart b/test/focus/focus_node_ownership_test.dart index b679bc08..4d101ffe 100644 --- a/test/focus/focus_node_ownership_test.dart +++ b/test/focus/focus_node_ownership_test.dart @@ -74,7 +74,11 @@ void main() { body: StatefulBuilder( builder: (context, setState) { rebuild = setState; - return FocusableTextField(controller: controller, focusNode: node, enableTvKeyboard: false); + return FocusableTextField( + controller: controller, + focusNode: node, + tvTextInputPresentation: TvTextInputPresentation.platform, + ); }, ), ), diff --git a/test/focus/focusable_text_field_test.dart b/test/focus/focusable_text_field_test.dart index 0c99a559..44136cbc 100644 --- a/test/focus/focusable_text_field_test.dart +++ b/test/focus/focusable_text_field_test.dart @@ -19,8 +19,16 @@ void main() { home: Scaffold( body: Column( children: [ - FocusableTextField(controller: c1, focusNode: first, enableTvKeyboard: false), - FocusableTextField(controller: c2, focusNode: second, enableTvKeyboard: false), + FocusableTextField( + controller: c1, + focusNode: first, + tvTextInputPresentation: TvTextInputPresentation.platform, + ), + FocusableTextField( + controller: c2, + focusNode: second, + tvTextInputPresentation: TvTextInputPresentation.platform, + ), ], ), ), @@ -56,8 +64,17 @@ void main() { home: Scaffold( body: Column( children: [ - FocusableTextField(controller: c1, focusNode: first, maxLines: 4, enableTvKeyboard: false), - FocusableTextField(controller: c2, focusNode: second, enableTvKeyboard: false), + FocusableTextField( + controller: c1, + focusNode: first, + maxLines: 4, + tvTextInputPresentation: TvTextInputPresentation.platform, + ), + FocusableTextField( + controller: c2, + focusNode: second, + tvTextInputPresentation: TvTextInputPresentation.platform, + ), ], ), ), diff --git a/test/widgets/focusable_text_field_test.dart b/test/widgets/focusable_text_field_test.dart index 0346ab26..256731a4 100644 --- a/test/widgets/focusable_text_field_test.dart +++ b/test/widgets/focusable_text_field_test.dart @@ -149,7 +149,11 @@ void main() { home: Scaffold( body: TickerMode( enabled: visible, - child: FocusableTextField(controller: controller, focusNode: fieldFocusNode), + child: FocusableTextField( + controller: controller, + focusNode: fieldFocusNode, + tvTextInputPresentation: TvTextInputPresentation.flutterOverlay, + ), ), ), ), @@ -181,7 +185,11 @@ void main() { MaterialApp( home: Scaffold( body: present - ? FocusableTextField(controller: controller, focusNode: fieldFocusNode) + ? FocusableTextField( + controller: controller, + focusNode: fieldFocusNode, + tvTextInputPresentation: TvTextInputPresentation.flutterOverlay, + ) : const SizedBox.shrink(), ), ), @@ -211,7 +219,11 @@ void main() { await tester.pumpWidget( MaterialApp( home: Scaffold( - body: FocusableTextField(controller: controller, focusNode: fieldFocusNode), + body: FocusableTextField( + controller: controller, + focusNode: fieldFocusNode, + tvTextInputPresentation: TvTextInputPresentation.flutterOverlay, + ), ), ), ); @@ -227,6 +239,196 @@ void main() { expect(find.byType(Dialog), findsNothing); }); + testWidgets('Apple TV automatic single-line input uses the platform field', (tester) async { + TvDetectionService.debugSetAppleTVOverride(true); + final controller = TextEditingController(); + final fieldFocusNode = FocusNode(debugLabel: 'native_name_field'); + addTearDown(controller.dispose); + addTearDown(fieldFocusNode.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: FocusableTextField(controller: controller, focusNode: fieldFocusNode), + ), + ), + ); + + fieldFocusNode.requestFocus(); + await tester.pumpAndSettle(); + + expect(tester.widget(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(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(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(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(find.byType(TextField)).readOnly, isTrue); + + await tester.pump(); + expect(tester.widget(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(find.byType(TextField)).readOnly, isTrue); + + await tester.sendKeyEvent(LogicalKeyboardKey.select); + await tester.pumpAndSettle(); + + expect(tester.widget(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(find.byType(TextField)).readOnly, isTrue); + + otherFocusNode.requestFocus(); + await tester.pumpAndSettle(); + fieldFocusNode.requestFocus(); + await tester.pumpAndSettle(); + + expect(tester.widget(find.byType(TextField)).readOnly, isFalse); + }); + testWidgets('Android TV native keyboard done uses D-pad navigation', (tester) async { TvDetectionService.debugSetAppleTVOverride(null); await TvDetectionService.getInstance(forceTv: true); @@ -246,7 +448,7 @@ void main() { FocusableTextField( controller: controller, focusNode: fieldFocusNode, - enableTvKeyboard: false, + tvTextInputPresentation: TvTextInputPresentation.platform, textInputAction: TextInputAction.done, onNavigateDown: nextFocusNode.requestFocus, ), @@ -311,7 +513,7 @@ void main() { FocusableTextFormField( controller: controller, focusNode: fieldFocusNode, - tvKeyboardAutoOpenBehavior: TvKeyboardAutoOpenBehavior.afterFirstFocus, + tvTextInputAutoOpenBehavior: TvTextInputAutoOpenBehavior.afterFirstFocus, ), Focus(focusNode: otherFocusNode, child: const SizedBox(width: 1, height: 1)), ], @@ -353,7 +555,7 @@ void main() { body: FocusableTextFormField( controller: controller, focusNode: fieldFocusNode, - tvKeyboardAutoOpenBehavior: TvKeyboardAutoOpenBehavior.afterFirstFocus, + tvTextInputAutoOpenBehavior: TvTextInputAutoOpenBehavior.afterFirstFocus, ), ), ), @@ -392,7 +594,7 @@ void main() { FocusableTextFormField( controller: controller, focusNode: fieldFocusNode, - enableTvKeyboard: false, + tvTextInputPresentation: TvTextInputPresentation.platform, onNavigateDown: nextFocusNode.requestFocus, onSelect: () => selects++, onBack: () => backs++, @@ -467,7 +669,11 @@ void main() { home: Scaffold( body: Column( children: [ - FocusableTextFormField(controller: controller, focusNode: fieldFocusNode, enableTvKeyboard: false), + FocusableTextFormField( + controller: controller, + focusNode: fieldFocusNode, + tvTextInputPresentation: TvTextInputPresentation.platform, + ), Focus(focusNode: otherFocusNode, child: const SizedBox.shrink()), ], ), @@ -557,11 +763,11 @@ void main() { expect(find.byType(Dialog), findsNothing); }); - testWidgets('tvOS engine-synthesized select is handled by the virtual keyboard', (tester) async { + testWidgets('tvOS synthesized Select opens an explicit Flutter overlay', (tester) async { // The custom Flutter tvOS engine emits Siri Remote center-dpad presses // as `LogicalKeyboardKey.select` with `deviceType=keyboard` (via the - // legacy `flutter/keyevent` Android DPAD_CENTER path). On Apple TV this - // must open the on-screen keyboard, not submit the form. Previously + // legacy `flutter/keyevent` path). Fields that explicitly retain Plezy's + // Flutter overlay must open it rather than submit the form. // `isPhysicalKeyboardEnter` matched select+keyboard and routed through // `_submitTextInput`, which silently triggered form submit on every // dpad center press (e.g. immediate validation error on empty fields). @@ -579,6 +785,7 @@ void main() { body: FocusableTextField( controller: controller, focusNode: fieldFocusNode, + tvTextInputPresentation: TvTextInputPresentation.flutterOverlay, onSubmitted: (value) => submitted = value, ), ), @@ -609,6 +816,7 @@ void main() { body: FocusableTextField( controller: controller, focusNode: fieldFocusNode, + tvTextInputPresentation: TvTextInputPresentation.flutterOverlay, maxLength: 2, inputFormatters: [FilteringTextInputFormatter.allow(RegExp('[ab]'))], onChanged: changes.add, @@ -660,7 +868,11 @@ void main() { await tester.pumpWidget( MaterialApp( home: Scaffold( - body: FocusableTextField(controller: controller, focusNode: fieldFocusNode), + body: FocusableTextField( + controller: controller, + focusNode: fieldFocusNode, + tvTextInputPresentation: TvTextInputPresentation.flutterOverlay, + ), ), ), ); @@ -689,7 +901,7 @@ void main() { body: FocusableTextField( controller: controller, focusNode: fieldFocusNode, - tvKeyboardAutoOpenBehavior: TvKeyboardAutoOpenBehavior.never, + tvTextInputAutoOpenBehavior: TvTextInputAutoOpenBehavior.never, ), ), ), @@ -772,7 +984,7 @@ void main() { body: FocusableTextField( controller: controller, focusNode: fieldFocusNode, - tvKeyboardAutoOpenBehavior: TvKeyboardAutoOpenBehavior.never, + tvTextInputAutoOpenBehavior: TvTextInputAutoOpenBehavior.never, maxLength: 8, inputFormatters: [ TextInputFormatter.withFunction((_, nextValue) { diff --git a/tvos/Runner.xcodeproj/project.pbxproj b/tvos/Runner.xcodeproj/project.pbxproj index 2172c48f..a13afd4e 100644 --- a/tvos/Runner.xcodeproj/project.pbxproj +++ b/tvos/Runner.xcodeproj/project.pbxproj @@ -14,6 +14,7 @@ 35DB0C8FEF635A3BCA0B722A /* PackageInfoPlusPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9426EFA282CDA8E0E98EEE9 /* PackageInfoPlusPlugin.swift */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 5C71F5F7B33075F2B007825B /* MpvPlayerPluginShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73645904F226A24585A092CE /* MpvPlayerPluginShared.swift */; }; + 4F1000024F1000024F100002 /* FlutterNativeTextInputTests.mm in Sources */ = {isa = PBXBuildFile; fileRef = 4F1000014F1000014F100001 /* FlutterNativeTextInputTests.mm */; }; 65AC2C222043B3E6723E2076 /* ConnectivityPlusPluginTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 934BC4E316D2AC788C954766 /* ConnectivityPlusPluginTests.swift */; }; 691577F0EB3F4EB1A5160280 /* MpvPlayerCoreBase.swift in Sources */ = {isa = PBXBuildFile; fileRef = D52A3BDA46E79969EA1DF3AC /* MpvPlayerCoreBase.swift */; }; 6F3C0DD6F2F8DA14E7E2F386 /* MpvPlayerCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A12B8610AE5D580077264851 /* MpvPlayerCore.swift */; }; @@ -96,6 +97,7 @@ 3CCA7E80F3A99D8759F2E59F /* ConnectivityProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConnectivityProvider.swift; sourceTree = ""; }; 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 = ""; }; 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 = ""; }; + 4F1000014F1000014F100001 /* FlutterNativeTextInputTests.mm */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.cpp.objcpp; path = FlutterNativeTextInputTests.mm; sourceTree = ""; }; 5E1F41676FEF1AB57076F8B9 /* DeviceInfoPlusPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DeviceInfoPlusPlugin.swift; sourceTree = ""; }; 66F56950138FF220CA079EB3 /* TvosEventDeliveryCoordinator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TvosEventDeliveryCoordinator.swift; sourceTree = ""; }; 67C3193A50DFDECE2B92D075 /* ConnectivityPlusPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConnectivityPlusPlugin.swift; sourceTree = ""; }; @@ -337,6 +339,7 @@ F9BDF6E09E3B9347861D2A50 /* RunnerTests */ = { isa = PBXGroup; children = ( + 4F1000014F1000014F100001 /* FlutterNativeTextInputTests.mm */, 2A7C0B1D9E5F4A6381027C11 /* MpvPlayerContractTests.swift */, FE6C8125B201A8BC3261AE2B /* TvosEventDeliveryCoordinatorTests.swift */, 934BC4E316D2AC788C954766 /* ConnectivityPlusPluginTests.swift */, @@ -590,6 +593,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 4F1000024F1000024F100002 /* FlutterNativeTextInputTests.mm in Sources */, 2A7C0B1D9E5F4A6381027C12 /* MpvPlayerContractTests.swift in Sources */, D2004D7BB4A40340AB7A01E0 /* TvosEventDeliveryCoordinatorTests.swift in Sources */, 65AC2C222043B3E6723E2076 /* ConnectivityPlusPluginTests.swift in Sources */, diff --git a/tvos/RunnerTests/FlutterNativeTextInputTests.mm b/tvos/RunnerTests/FlutterNativeTextInputTests.mm new file mode 100644 index 00000000..0ba7394a --- /dev/null +++ b/tvos/RunnerTests/FlutterNativeTextInputTests.mm @@ -0,0 +1,114 @@ +#import +#import +#import + +@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*)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 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 diff --git a/tvos/engine.sha256 b/tvos/engine.sha256 index 8c3e1643..5a87295a 100644 --- a/tvos/engine.sha256 +++ b/tvos/engine.sha256 @@ -1 +1 @@ -278584b13cf4def805f9ceaa97ee4e318d5e5f75c9222286295135e5691d23ef +21dcdb1b8e369e8385cd41b7844e7248923680a22f4ea303a976494724a0829d diff --git a/tvos/engine.version b/tvos/engine.version index f40e6cda..092c6f9f 100644 --- a/tvos/engine.version +++ b/tvos/engine.version @@ -1 +1 @@ -3.44.0+4 +3.44.0+5