fix(tv): support hardware keyboard input

This commit is contained in:
edde746
2026-05-07 00:52:34 +02:00
parent 7988c83bec
commit 9446f976a4
11 changed files with 753 additions and 4 deletions
+17
View File
@@ -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 = {
+306 -1
View File
@@ -15,6 +15,14 @@ KeyEventResult _handleInputKey({
required bool enabled,
required VoidCallback openKeyboard,
required KeyEvent event,
TextInputType? keyboardType,
TextInputAction? textInputAction,
List<TextInputFormatter>? inputFormatters,
ValueChanged<String>? onChanged,
ValueChanged<String>? 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<TextInputFormatter>? inputFormatters,
ValueChanged<String>? onChanged,
ValueChanged<String>? 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<String>? 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<TextInputFormatter>? inputFormatters,
int? maxLength,
ValueChanged<String>? 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<TextInputFormatter>? inputFormatters,
int? maxLength,
ValueChanged<String>? 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<TextInputFormatter>? inputFormatters,
int? maxLength,
ValueChanged<String>? 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<TextInputFormatter>? inputFormatters,
int? maxLength,
ValueChanged<String>? 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<TextInputFormatter>? inputFormatters,
int? maxLength,
ValueChanged<String>? 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,
+8
View File
@@ -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
+3
View File
@@ -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,
),
);
}
+4
View File
@@ -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;
+8 -2
View File
@@ -31,6 +31,7 @@ AndroidTvFeatureDetection detectAndroidTvFromSystemFeatures(Iterable<String> 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<String> tvDetectionReasonsSync() => _instance?._effectiveDetectionReasons ?? const [];
+10 -1
View File
@@ -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;
}
+44
View File
@@ -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);
});
}
+42
View File
@@ -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 = <KeyEvent>[];
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));
});
}
+189
View File
@@ -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 = <String>[];
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<TextField>(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<void> _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;
}
+122
View File
@@ -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<void> _pumpKeyboard(
WidgetTester tester, {
required TextEditingController controller,
TextInputType? keyboardType,
int? maxLines,
ValueChanged<String>? 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;
}