fix(tv): make osk search key focus results

The TV keyboard dialog froze the field's callbacks at open time, so the
search screen's done key fell through to unfocus() once results arrived.
Resolve callbacks against the latest field widget at invoke time, and
give the search screen a TV submit handler that focuses the first result
or flushes the pending debounce.
This commit is contained in:
edde746
2026-06-10 16:37:35 +02:00
parent 804bea8e9a
commit e75ab94eb9
4 changed files with 354 additions and 20 deletions
+147 -1
View File
@@ -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<State<SearchScreen>>();
@@ -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<State<SearchScreen>>)> _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<State<SearchScreen>>();
await tester.pumpWidget(
TranslationProvider(
child: ChangeNotifierProvider<MultiServerProvider>.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<MediaItem> items;
final List<String> 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<List<MediaItem>> searchItems(String query, {int limit = 100}) async {
queries.add(query);
return items;
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
+143
View File
@@ -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<String>? 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<void> _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),