fix(tv): handle native text input completion
This commit is contained in:
@@ -430,6 +430,13 @@ abstract class _FocusableTextInputBase extends StatelessWidget {
|
||||
});
|
||||
|
||||
bool get _hasTvKeyboard => _usesTvKeyboard(enableTvKeyboard);
|
||||
bool get _usesNativeTvKeyboard => PlatformDetector.isTV() && !_hasTvKeyboard;
|
||||
|
||||
VoidCallback? get _effectiveOnEditingComplete {
|
||||
if (onEditingComplete != null) return onEditingComplete;
|
||||
if (_usesNativeTvKeyboard && onSubmitted == null) return _handleTvKeyboardAction;
|
||||
return null;
|
||||
}
|
||||
|
||||
void _showTvKeyboard(BuildContext context) {
|
||||
if (!enabled) return;
|
||||
@@ -445,10 +452,22 @@ abstract class _FocusableTextInputBase extends StatelessWidget {
|
||||
maxLines: maxLines,
|
||||
onChanged: onChanged,
|
||||
onSubmitted: onSubmitted,
|
||||
onAction: onEditingComplete ?? onSelect,
|
||||
onAction: _handleTvKeyboardAction,
|
||||
);
|
||||
}
|
||||
|
||||
void _handleTvKeyboardAction() {
|
||||
if (onEditingComplete != null) {
|
||||
onEditingComplete!();
|
||||
} else if (onSelect != null) {
|
||||
onSelect!();
|
||||
} else if (onNavigateDown != null) {
|
||||
onNavigateDown!();
|
||||
} else {
|
||||
_defaultEditingComplete(textInputAction);
|
||||
}
|
||||
}
|
||||
|
||||
KeyEventResult _handleKey(BuildContext context, FocusNode _, KeyEvent event) {
|
||||
return _handleInputKey(
|
||||
controller: controller,
|
||||
@@ -473,16 +492,84 @@ abstract class _FocusableTextInputBase extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildFocusableInput(BuildContext context, Widget Function(bool usesTvKeyboard) builder) {
|
||||
final usesTvKeyboard = _hasTvKeyboard;
|
||||
return Focus(
|
||||
// This wrapper only intercepts key events bubbling from the input; the
|
||||
// real TextField/TextFormField must remain the traversable focus target.
|
||||
canRequestFocus: false,
|
||||
skipTraversal: true,
|
||||
onKeyEvent: (node, event) => _handleKey(context, node, event),
|
||||
child: builder(usesTvKeyboard),
|
||||
);
|
||||
Widget buildFocusableInput(BuildContext context, Widget Function(bool usesTvKeyboard, FocusNode focusNode) builder) {
|
||||
return _FocusableTextInputHost(input: this, builder: builder);
|
||||
}
|
||||
}
|
||||
|
||||
class _FocusableTextInputHost extends StatefulWidget {
|
||||
final _FocusableTextInputBase input;
|
||||
final Widget Function(bool usesTvKeyboard, FocusNode focusNode) builder;
|
||||
|
||||
const _FocusableTextInputHost({required this.input, required this.builder});
|
||||
|
||||
@override
|
||||
State<_FocusableTextInputHost> createState() => _FocusableTextInputHostState();
|
||||
}
|
||||
|
||||
class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
||||
FocusNode? _ownedFocusNode;
|
||||
FocusNode? _installedFocusNode;
|
||||
FocusOnKeyEventCallback? _previousOnKeyEvent;
|
||||
late final FocusOnKeyEventCallback _keyHandler = _handleKey;
|
||||
|
||||
FocusNode get _effectiveFocusNode =>
|
||||
widget.input.focusNode ?? (_ownedFocusNode ??= FocusNode(debugLabel: 'FocusableTextInput'));
|
||||
|
||||
@override
|
||||
void didUpdateWidget(_FocusableTextInputHost oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.input.focusNode != widget.input.focusNode) {
|
||||
_restoreInstalledHandler();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_restoreInstalledHandler();
|
||||
_ownedFocusNode?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
KeyEventResult _handleKey(FocusNode node, KeyEvent event) {
|
||||
final previous = _previousOnKeyEvent;
|
||||
if (previous != null && !identical(previous, _keyHandler)) {
|
||||
final result = previous(node, event);
|
||||
if (result != KeyEventResult.ignored) return result;
|
||||
}
|
||||
return widget.input._handleKey(context, node, event);
|
||||
}
|
||||
|
||||
void _installKeyHandler(FocusNode node) {
|
||||
// Handle D-pad escapes on the field's own node so EditableText shortcuts
|
||||
// can't consume directions before our reusable navigation callbacks run.
|
||||
if (_installedFocusNode == node) {
|
||||
if (identical(node.onKeyEvent, _keyHandler)) return;
|
||||
_previousOnKeyEvent = node.onKeyEvent;
|
||||
node.onKeyEvent = _keyHandler;
|
||||
return;
|
||||
}
|
||||
|
||||
_restoreInstalledHandler();
|
||||
_installedFocusNode = node;
|
||||
_previousOnKeyEvent = node.onKeyEvent;
|
||||
node.onKeyEvent = _keyHandler;
|
||||
}
|
||||
|
||||
void _restoreInstalledHandler() {
|
||||
final node = _installedFocusNode;
|
||||
if (node != null && identical(node.onKeyEvent, _keyHandler)) {
|
||||
node.onKeyEvent = _previousOnKeyEvent;
|
||||
}
|
||||
_installedFocusNode = null;
|
||||
_previousOnKeyEvent = null;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final focusNode = _effectiveFocusNode;
|
||||
_installKeyHandler(focusNode);
|
||||
return widget.builder(widget.input._hasTvKeyboard, focusNode);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -530,9 +617,9 @@ class FocusableTextField extends _FocusableTextInputBase {
|
||||
Widget build(BuildContext context) {
|
||||
return buildFocusableInput(
|
||||
context,
|
||||
(usesTvKeyboard) => TextField(
|
||||
(usesTvKeyboard, effectiveFocusNode) => TextField(
|
||||
controller: controller,
|
||||
focusNode: focusNode,
|
||||
focusNode: effectiveFocusNode,
|
||||
enabled: enabled,
|
||||
decoration: decoration,
|
||||
keyboardType: usesTvKeyboard ? TextInputType.none : keyboardType,
|
||||
@@ -540,7 +627,7 @@ class FocusableTextField extends _FocusableTextInputBase {
|
||||
inputFormatters: inputFormatters,
|
||||
onChanged: onChanged,
|
||||
onSubmitted: onSubmitted,
|
||||
onEditingComplete: onEditingComplete,
|
||||
onEditingComplete: _effectiveOnEditingComplete,
|
||||
autofocus: autofocus,
|
||||
autocorrect: autocorrect,
|
||||
enableSuggestions: enableSuggestions,
|
||||
@@ -605,9 +692,9 @@ class FocusableTextFormField extends _FocusableTextInputBase {
|
||||
Widget build(BuildContext context) {
|
||||
return buildFocusableInput(
|
||||
context,
|
||||
(usesTvKeyboard) => TextFormField(
|
||||
(usesTvKeyboard, effectiveFocusNode) => TextFormField(
|
||||
controller: controller,
|
||||
focusNode: focusNode,
|
||||
focusNode: effectiveFocusNode,
|
||||
enabled: enabled,
|
||||
decoration: decoration,
|
||||
keyboardType: usesTvKeyboard ? TextInputType.none : keyboardType,
|
||||
@@ -615,7 +702,7 @@ class FocusableTextFormField extends _FocusableTextInputBase {
|
||||
inputFormatters: inputFormatters,
|
||||
onChanged: onChanged,
|
||||
onFieldSubmitted: onFieldSubmitted,
|
||||
onEditingComplete: onEditingComplete,
|
||||
onEditingComplete: _effectiveOnEditingComplete,
|
||||
validator: validator,
|
||||
autovalidateMode: autovalidateMode,
|
||||
onSaved: onSaved,
|
||||
|
||||
@@ -19,6 +19,7 @@ void main() {
|
||||
|
||||
tearDown(() {
|
||||
TvDetectionService.debugSetAppleTVOverride(null);
|
||||
TvDetectionService.setForceTVSync(false);
|
||||
});
|
||||
|
||||
testWidgets('D-pad leaves profile name input and reaches actions', (tester) async {
|
||||
@@ -44,6 +45,27 @@ void main() {
|
||||
expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddLocalProfile:Cancel');
|
||||
});
|
||||
|
||||
testWidgets('Android TV native keyboard done leaves profile name input', (tester) async {
|
||||
TvDetectionService.debugSetAppleTVOverride(null);
|
||||
await TvDetectionService.getInstance(forceTv: true);
|
||||
TvDetectionService.setForceTVSync(true);
|
||||
|
||||
await tester.pumpWidget(
|
||||
TranslationProvider(
|
||||
child: const InputModeTracker(child: MaterialApp(home: AddLocalProfileScreen())),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddLocalProfile:Name');
|
||||
|
||||
await tester.showKeyboard(find.byType(TextField));
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await tester.pump();
|
||||
|
||||
expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddLocalProfile:SetPin');
|
||||
});
|
||||
|
||||
testWidgets('remote back pops the new profile page', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
TranslationProvider(
|
||||
|
||||
@@ -9,9 +9,10 @@ import 'package:plezy/utils/platform_detector.dart';
|
||||
void main() {
|
||||
tearDown(() {
|
||||
TvDetectionService.debugSetAppleTVOverride(null);
|
||||
TvDetectionService.setForceTVSync(false);
|
||||
});
|
||||
|
||||
testWidgets('tab traversal focuses the text form field instead of its key handler wrapper', (tester) async {
|
||||
testWidgets('tab traversal focuses the text form field', (tester) async {
|
||||
final controller = TextEditingController();
|
||||
final fieldFocusNode = FocusNode(debugLabel: 'server_url_field');
|
||||
final buttonFocusNode = FocusNode(debugLabel: 'find_server_button');
|
||||
@@ -43,7 +44,7 @@ void main() {
|
||||
expect(buttonFocusNode.hasFocus, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('focused text form field still receives wrapper select handling', (tester) async {
|
||||
testWidgets('focused text form field still receives select handling', (tester) async {
|
||||
final controller = TextEditingController();
|
||||
final fieldFocusNode = FocusNode(debugLabel: 'server_url_field');
|
||||
var selects = 0;
|
||||
@@ -66,6 +67,116 @@ void main() {
|
||||
expect(selects, 1);
|
||||
});
|
||||
|
||||
testWidgets('d-pad direction handlers are installed on the text field focus node', (tester) async {
|
||||
final controller = TextEditingController();
|
||||
final fieldFocusNode = FocusNode(debugLabel: 'name_field');
|
||||
final nextFocusNode = FocusNode(debugLabel: 'next_button');
|
||||
addTearDown(controller.dispose);
|
||||
addTearDown(fieldFocusNode.dispose);
|
||||
addTearDown(nextFocusNode.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Column(
|
||||
children: [
|
||||
FocusableTextField(
|
||||
controller: controller,
|
||||
focusNode: fieldFocusNode,
|
||||
onNavigateDown: nextFocusNode.requestFocus,
|
||||
),
|
||||
FilledButton(focusNode: nextFocusNode, onPressed: () {}, child: const Text('Next')),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
fieldFocusNode.requestFocus();
|
||||
await tester.pump();
|
||||
final handler = fieldFocusNode.onKeyEvent;
|
||||
|
||||
expect(handler, isNotNull);
|
||||
final result = handler!(
|
||||
fieldFocusNode,
|
||||
const KeyDownEvent(
|
||||
physicalKey: PhysicalKeyboardKey.arrowDown,
|
||||
logicalKey: LogicalKeyboardKey.arrowDown,
|
||||
timeStamp: Duration.zero,
|
||||
deviceType: ui.KeyEventDeviceType.directionalPad,
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(result, KeyEventResult.handled);
|
||||
expect(nextFocusNode.hasPrimaryFocus, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('existing focus node key handler is preserved before text field navigation', (tester) async {
|
||||
final controller = TextEditingController();
|
||||
final handledKeys = <LogicalKeyboardKey>[];
|
||||
final fieldFocusNode = FocusNode(
|
||||
debugLabel: 'custom_field',
|
||||
onKeyEvent: (_, event) {
|
||||
if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.arrowUp) {
|
||||
handledKeys.add(event.logicalKey);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
},
|
||||
);
|
||||
final nextFocusNode = FocusNode(debugLabel: 'next_button');
|
||||
addTearDown(controller.dispose);
|
||||
addTearDown(fieldFocusNode.dispose);
|
||||
addTearDown(nextFocusNode.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Column(
|
||||
children: [
|
||||
FocusableTextField(
|
||||
controller: controller,
|
||||
focusNode: fieldFocusNode,
|
||||
onNavigateDown: nextFocusNode.requestFocus,
|
||||
),
|
||||
FilledButton(focusNode: nextFocusNode, onPressed: () {}, child: const Text('Next')),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
fieldFocusNode.requestFocus();
|
||||
await tester.pump();
|
||||
final handler = fieldFocusNode.onKeyEvent!;
|
||||
|
||||
final customResult = handler(
|
||||
fieldFocusNode,
|
||||
const KeyDownEvent(
|
||||
physicalKey: PhysicalKeyboardKey.arrowUp,
|
||||
logicalKey: LogicalKeyboardKey.arrowUp,
|
||||
timeStamp: Duration.zero,
|
||||
deviceType: ui.KeyEventDeviceType.directionalPad,
|
||||
),
|
||||
);
|
||||
final navigationResult = handler(
|
||||
fieldFocusNode,
|
||||
const KeyDownEvent(
|
||||
physicalKey: PhysicalKeyboardKey.arrowDown,
|
||||
logicalKey: LogicalKeyboardKey.arrowDown,
|
||||
timeStamp: Duration.zero,
|
||||
deviceType: ui.KeyEventDeviceType.directionalPad,
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(customResult, KeyEventResult.handled);
|
||||
expect(handledKeys, [LogicalKeyboardKey.arrowUp]);
|
||||
expect(navigationResult, KeyEventResult.handled);
|
||||
expect(nextFocusNode.hasPrimaryFocus, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('tvOS keyboard enter does not open virtual keyboard', (tester) async {
|
||||
TvDetectionService.debugSetAppleTVOverride(true);
|
||||
final controller = TextEditingController();
|
||||
@@ -126,6 +237,45 @@ void main() {
|
||||
expect(find.byType(Dialog), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Android TV native keyboard done uses D-pad navigation', (tester) async {
|
||||
TvDetectionService.debugSetAppleTVOverride(null);
|
||||
await TvDetectionService.getInstance(forceTv: true);
|
||||
TvDetectionService.setForceTVSync(true);
|
||||
final controller = TextEditingController();
|
||||
final fieldFocusNode = FocusNode(debugLabel: 'name_field');
|
||||
final nextFocusNode = FocusNode(debugLabel: 'next_button');
|
||||
addTearDown(controller.dispose);
|
||||
addTearDown(fieldFocusNode.dispose);
|
||||
addTearDown(nextFocusNode.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Column(
|
||||
children: [
|
||||
FocusableTextField(
|
||||
controller: controller,
|
||||
focusNode: fieldFocusNode,
|
||||
textInputAction: TextInputAction.done,
|
||||
onNavigateDown: nextFocusNode.requestFocus,
|
||||
),
|
||||
FilledButton(focusNode: nextFocusNode, onPressed: () {}, child: const Text('Next')),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
fieldFocusNode.requestFocus();
|
||||
await tester.pump();
|
||||
await tester.showKeyboard(find.byType(TextField));
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await tester.pump();
|
||||
|
||||
expect(nextFocusNode.hasPrimaryFocus, isTrue);
|
||||
expect(find.byType(Dialog), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('tvOS engine-synthesized select opens the virtual keyboard', (tester) async {
|
||||
// The custom Flutter tvOS engine emits Siri Remote center-dpad presses
|
||||
// as `LogicalKeyboardKey.select` with `deviceType=keyboard` (via the
|
||||
|
||||
Reference in New Issue
Block a user