diff --git a/lib/focus/focusable_text_field.dart b/lib/focus/focusable_text_field.dart index bc782bfa..8a3f25f6 100644 --- a/lib/focus/focusable_text_field.dart +++ b/lib/focus/focusable_text_field.dart @@ -585,24 +585,6 @@ abstract class _FocusableTextInputBase extends StatelessWidget { return null; } - Future _showTvKeyboard(BuildContext context) { - if (!enabled) return Future.value(); - return showTvVirtualKeyboard( - context: context, - controller: controller, - hintText: _keyboardHint(decoration), - keyboardType: keyboardType, - textInputAction: textInputAction, - inputFormatters: inputFormatters, - obscureText: obscureText, - maxLength: maxLength, - maxLines: maxLines, - onChanged: onChanged, - onSubmitted: onSubmitted, - onAction: _handleTvKeyboardAction, - ); - } - void _handleTvKeyboardAction() { if (onEditingComplete != null) { onEditingComplete!(); @@ -776,8 +758,36 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> { _suppressTvKeyboardForCurrentFocus = false; _suppressTvKeyboardAutoOpen = true; _logTvTextInput('Host.openTvKeyboard node=${_installedFocusNode?.debugLabel}'); + // The dialog outlives input rebuilds (e.g. a search field whose + // onNavigateDown appears once results arrive while the keyboard is up), + // so only static configuration may be snapshotted here — the callbacks + // must resolve against widget.input at invoke time. + final input = widget.input; unawaited( - widget.input._showTvKeyboard(context).whenComplete(() { + showTvVirtualKeyboard( + context: context, + controller: input.controller, + hintText: _keyboardHint(input.decoration), + keyboardType: input.keyboardType, + textInputAction: input.textInputAction, + inputFormatters: input.inputFormatters, + obscureText: input.obscureText, + maxLength: input.maxLength, + maxLines: input.maxLines, + onChanged: (text) { + if (!mounted) return; + widget.input.onChanged?.call(text); + }, + onSubmitted: (text) { + if (!mounted) return; + final current = widget.input; + if (current.onSubmitted != null) { + current.onSubmitted!(text); + } else { + current._handleTvKeyboardAction(); + } + }, + ).whenComplete(() { if (!mounted) return; _tvKeyboardOpen = false; WidgetsBinding.instance.addPostFrameCallback((_) { diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index 3a1ebc5c..87f98ca6 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -12,6 +12,7 @@ import '../mixins/mounted_set_state_mixin.dart'; import '../mixins/refreshable.dart'; import '../providers/multi_server_provider.dart'; import '../utils/app_logger.dart'; +import '../utils/platform_detector.dart'; import '../utils/snackbar_helper.dart'; import '../widgets/desktop_app_bar.dart'; import '../widgets/loading_indicator_box.dart'; @@ -44,6 +45,7 @@ class _SearchScreenState extends State bool _hasSearched = false; late final Debounce _searchDebounce; String _lastSearchedQuery = ''; + String? _focusResultsForQuery; @override void initState() { @@ -69,6 +71,7 @@ class _SearchScreenState extends State if (query.trim().isEmpty) { _searchDebounce.cancel(); + _focusResultsForQuery = null; setStateIfMounted(() { _searchResults = []; _hasSearched = false; @@ -117,8 +120,10 @@ class _SearchScreenState extends State _isSearching = false; _lastSearchedQuery = query.trim(); }); + _maybeFocusResultsAfterSubmit(query, neutral); } } catch (e) { + _focusResultsForQuery = null; if (mounted) { setStateIfMounted(() { _isSearching = false; @@ -128,6 +133,34 @@ class _SearchScreenState extends State } } + /// OSK "Search" / hardware Enter on TV: jump to results, or force the + /// search to run now and focus results when it lands. + void _handleSearchSubmit() { + final query = _searchController.text.trim(); + if (query.isEmpty) return; + + if (_searchResults.isNotEmpty && !_isSearching && query == _lastSearchedQuery.trim()) { + _firstResultFocusNode.requestFocus(); + return; + } + + _focusResultsForQuery = query; + if (_searchDebounce.isPending || !_isSearching) { + _searchDebounce.cancel(); + _performSearch(query); + } + // else: the in-flight search already covers the current text; its + // completion focuses the results. + } + + void _maybeFocusResultsAfterSubmit(String query, List results) { + if (_focusResultsForQuery == null || _focusResultsForQuery != query.trim()) return; + _focusResultsForQuery = null; + if (results.isEmpty) return; + if (_searchController.text.trim() != query.trim()) return; // user kept editing + FocusUtils.requestFocusAfterBuild(this, _firstResultFocusNode); + } + @override void refresh() { if (!mounted) return; @@ -163,6 +196,7 @@ class _SearchScreenState extends State appLogger.d('SearchScreen.fullRefresh() called - clearing search and reloading'); // Clear search results and search text for new profile _searchController.clear(); + _focusResultsForQuery = null; setStateIfMounted(() { _searchResults.clear(); _isSearching = false; @@ -228,6 +262,7 @@ class _SearchScreenState extends State onNavigateDown: _searchResults.isNotEmpty && !_isSearching ? _firstResultFocusNode.requestFocus : null, + onEditingComplete: PlatformDetector.isTV() ? _handleSearchSubmit : null, onBack: () { if (_searchController.text.isNotEmpty) { _searchController.clear(); diff --git a/test/screens/search_screen_test.dart b/test/screens/search_screen_test.dart index 1df4bfc8..e70ce505 100644 --- a/test/screens/search_screen_test.dart +++ b/test/screens/search_screen_test.dart @@ -1,16 +1,42 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/i18n/strings.g.dart'; +import 'package:plezy/media/ids.dart'; +import 'package:plezy/media/media_backend.dart'; +import 'package:plezy/media/media_item.dart'; +import 'package:plezy/media/media_kind.dart'; +import 'package:plezy/media/media_server_client.dart'; +import 'package:plezy/media/server_capabilities.dart'; import 'package:plezy/mixins/refreshable.dart'; +import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/screens/search_screen.dart'; +import 'package:plezy/services/data_aggregation_service.dart'; +import 'package:plezy/services/multi_server_manager.dart'; +import 'package:plezy/services/settings_service.dart'; +import 'package:plezy/theme/mono_theme.dart'; +import 'package:plezy/utils/platform_detector.dart'; +import 'package:provider/provider.dart'; + +import '../test_helpers/prefs.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); - setUp(() { + setUpAll(() { LocaleSettings.setLocaleSync(AppLocale.en); }); + setUp(() async { + resetSharedPreferencesForTest(); + SettingsService.resetForTesting(); + await SettingsService.getInstance(); + }); + + tearDown(() { + TvDetectionService.debugSetAppleTVOverride(null); + TvDetectionService.setForceTVSync(false); + }); + testWidgets('stale callbacks are no-ops after SearchScreen is disposed', (tester) async { final key = GlobalKey>(); @@ -36,4 +62,124 @@ void main() { expect(() => (state as FocusableTab).focusActiveTabIfReady(), returnsNormally); expect(tester.takeException(), isNull); }); + + testWidgets('TV OSK search key moves focus to the first result', (tester) async { + final (client, key) = await _pumpTvSearchScreen(tester); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsOneWidget); + + final state = key.currentState!; + (state as SearchInputFocusable).setSearchQuery('movie'); + // rate_limiter's Debounce compares DateTime.now() against the fake-clock + // timer, so it never invokes under FakeAsync — run the search via + // refresh() (same _performSearch path) to get results behind the dialog. + (state as Refreshable).refresh(); + await tester.pumpAndSettle(); + expect(client.queries, ['movie']); + expect(find.text('Movie 1'), findsOneWidget); + + await tester.tap(_keyboardDoneKey()); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsNothing); + expect(FocusManager.instance.primaryFocus?.debugLabel, 'SearchFirstResult'); + expect(find.text('Movie 1'), findsOneWidget); + + // Dispose the screen so its still-armed debounce timer is cancelled. + await tester.pumpWidget(const SizedBox.shrink()); + }); + + testWidgets('TV OSK search key before the debounce fires searches immediately', (tester) async { + final (client, key) = await _pumpTvSearchScreen(tester); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsOneWidget); + + (key.currentState! as SearchInputFocusable).setSearchQuery('movie'); + await tester.pump(const Duration(milliseconds: 100)); + expect(client.queries, isEmpty); + + await tester.tap(_keyboardDoneKey()); + await tester.pumpAndSettle(); + + expect(client.queries, ['movie']); + expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsNothing); + expect(FocusManager.instance.primaryFocus?.debugLabel, 'SearchFirstResult'); + }); +} + +Future<(_FakeMediaServerClient, GlobalKey>)> _pumpTvSearchScreen(WidgetTester tester) async { + TvDetectionService.debugSetAppleTVOverride(null); + await TvDetectionService.getInstance(forceTv: true); + TvDetectionService.setForceTVSync(true); + tester.view.devicePixelRatio = 1.0; + tester.view.physicalSize = const Size(1280, 720); + addTearDown(() { + tester.view.resetDevicePixelRatio(); + tester.view.resetPhysicalSize(); + }); + + final client = _FakeMediaServerClient( + items: [ + MediaItem( + id: 'movie_1', + backend: MediaBackend.plex, + kind: MediaKind.movie, + title: 'Movie 1', + serverId: 'server_1', + serverName: 'Server', + ), + ], + ); + final manager = MultiServerManager()..debugRegisterClientForTesting(client); + final provider = MultiServerProvider(manager, DataAggregationService(manager)); + addTearDown(provider.dispose); + + final key = GlobalKey>(); + await tester.pumpWidget( + TranslationProvider( + child: ChangeNotifierProvider.value( + value: provider, + child: MaterialApp( + theme: monoTheme(dark: true), + home: SearchScreen(key: key), + ), + ), + ), + ); + return (client, key); +} + +Finder _keyboardDoneKey() { + return find.descendant( + of: find.byKey(const Key('tv_virtual_keyboard_panel')), + matching: find.byIcon(Icons.search_rounded), + ); +} + +class _FakeMediaServerClient implements MediaServerClient { + final List items; + final List queries = []; + + _FakeMediaServerClient({required this.items}); + + @override + ServerId get serverId => ServerId('server_1'); + + @override + String? get serverName => 'Server'; + + @override + MediaBackend get backend => MediaBackend.plex; + + @override + ServerCapabilities get capabilities => ServerCapabilities.plex; + + @override + Future> searchItems(String query, {int limit = 100}) async { + queries.add(query); + return items; + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } diff --git a/test/widgets/focusable_text_field_test.dart b/test/widgets/focusable_text_field_test.dart index 05bb970d..7b1c0aba 100644 --- a/test/widgets/focusable_text_field_test.dart +++ b/test/widgets/focusable_text_field_test.dart @@ -683,6 +683,145 @@ void main() { expect(find.byType(Dialog), findsNothing); }); + testWidgets('TV keyboard done resolves callbacks against the latest field widget', (tester) async { + TvDetectionService.debugSetAppleTVOverride(null); + await TvDetectionService.getInstance(forceTv: true); + TvDetectionService.setForceTVSync(true); + await _setTvSurfaceSize(tester); + final controller = TextEditingController(text: 'query'); + final fieldFocusNode = FocusNode(debugLabel: 'search_field'); + var navigateDownCalls = 0; + VoidCallback? onNavigateDown; + late StateSetter rebuild; + addTearDown(controller.dispose); + addTearDown(fieldFocusNode.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StatefulBuilder( + builder: (context, setState) { + rebuild = setState; + return FocusableTextField( + controller: controller, + focusNode: fieldFocusNode, + textInputAction: TextInputAction.search, + onNavigateDown: onNavigateDown, + ); + }, + ), + ), + ), + ); + + fieldFocusNode.requestFocus(); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsOneWidget); + + // Simulates search results arriving while the keyboard is open: the field + // rebuilds and only now gains an onNavigateDown callback. + rebuild(() => onNavigateDown = () => navigateDownCalls++); + await tester.pump(); + + await tester.tap(_tvKeyboardDoneKey(Icons.search_rounded)); + await tester.pumpAndSettle(); + + expect(navigateDownCalls, 1); + expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsNothing); + expect(controller.text, 'query'); + }); + + testWidgets('TV keyboard done prefers the latest onSubmitted over navigation', (tester) async { + TvDetectionService.debugSetAppleTVOverride(null); + await TvDetectionService.getInstance(forceTv: true); + TvDetectionService.setForceTVSync(true); + await _setTvSurfaceSize(tester); + final controller = TextEditingController(text: 'query'); + final fieldFocusNode = FocusNode(debugLabel: 'search_field'); + String? submitted; + var navigateDownCalls = 0; + ValueChanged? onSubmitted; + VoidCallback? onNavigateDown; + late StateSetter rebuild; + addTearDown(controller.dispose); + addTearDown(fieldFocusNode.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StatefulBuilder( + builder: (context, setState) { + rebuild = setState; + return FocusableTextField( + controller: controller, + focusNode: fieldFocusNode, + textInputAction: TextInputAction.search, + onSubmitted: onSubmitted, + onNavigateDown: onNavigateDown, + ); + }, + ), + ), + ), + ); + + fieldFocusNode.requestFocus(); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsOneWidget); + + rebuild(() { + onSubmitted = (value) => submitted = value; + onNavigateDown = () => navigateDownCalls++; + }); + await tester.pump(); + + await tester.tap(_tvKeyboardDoneKey(Icons.search_rounded)); + await tester.pumpAndSettle(); + + expect(submitted, 'query'); + expect(navigateDownCalls, 0); + }); + + testWidgets('TV keyboard stays closed when done keeps field focus', (tester) async { + TvDetectionService.debugSetAppleTVOverride(null); + await TvDetectionService.getInstance(forceTv: true); + TvDetectionService.setForceTVSync(true); + await _setTvSurfaceSize(tester); + final controller = TextEditingController(text: 'query'); + final fieldFocusNode = FocusNode(debugLabel: 'search_field'); + addTearDown(controller.dispose); + addTearDown(fieldFocusNode.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: FocusableTextField( + controller: controller, + focusNode: fieldFocusNode, + textInputAction: TextInputAction.search, + onEditingComplete: () {}, + ), + ), + ), + ); + + fieldFocusNode.requestFocus(); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsOneWidget); + + await tester.tap(_tvKeyboardDoneKey(Icons.search_rounded)); + await tester.pumpAndSettle(); + await tester.pump(); + + expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsNothing); + expect(fieldFocusNode.hasPrimaryFocus, isTrue); + + await tester.sendKeyEvent(LogicalKeyboardKey.select); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsOneWidget); + }); + testWidgets('tvOS keyboard enter inserts newline for multiline text field', (tester) async { TvDetectionService.debugSetAppleTVOverride(true); final controller = TextEditingController(text: 'a'); @@ -718,6 +857,10 @@ Future _setTvSurfaceSize(WidgetTester tester) async { addTearDown(() => tester.binding.setSurfaceSize(null)); } +Finder _tvKeyboardDoneKey(IconData icon) { + return find.descendant(of: find.byKey(const Key('tv_virtual_keyboard_panel')), matching: find.byIcon(icon)); +} + KeyDownEvent _remoteKey(LogicalKeyboardKey key) { return KeyDownEvent( physicalKey: _physicalKeyFor(key),