fix(tv): support hardware keyboard input
This commit is contained in:
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user