diff --git a/lib/focus/dpad_navigator.dart b/lib/focus/dpad_navigator.dart index c254d056..1c19e3de 100644 --- a/lib/focus/dpad_navigator.dart +++ b/lib/focus/dpad_navigator.dart @@ -1,7 +1,24 @@ +import 'dart:ui' as ui; + import 'package:flutter/services.dart'; extension KeyEventActionable on KeyEvent { bool get isActionable => this is KeyDownEvent || this is KeyRepeatEvent; + bool get isPhysicalKeyboardEvent => deviceType == ui.KeyEventDeviceType.keyboard; + bool get isPhysicalKeyboardEnter => + deviceType == ui.KeyEventDeviceType.keyboard && + (logicalKey == LogicalKeyboardKey.enter || + logicalKey == LogicalKeyboardKey.numpadEnter || + logicalKey == LogicalKeyboardKey.select); + + bool get isTvSelectEvent { + if (isPhysicalKeyboardEvent) return false; + if (logicalKey == LogicalKeyboardKey.select || logicalKey == LogicalKeyboardKey.gameButtonA) return true; + if (logicalKey == LogicalKeyboardKey.enter || logicalKey == LogicalKeyboardKey.numpadEnter) { + return deviceType != ui.KeyEventDeviceType.keyboard; + } + return false; + } } final _dpadDirectionKeys = { diff --git a/lib/focus/focusable_text_field.dart b/lib/focus/focusable_text_field.dart index 28ab4983..27ad42a8 100644 --- a/lib/focus/focusable_text_field.dart +++ b/lib/focus/focusable_text_field.dart @@ -15,6 +15,14 @@ KeyEventResult _handleInputKey({ required bool enabled, required VoidCallback openKeyboard, required KeyEvent event, + TextInputType? keyboardType, + TextInputAction? textInputAction, + List? inputFormatters, + ValueChanged? onChanged, + ValueChanged? onSubmitted, + VoidCallback? onEditingComplete, + int? maxLength, + int? maxLines, VoidCallback? onSelect, VoidCallback? onBack, VoidCallback? onNavigateLeft, @@ -24,11 +32,27 @@ KeyEventResult _handleInputKey({ }) { final key = event.logicalKey; - if (usesTvKeyboard && enabled && key.isSelectKey) { + if (usesTvKeyboard && enabled && event.isTvSelectEvent) { if (event is KeyDownEvent) openKeyboard(); return KeyEventResult.handled; } + if (usesTvKeyboard && enabled && event.isPhysicalKeyboardEvent) { + final result = _handleTvHardwareKeyboardKey( + controller: controller, + keyboardType: keyboardType, + textInputAction: textInputAction, + inputFormatters: inputFormatters, + onChanged: onChanged, + onSubmitted: onSubmitted, + onEditingComplete: onEditingComplete, + maxLength: maxLength, + maxLines: maxLines, + event: event, + ); + if (result != KeyEventResult.ignored) return result; + } + if (onBack != null && key.isBackKey) { if (event is KeyDownEvent) onBack(); return KeyEventResult.handled; @@ -69,6 +93,279 @@ KeyEventResult _handleInputKey({ return KeyEventResult.ignored; } +KeyEventResult _handleTvHardwareKeyboardKey({ + required TextEditingController controller, + required KeyEvent event, + TextInputType? keyboardType, + TextInputAction? textInputAction, + List? inputFormatters, + ValueChanged? onChanged, + ValueChanged? onSubmitted, + VoidCallback? onEditingComplete, + int? maxLength, + int? maxLines, +}) { + final key = event.logicalKey; + + if (event.isPhysicalKeyboardEnter) { + if (event is KeyDownEvent) { + if (_isMultilineTextInput(keyboardType: keyboardType, maxLines: maxLines)) { + _insertText( + controller: controller, + text: '\n', + inputFormatters: inputFormatters, + maxLength: maxLength, + onChanged: onChanged, + ); + } else { + _submitTextInput( + controller: controller, + textInputAction: textInputAction, + onSubmitted: onSubmitted, + onEditingComplete: onEditingComplete, + ); + } + } + return KeyEventResult.handled; + } + + if (!event.isActionable) return KeyEventResult.ignored; + + if (key == LogicalKeyboardKey.backspace) { + _backspace(controller: controller, inputFormatters: inputFormatters, maxLength: maxLength, onChanged: onChanged); + return KeyEventResult.handled; + } + if (key == LogicalKeyboardKey.delete) { + _deleteForward( + controller: controller, + inputFormatters: inputFormatters, + maxLength: maxLength, + onChanged: onChanged, + ); + return KeyEventResult.handled; + } + + if (key.isLeftKey || key.isRightKey) { + return _moveCaretHorizontally(controller, key.isLeftKey ? -1 : 1); + } + + final character = event.character; + if (character != null && character.isNotEmpty && !key.isNavigationKey && !_isControlCharacter(character)) { + _insertText( + controller: controller, + text: character, + inputFormatters: inputFormatters, + maxLength: maxLength, + onChanged: onChanged, + ); + return KeyEventResult.handled; + } + + return KeyEventResult.ignored; +} + +bool _isMultilineTextInput({TextInputType? keyboardType, int? maxLines}) { + return keyboardType?.index == TextInputType.multiline.index || (maxLines != null && maxLines != 1); +} + +bool _isControlCharacter(String text) { + return text.runes.every((codeUnit) => codeUnit < 0x20 || codeUnit == 0x7f); +} + +KeyEventResult _moveCaretHorizontally(TextEditingController controller, int delta) { + final value = controller.value; + final selection = value.selection; + if (!selection.isValid) { + controller.selection = TextSelection.collapsed(offset: value.text.length); + return KeyEventResult.handled; + } + + if (!selection.isCollapsed) { + final offset = delta < 0 + ? (selection.start < selection.end ? selection.start : selection.end) + : (selection.start > selection.end ? selection.start : selection.end); + controller.selection = TextSelection.collapsed(offset: offset); + return KeyEventResult.handled; + } + + final nextOffset = selection.extentOffset + delta; + if (nextOffset < 0 || nextOffset > value.text.length) return KeyEventResult.ignored; + controller.selection = TextSelection.collapsed(offset: nextOffset); + return KeyEventResult.handled; +} + +void _submitTextInput({ + required TextEditingController controller, + required TextInputAction? textInputAction, + ValueChanged? onSubmitted, + VoidCallback? onEditingComplete, +}) { + if (onEditingComplete != null) { + onEditingComplete(); + } else { + _defaultEditingComplete(textInputAction); + } + onSubmitted?.call(controller.text); +} + +void _defaultEditingComplete(TextInputAction? textInputAction) { + final focus = FocusManager.instance.primaryFocus; + switch (textInputAction) { + case TextInputAction.next: + focus?.nextFocus(); + case TextInputAction.previous: + focus?.previousFocus(); + default: + focus?.unfocus(); + } +} + +void _insertText({ + required TextEditingController controller, + required String text, + List? inputFormatters, + int? maxLength, + ValueChanged? onChanged, +}) { + final value = controller.value; + final selection = value.selection; + final start = selection.isValid + ? (selection.start < selection.end ? selection.start : selection.end) + : value.text.length; + final end = selection.isValid + ? (selection.start > selection.end ? selection.start : selection.end) + : value.text.length; + final newText = value.text.replaceRange(start, end, text); + _replaceTextValue( + controller: controller, + nextValue: value.copyWith( + text: newText, + selection: TextSelection.collapsed(offset: start + text.length), + composing: TextRange.empty, + ), + inputFormatters: inputFormatters, + maxLength: maxLength, + onChanged: onChanged, + ); +} + +void _backspace({ + required TextEditingController controller, + List? inputFormatters, + int? maxLength, + ValueChanged? onChanged, +}) { + final value = controller.value; + final selection = value.selection; + final start = selection.isValid + ? (selection.start < selection.end ? selection.start : selection.end) + : value.text.length; + final end = selection.isValid + ? (selection.start > selection.end ? selection.start : selection.end) + : value.text.length; + if (start != end) { + _replaceTextRange( + controller, + start, + end, + inputFormatters: inputFormatters, + maxLength: maxLength, + onChanged: onChanged, + ); + return; + } + if (start == 0) return; + _replaceTextRange( + controller, + start - 1, + start, + inputFormatters: inputFormatters, + maxLength: maxLength, + onChanged: onChanged, + ); +} + +void _deleteForward({ + required TextEditingController controller, + List? inputFormatters, + int? maxLength, + ValueChanged? onChanged, +}) { + final value = controller.value; + final selection = value.selection; + final start = selection.isValid + ? (selection.start < selection.end ? selection.start : selection.end) + : value.text.length; + final end = selection.isValid + ? (selection.start > selection.end ? selection.start : selection.end) + : value.text.length; + if (start != end) { + _replaceTextRange( + controller, + start, + end, + inputFormatters: inputFormatters, + maxLength: maxLength, + onChanged: onChanged, + ); + return; + } + if (start >= value.text.length) return; + _replaceTextRange( + controller, + start, + start + 1, + inputFormatters: inputFormatters, + maxLength: maxLength, + onChanged: onChanged, + ); +} + +void _replaceTextRange( + TextEditingController controller, + int start, + int end, { + List? inputFormatters, + int? maxLength, + ValueChanged? onChanged, +}) { + final value = controller.value; + _replaceTextValue( + controller: controller, + nextValue: value.copyWith( + text: value.text.replaceRange(start, end, ''), + selection: TextSelection.collapsed(offset: start), + composing: TextRange.empty, + ), + inputFormatters: inputFormatters, + maxLength: maxLength, + onChanged: onChanged, + ); +} + +void _replaceTextValue({ + required TextEditingController controller, + required TextEditingValue nextValue, + List? inputFormatters, + int? maxLength, + ValueChanged? onChanged, +}) { + final previousValue = controller.value; + var formattedValue = nextValue; + final formatters = [ + ...?inputFormatters, + if (maxLength != null && maxLength > 0) LengthLimitingTextInputFormatter(maxLength), + ]; + for (final formatter in formatters) { + formattedValue = formatter.formatEditUpdate(previousValue, formattedValue); + } + + controller.value = formattedValue; + if (formattedValue.text != previousValue.text) { + onChanged?.call(formattedValue.text); + } +} + abstract class _FocusableTextInputBase extends StatelessWidget { final TextEditingController controller; final FocusNode? focusNode; @@ -159,6 +456,14 @@ abstract class _FocusableTextInputBase extends StatelessWidget { enabled: enabled, openKeyboard: () => _showTvKeyboard(context), event: event, + keyboardType: keyboardType, + textInputAction: textInputAction, + inputFormatters: inputFormatters, + onChanged: onChanged, + onSubmitted: onSubmitted, + onEditingComplete: onEditingComplete, + maxLength: maxLength, + maxLines: maxLines, onSelect: onSelect, onBack: onBack, onNavigateLeft: onNavigateLeft, diff --git a/lib/focus/key_event_utils.dart b/lib/focus/key_event_utils.dart index bccca113..b33b4985 100644 --- a/lib/focus/key_event_utils.dart +++ b/lib/focus/key_event_utils.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import '../utils/platform_detector.dart'; import 'dpad_navigator.dart'; /// Handles back key events by popping the current route. @@ -61,6 +62,13 @@ KeyEventResult handleBackKeyAction(KeyEvent event, VoidCallback onBack) { return KeyEventResult.handled; } + if (PlatformDetector.isAppleTV() && event.isPhysicalKeyboardEvent && event is KeyDownEvent) { + BackKeyCoordinator.markHandled(); + BackKeyUpSuppressor.suppressBackUntilKeyUp(); + onBack(); + return KeyEventResult.handled; + } + if (event is KeyUpEvent) { BackKeyCoordinator.markHandled(); // Mark that we're closing via back key so suppressBackUntilKeyUp() knows to skip diff --git a/lib/services/gamepad_service.dart b/lib/services/gamepad_service.dart index 32c8d16b..65d82be0 100644 --- a/lib/services/gamepad_service.dart +++ b/lib/services/gamepad_service.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:io'; +import 'dart:ui' as ui; import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; @@ -430,6 +431,7 @@ class GamepadService with WindowListener { physicalKey: _getPhysicalKey(logicalKey), logicalKey: logicalKey, timeStamp: Duration(milliseconds: DateTime.now().millisecondsSinceEpoch), + deviceType: ui.KeyEventDeviceType.gamepad, ), ); } @@ -440,6 +442,7 @@ class GamepadService with WindowListener { physicalKey: _getPhysicalKey(logicalKey), logicalKey: logicalKey, timeStamp: Duration(milliseconds: DateTime.now().millisecondsSinceEpoch), + deviceType: ui.KeyEventDeviceType.gamepad, ), ); } diff --git a/lib/utils/key_event_simulator.dart b/lib/utils/key_event_simulator.dart index 93801960..dfe486d2 100644 --- a/lib/utils/key_event_simulator.dart +++ b/lib/utils/key_event_simulator.dart @@ -1,3 +1,5 @@ +import 'dart:ui' as ui; + import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; @@ -17,6 +19,7 @@ void simulateKeyPress(LogicalKeyboardKey logicalKey) { physicalKey: physicalKey, logicalKey: logicalKey, timeStamp: Duration(milliseconds: DateTime.now().millisecondsSinceEpoch), + deviceType: ui.KeyEventDeviceType.directionalPad, ); FocusNode? node = focusNode; @@ -33,6 +36,7 @@ void simulateKeyPress(LogicalKeyboardKey logicalKey) { physicalKey: physicalKey, logicalKey: logicalKey, timeStamp: Duration(milliseconds: DateTime.now().millisecondsSinceEpoch), + deviceType: ui.KeyEventDeviceType.directionalPad, ); node = focusNode; diff --git a/lib/utils/platform_detector.dart b/lib/utils/platform_detector.dart index e8132acf..44a220ec 100644 --- a/lib/utils/platform_detector.dart +++ b/lib/utils/platform_detector.dart @@ -31,6 +31,7 @@ AndroidTvFeatureDetection detectAndroidTvFromSystemFeatures(Iterable fea /// Service for detecting if the app is running on Android TV or Apple TV. class TvDetectionService { static TvDetectionService? _instance; + static bool? _debugAppleTVOverride; bool _detected = false; bool _forceTv = false; bool _isTV = false; @@ -122,10 +123,15 @@ class TvDetectionService { } /// Synchronous access after initialization (returns false if not initialized) - static bool isTVSync() => _instance?._isTV ?? false; + static bool isTVSync() => _debugAppleTVOverride ?? _instance?._isTV ?? false; /// Synchronous Apple TV check (returns false if not initialized or not tvOS). - static bool isAppleTVSync() => _instance?._isAppleTV ?? false; + static bool isAppleTVSync() => _debugAppleTVOverride ?? _instance?._isAppleTV ?? false; + + @visibleForTesting + static void debugSetAppleTVOverride(bool? value) { + _debugAppleTVOverride = value; + } static List tvDetectionReasonsSync() => _instance?._effectiveDetectionReasons ?? const []; diff --git a/lib/widgets/tv_virtual_keyboard.dart b/lib/widgets/tv_virtual_keyboard.dart index f6be4588..0a64c823 100644 --- a/lib/widgets/tv_virtual_keyboard.dart +++ b/lib/widgets/tv_virtual_keyboard.dart @@ -227,7 +227,16 @@ class _TvVirtualKeyboardDialogState extends State<_TvVirtualKeyboardDialog> with return KeyEventResult.handled; } - if (key.isSelectKey) { + if (event.isPhysicalKeyboardEnter) { + if (_isMultiline) { + _insert('\n'); + } else if (event is KeyDownEvent) { + _submit(); + } + return KeyEventResult.handled; + } + + if (event.isTvSelectEvent) { _activate(_rows[_row][_column]); return KeyEventResult.handled; } diff --git a/test/focus/key_event_utils_test.dart b/test/focus/key_event_utils_test.dart new file mode 100644 index 00000000..bab6a73a --- /dev/null +++ b/test/focus/key_event_utils_test.dart @@ -0,0 +1,44 @@ +import 'dart:ui' as ui; + +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/focus/dpad_navigator.dart'; +import 'package:plezy/focus/key_event_utils.dart'; +import 'package:plezy/utils/platform_detector.dart'; + +void main() { + tearDown(() { + TvDetectionService.debugSetAppleTVOverride(null); + BackKeyUpSuppressor.clearSuppression(); + }); + + testWidgets('tvOS physical keyboard back runs on key down and suppresses key up', (tester) async { + TvDetectionService.debugSetAppleTVOverride(true); + var backs = 0; + + final downResult = handleBackKeyAction( + const KeyDownEvent( + physicalKey: PhysicalKeyboardKey.escape, + logicalKey: LogicalKeyboardKey.escape, + timeStamp: Duration.zero, + deviceType: ui.KeyEventDeviceType.keyboard, + ), + () => backs++, + ); + + final upResult = handleBackKeyAction( + const KeyUpEvent( + physicalKey: PhysicalKeyboardKey.escape, + logicalKey: LogicalKeyboardKey.escape, + timeStamp: Duration.zero, + deviceType: ui.KeyEventDeviceType.keyboard, + ), + () => backs++, + ); + + expect(downResult, KeyEventResult.handled); + expect(upResult, KeyEventResult.handled); + expect(backs, 1); + }); +} diff --git a/test/utils/key_event_simulator_test.dart b/test/utils/key_event_simulator_test.dart new file mode 100644 index 00000000..581bbcaa --- /dev/null +++ b/test/utils/key_event_simulator_test.dart @@ -0,0 +1,42 @@ +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/utils/key_event_simulator.dart'; + +void main() { + testWidgets('simulateKeyPress dispatches directional pad key events', (tester) async { + final events = []; + late BuildContext focusContext; + + await tester.pumpWidget( + MaterialApp( + home: Focus( + autofocus: true, + onKeyEvent: (_, event) { + events.add(event); + return KeyEventResult.handled; + }, + child: Builder( + builder: (context) { + focusContext = context; + return const SizedBox.shrink(); + }, + ), + ), + ), + ); + Focus.of(focusContext).requestFocus(); + await tester.pump(); + expect(Focus.of(focusContext).hasPrimaryFocus, isTrue); + + scheduleFrameIfIdle(); + simulateKeyPress(LogicalKeyboardKey.enter); + await tester.pump(); + await tester.pump(); + + expect(events, hasLength(2)); + expect(events.map((event) => event.deviceType), everyElement(ui.KeyEventDeviceType.directionalPad)); + }); +} diff --git a/test/widgets/focusable_text_field_test.dart b/test/widgets/focusable_text_field_test.dart index 83fe102f..fa945267 100644 --- a/test/widgets/focusable_text_field_test.dart +++ b/test/widgets/focusable_text_field_test.dart @@ -1,9 +1,16 @@ +import 'dart:ui' as ui; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/focus/focusable_text_field.dart'; +import 'package:plezy/utils/platform_detector.dart'; void main() { + tearDown(() { + TvDetectionService.debugSetAppleTVOverride(null); + }); + testWidgets('tab traversal focuses the text form field instead of its key handler wrapper', (tester) async { final controller = TextEditingController(); final fieldFocusNode = FocusNode(debugLabel: 'server_url_field'); @@ -58,4 +65,186 @@ void main() { expect(selects, 1); }); + + testWidgets('tvOS keyboard enter does not open virtual keyboard', (tester) async { + TvDetectionService.debugSetAppleTVOverride(true); + final controller = TextEditingController(); + final fieldFocusNode = FocusNode(debugLabel: 'search_field'); + String? submitted; + addTearDown(controller.dispose); + addTearDown(fieldFocusNode.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: FocusableTextField( + controller: controller, + focusNode: fieldFocusNode, + onSubmitted: (value) => submitted = value, + ), + ), + ), + ); + + fieldFocusNode.requestFocus(); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + + expect(find.byType(Dialog), findsNothing); + expect(submitted, isEmpty); + }); + + testWidgets('tvOS remote select opens virtual keyboard', (tester) async { + TvDetectionService.debugSetAppleTVOverride(true); + await _setTvSurfaceSize(tester); + final controller = TextEditingController(); + final fieldFocusNode = FocusNode(debugLabel: 'search_field'); + addTearDown(controller.dispose); + addTearDown(fieldFocusNode.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: FocusableTextField(controller: controller, focusNode: fieldFocusNode), + ), + ), + ); + + fieldFocusNode.requestFocus(); + await tester.pump(); + _dispatchKey( + const KeyDownEvent( + physicalKey: PhysicalKeyboardKey.select, + logicalKey: LogicalKeyboardKey.select, + timeStamp: Duration.zero, + deviceType: ui.KeyEventDeviceType.directionalPad, + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(Dialog), findsOneWidget); + }); + + testWidgets('tvOS keyboard-mapped select submits without opening virtual keyboard', (tester) async { + TvDetectionService.debugSetAppleTVOverride(true); + final controller = TextEditingController(text: 'query'); + final fieldFocusNode = FocusNode(debugLabel: 'search_field'); + String? submitted; + addTearDown(controller.dispose); + addTearDown(fieldFocusNode.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: FocusableTextField( + controller: controller, + focusNode: fieldFocusNode, + onSubmitted: (value) => submitted = value, + ), + ), + ), + ); + + fieldFocusNode.requestFocus(); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.select); + await tester.pumpAndSettle(); + + expect(submitted, 'query'); + expect(find.byType(Dialog), findsNothing); + }); + + testWidgets('tvOS text field handles physical keyboard text editing without opening virtual keyboard', ( + tester, + ) async { + TvDetectionService.debugSetAppleTVOverride(true); + final controller = TextEditingController(); + final fieldFocusNode = FocusNode(debugLabel: 'search_field'); + final changes = []; + addTearDown(controller.dispose); + addTearDown(fieldFocusNode.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: FocusableTextField( + controller: controller, + focusNode: fieldFocusNode, + maxLength: 2, + inputFormatters: [FilteringTextInputFormatter.allow(RegExp('[ab]'))], + onChanged: changes.add, + ), + ), + ), + ); + + expect(tester.widget(find.byType(TextField)).readOnly, isTrue); + + fieldFocusNode.requestFocus(); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.keyA, character: 'a'); + await tester.sendKeyEvent(LogicalKeyboardKey.keyC, character: 'c'); + await tester.sendKeyEvent(LogicalKeyboardKey.keyB, character: 'b'); + await tester.pumpAndSettle(); + + expect(controller.text, 'ab'); + expect(changes, ['a', 'ab']); + + await tester.sendKeyEvent(LogicalKeyboardKey.backspace); + await tester.pump(); + + expect(controller.text, 'a'); + + controller.selection = const TextSelection.collapsed(offset: 0); + await tester.sendKeyEvent(LogicalKeyboardKey.delete); + await tester.pump(); + + expect(controller.text, isEmpty); + expect(find.byType(Dialog), findsNothing); + }); + + testWidgets('tvOS keyboard enter inserts newline for multiline text field', (tester) async { + TvDetectionService.debugSetAppleTVOverride(true); + final controller = TextEditingController(text: 'a'); + final fieldFocusNode = FocusNode(debugLabel: 'notes_field'); + addTearDown(controller.dispose); + addTearDown(fieldFocusNode.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: FocusableTextField( + controller: controller, + focusNode: fieldFocusNode, + keyboardType: TextInputType.multiline, + maxLines: 2, + ), + ), + ), + ); + + fieldFocusNode.requestFocus(); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + + expect(controller.text, 'a\n'); + expect(find.byType(Dialog), findsNothing); + }); +} + +Future _setTvSurfaceSize(WidgetTester tester) async { + await tester.binding.setSurfaceSize(const Size(1280, 720)); + addTearDown(() => tester.binding.setSurfaceSize(null)); +} + +KeyEventResult _dispatchKey(KeyEvent event) { + FocusNode? node = FocusManager.instance.primaryFocus; + while (node != null) { + final result = node.onKeyEvent?.call(node, event) ?? KeyEventResult.ignored; + if (result == KeyEventResult.handled) return result; + node = node.parent; + } + return KeyEventResult.ignored; } diff --git a/test/widgets/tv_virtual_keyboard_test.dart b/test/widgets/tv_virtual_keyboard_test.dart new file mode 100644 index 00000000..88d1efc7 --- /dev/null +++ b/test/widgets/tv_virtual_keyboard_test.dart @@ -0,0 +1,122 @@ +import 'dart:async'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/utils/platform_detector.dart'; +import 'package:plezy/widgets/tv_virtual_keyboard.dart'; + +void main() { + tearDown(() { + TvDetectionService.debugSetAppleTVOverride(null); + }); + + testWidgets('keyboard enter submits without inserting highlighted key', (tester) async { + final controller = TextEditingController(); + String? submitted; + addTearDown(controller.dispose); + + await _pumpKeyboard(tester, controller: controller, onSubmitted: (value) => submitted = value); + + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + + expect(controller.text, isEmpty); + expect(submitted, isEmpty); + expect(find.byType(Dialog), findsNothing); + }); + + testWidgets('keyboard-mapped select submits without inserting highlighted key', (tester) async { + final controller = TextEditingController(text: 'query'); + String? submitted; + addTearDown(controller.dispose); + + await _pumpKeyboard(tester, controller: controller, onSubmitted: (value) => submitted = value); + + await tester.sendKeyEvent(LogicalKeyboardKey.select); + await tester.pumpAndSettle(); + + expect(controller.text, 'query'); + expect(submitted, 'query'); + expect(find.byType(Dialog), findsNothing); + }); + + testWidgets('directional pad enter activates highlighted key', (tester) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + + await _pumpKeyboard(tester, controller: controller); + + _dispatchKey( + const KeyDownEvent( + physicalKey: PhysicalKeyboardKey.enter, + logicalKey: LogicalKeyboardKey.enter, + timeStamp: Duration.zero, + deviceType: ui.KeyEventDeviceType.directionalPad, + ), + ); + await tester.pump(); + + expect(controller.text, '1'); + expect(find.byType(Dialog), findsOneWidget); + }); + + testWidgets('keyboard enter inserts newline for multiline input', (tester) async { + final controller = TextEditingController(text: 'a'); + addTearDown(controller.dispose); + + await _pumpKeyboard(tester, controller: controller, keyboardType: TextInputType.multiline, maxLines: 2); + + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + + expect(controller.text, 'a\n'); + expect(find.byType(Dialog), findsOneWidget); + }); +} + +Future _pumpKeyboard( + WidgetTester tester, { + required TextEditingController controller, + TextInputType? keyboardType, + int? maxLines, + ValueChanged? onSubmitted, +}) async { + TvDetectionService.debugSetAppleTVOverride(true); + await tester.binding.setSurfaceSize(const Size(1280, 720)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + late BuildContext context; + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (builderContext) { + context = builderContext; + return const SizedBox.shrink(); + }, + ), + ), + ); + + unawaited( + showTvVirtualKeyboard( + context: context, + controller: controller, + keyboardType: keyboardType, + maxLines: maxLines, + onSubmitted: onSubmitted, + ), + ); + await tester.pumpAndSettle(); +} + +KeyEventResult _dispatchKey(KeyEvent event) { + FocusNode? node = FocusManager.instance.primaryFocus; + while (node != null) { + final result = node.onKeyEvent?.call(node, event) ?? KeyEventResult.ignored; + if (result == KeyEventResult.handled) return result; + node = node.parent; + } + return KeyEventResult.ignored; +}