1316 lines
45 KiB
Dart
1316 lines
45 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
|
|
import '../services/gamepad_service.dart';
|
|
import '../utils/platform_detector.dart';
|
|
import '../utils/text_input_diagnostics.dart';
|
|
import '../widgets/tv_virtual_keyboard.dart';
|
|
import 'dpad_navigator.dart';
|
|
import 'key_event_utils.dart';
|
|
import 'owned_focus_node_binding.dart';
|
|
|
|
enum TvTextInputPresentation {
|
|
/// Use the native platform keyboard for single-line Apple TV input and the
|
|
/// Flutter overlay on other TVs or for multiline input.
|
|
automatic,
|
|
|
|
/// Always use the platform text input implementation.
|
|
platform,
|
|
|
|
/// Always use Plezy's in-app Flutter keyboard overlay.
|
|
flutterOverlay,
|
|
}
|
|
|
|
bool _usesTvKeyboard({required TvTextInputPresentation presentation, TextInputType? keyboardType, int? maxLines}) {
|
|
if (!PlatformDetector.isTV()) return false;
|
|
return switch (presentation) {
|
|
TvTextInputPresentation.automatic =>
|
|
!PlatformDetector.isAppleTV() || _isMultilineTextInput(keyboardType: keyboardType, maxLines: maxLines),
|
|
TvTextInputPresentation.platform => false,
|
|
TvTextInputPresentation.flutterOverlay => true,
|
|
};
|
|
}
|
|
|
|
String? _keyboardHint(InputDecoration? decoration) => decoration?.hintText ?? decoration?.labelText;
|
|
|
|
enum TvTextInputAutoOpenBehavior {
|
|
/// Open the selected TV text input presentation whenever the field receives
|
|
/// focus.
|
|
onFocus,
|
|
|
|
/// Keep initial focus on the field without opening text input, then open it
|
|
/// automatically on later focus entries. Explicit tap/select still opens it.
|
|
afterFirstFocus,
|
|
|
|
/// Never auto-open text input on focus. Explicit tap/select still opens it.
|
|
never,
|
|
}
|
|
|
|
/// Imperative handle to TV text input for a [FocusableTextField] /
|
|
/// [FocusableTextFormField]. Pass the same instance to the field's
|
|
/// `tvTextInputController`; the field's host attaches itself on mount.
|
|
///
|
|
/// Only meaningful on TV. On other platforms every method is an effective
|
|
/// no-op.
|
|
class TvTextInputController {
|
|
_FocusableTextInputHostState? _host;
|
|
|
|
void _attach(_FocusableTextInputHostState host) => _host = host;
|
|
void _detach(_FocusableTextInputHostState host) {
|
|
if (identical(_host, host)) _host = null;
|
|
}
|
|
|
|
/// Dismiss the Flutter overlay if it is open and prevent it from reopening
|
|
/// while the field keeps focus.
|
|
void closeTextInput() => _host?._dismissTvKeyboard();
|
|
|
|
/// Focus the field without opening either native or Flutter text input for
|
|
/// this focus entry.
|
|
void focusInputWithoutOpening() => _host?._focusWithoutKeyboard();
|
|
}
|
|
|
|
String _describeTextInputKey(KeyEvent event) {
|
|
return 'type=${event.runtimeType} logical=${event.logicalKey.keyLabel}/${event.logicalKey.keyId} '
|
|
'physical=${event.physicalKey.usbHidUsage} deviceType=${event.deviceType} character=${event.character}';
|
|
}
|
|
|
|
void _logTvTextInput(String message) {
|
|
TextInputDiagnostics.log('FlutterTextField', message);
|
|
}
|
|
|
|
class _NativeTvTextInputFocusBridge {
|
|
static const _channel = MethodChannel('com.plezy/text_input');
|
|
static final Set<Object> _focusedTokens = <Object>{};
|
|
static bool _lastSentFocused = false;
|
|
|
|
static void setFocused(Object token, bool focused) {
|
|
_logTvTextInput(
|
|
'NativeFocusBridge.setFocused requested focused=$focused token=$token activeTokens=${_focusedTokens.length}',
|
|
);
|
|
if (focused) {
|
|
_focusedTokens.add(token);
|
|
} else {
|
|
_focusedTokens.remove(token);
|
|
}
|
|
|
|
if (!PlatformDetector.isTV() || PlatformDetector.isAppleTV()) {
|
|
_logTvTextInput(
|
|
'NativeFocusBridge clearing without platform send isTv=${PlatformDetector.isTV()} '
|
|
'isAppleTV=${PlatformDetector.isAppleTV()}',
|
|
);
|
|
_focusedTokens.clear();
|
|
_lastSentFocused = false;
|
|
return;
|
|
}
|
|
|
|
final anyFocused = _focusedTokens.isNotEmpty;
|
|
if (_lastSentFocused == anyFocused) {
|
|
_logTvTextInput('NativeFocusBridge no-op anyFocused=$anyFocused tokenCount=${_focusedTokens.length}');
|
|
return;
|
|
}
|
|
_lastSentFocused = anyFocused;
|
|
_logTvTextInput('NativeFocusBridge sending anyFocused=$anyFocused tokenCount=${_focusedTokens.length}');
|
|
unawaited(GamepadService.setNativeTextInputFocused(anyFocused));
|
|
unawaited(_sendFocused(anyFocused));
|
|
}
|
|
|
|
static Future<void> _sendFocused(bool focused) async {
|
|
try {
|
|
await _channel.invokeMethod<void>('setNativeTextInputFocused', focused);
|
|
_logTvTextInput('NativeFocusBridge platform send complete focused=$focused');
|
|
} on MissingPluginException {
|
|
_logTvTextInput('NativeFocusBridge platform send missing plugin focused=$focused');
|
|
// Tests and non-Android embedders do not register this channel.
|
|
} on PlatformException {
|
|
_logTvTextInput('NativeFocusBridge platform send failed focused=$focused');
|
|
// Focus reporting is a best-effort native routing hint.
|
|
}
|
|
}
|
|
}
|
|
|
|
KeyEventResult _handleInputKey({
|
|
required TextEditingController controller,
|
|
required FocusNode node,
|
|
required bool usesTvKeyboard,
|
|
required bool enabled,
|
|
required VoidCallback openKeyboard,
|
|
required bool activateNativeTextInput,
|
|
required VoidCallback activateNativeTextInputCallback,
|
|
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,
|
|
VoidCallback? onNavigateRight,
|
|
VoidCallback? onNavigateUp,
|
|
VoidCallback? onNavigateDown,
|
|
}) {
|
|
final key = event.logicalKey;
|
|
final diagnosticsEnabled = TextInputDiagnostics.enabled;
|
|
KeyEventResult finish(KeyEventResult result, String reason) {
|
|
if (diagnosticsEnabled) {
|
|
_logTvTextInput(
|
|
'result=$result reason=$reason key=(${_describeTextInputKey(event)}) '
|
|
'usesTvKeyboard=$usesTvKeyboard enabled=$enabled textLength=${controller.text.length} '
|
|
'selection=${controller.selection} onNav(up=${onNavigateUp != null},down=${onNavigateDown != null},'
|
|
'left=${onNavigateLeft != null},right=${onNavigateRight != null}) onSelect=${onSelect != null} onBack=${onBack != null}',
|
|
);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
if (diagnosticsEnabled) {
|
|
_logTvTextInput(
|
|
'received key=(${_describeTextInputKey(event)}) usesTvKeyboard=$usesTvKeyboard enabled=$enabled '
|
|
'textLength=${controller.text.length} selection=${controller.selection}',
|
|
);
|
|
}
|
|
|
|
if (activateNativeTextInput && enabled && event.isTvSelectEvent) {
|
|
if (event is KeyDownEvent) activateNativeTextInputCallback();
|
|
return finish(KeyEventResult.handled, 'activate-native-tv-text-input');
|
|
}
|
|
|
|
if (_shouldPassNativeTvKeyToPlatform(
|
|
usesTvKeyboard: usesTvKeyboard,
|
|
nativeTextInputActive: !activateNativeTextInput,
|
|
enabled: enabled,
|
|
event: event,
|
|
)) {
|
|
return finish(KeyEventResult.skipRemainingHandlers, 'pass-native-tv-key-to-platform');
|
|
}
|
|
|
|
if (usesTvKeyboard && enabled && event.isTvSelectEvent) {
|
|
if (event is KeyDownEvent) openKeyboard();
|
|
return finish(KeyEventResult.handled, 'open-custom-tv-keyboard');
|
|
}
|
|
|
|
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 finish(result, 'custom-tv-hardware-keyboard');
|
|
}
|
|
|
|
if (onBack != null && key.isBackKey) {
|
|
if (event is KeyDownEvent) onBack();
|
|
return finish(KeyEventResult.handled, 'onBack');
|
|
}
|
|
|
|
// Enter/numpad enter are left to TextField.onSubmitted. Handle only
|
|
// non-text submit keys that TV remotes/gamepads may send while editing.
|
|
if (!usesTvKeyboard &&
|
|
onSelect != null &&
|
|
(key == LogicalKeyboardKey.select || key == LogicalKeyboardKey.gameButtonA)) {
|
|
if (event is KeyDownEvent) onSelect();
|
|
return finish(KeyEventResult.handled, 'native-tv-non-text-select');
|
|
}
|
|
|
|
if (!event.isActionable) return finish(KeyEventResult.ignored, 'non-actionable');
|
|
|
|
final isMultiline = _isMultilineTextInput(keyboardType: keyboardType, maxLines: maxLines);
|
|
|
|
// Directional escape: an explicit callback always wins. Otherwise, fall back
|
|
// to the framework's geometry-based directional traversal so an un-wired
|
|
// field moves to its nearest neighbour instead of dead-ending (the field's
|
|
// own onKeyEvent runs before EditableText, which would otherwise swallow the
|
|
// arrow). Single-line only for UP/DOWN — multiline falls through so
|
|
// EditableText can move the caret between lines.
|
|
if (key.isUpKey) {
|
|
if (onNavigateUp != null) {
|
|
onNavigateUp();
|
|
return finish(KeyEventResult.handled, 'onNavigateUp');
|
|
}
|
|
if (!isMultiline) {
|
|
final moved = node.focusInDirection(TraversalDirection.up);
|
|
return finish(moved ? KeyEventResult.handled : KeyEventResult.ignored, 'focusInDirection-up');
|
|
}
|
|
}
|
|
if (key.isDownKey) {
|
|
if (onNavigateDown != null) {
|
|
onNavigateDown();
|
|
return finish(KeyEventResult.handled, 'onNavigateDown');
|
|
}
|
|
if (!isMultiline) {
|
|
final moved = node.focusInDirection(TraversalDirection.down);
|
|
return finish(moved ? KeyEventResult.handled : KeyEventResult.ignored, 'focusInDirection-down');
|
|
}
|
|
}
|
|
|
|
final sel = controller.selection;
|
|
if (sel.isCollapsed) {
|
|
if (key.isLeftKey && sel.baseOffset == 0) {
|
|
if (onNavigateLeft != null) {
|
|
onNavigateLeft();
|
|
return finish(KeyEventResult.handled, 'onNavigateLeft-at-start');
|
|
}
|
|
final moved = node.focusInDirection(TraversalDirection.left);
|
|
return finish(moved ? KeyEventResult.handled : KeyEventResult.ignored, 'focusInDirection-left-at-start');
|
|
}
|
|
if (key.isRightKey && sel.baseOffset == controller.text.length) {
|
|
if (onNavigateRight != null) {
|
|
onNavigateRight();
|
|
return finish(KeyEventResult.handled, 'onNavigateRight-at-end');
|
|
}
|
|
final moved = node.focusInDirection(TraversalDirection.right);
|
|
return finish(moved ? KeyEventResult.handled : KeyEventResult.ignored, 'focusInDirection-right-at-end');
|
|
}
|
|
}
|
|
|
|
return finish(KeyEventResult.ignored, 'fall-through');
|
|
}
|
|
|
|
bool _shouldPassNativeTvKeyToPlatform({
|
|
required bool usesTvKeyboard,
|
|
required bool nativeTextInputActive,
|
|
required bool enabled,
|
|
required KeyEvent event,
|
|
}) {
|
|
if (!enabled || usesTvKeyboard || !nativeTextInputActive || !PlatformDetector.isTV()) {
|
|
if (TextInputDiagnostics.enabled) {
|
|
_logTvTextInput(
|
|
'native-pass=false reason=inactive-disabled-or-custom enabled=$enabled '
|
|
'usesTvKeyboard=$usesTvKeyboard nativeTextInputActive=$nativeTextInputActive '
|
|
'isTv=${PlatformDetector.isTV()} key=(${_describeTextInputKey(event)})',
|
|
);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// Android TV provides its own IME. Remote keys must reach the platform so
|
|
// users can move around that keyboard instead of escaping the app field.
|
|
// Some remotes (Chromecast) are reported by Flutter as keyboard events, so
|
|
// native TV navigation cannot rely on deviceType.
|
|
final key = event.logicalKey;
|
|
final shouldPass = key.isDpadDirection || key.isBackKey || event.isTvSelectEvent;
|
|
if (TextInputDiagnostics.enabled) {
|
|
_logTvTextInput(
|
|
'native-pass=$shouldPass reason=${shouldPass ? "remote-navigation-key" : "not-navigation-key"} '
|
|
'key=(${_describeTextInputKey(event)})',
|
|
);
|
|
}
|
|
return shouldPass;
|
|
}
|
|
|
|
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 range = expandToGraphemeRange(value.text, selection);
|
|
controller.selection = TextSelection.collapsed(offset: delta < 0 ? range.start : range.end);
|
|
return KeyEventResult.handled;
|
|
}
|
|
|
|
final offset = selection.extentOffset.clamp(0, value.text.length);
|
|
if ((delta < 0 && offset == 0) || (delta > 0 && offset == value.text.length)) {
|
|
return KeyEventResult.ignored;
|
|
}
|
|
final codeUnitRange = delta < 0
|
|
? TextRange(start: offset - 1, end: offset)
|
|
: TextRange(start: offset, end: offset + 1);
|
|
final range = expandToGraphemeRange(value.text, codeUnitRange);
|
|
controller.selection = TextSelection.collapsed(offset: delta < 0 ? range.start : range.end);
|
|
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();
|
|
}
|
|
}
|
|
|
|
({int start, int end}) _normalizedSelectionRange(TextEditingValue value) {
|
|
final selection = value.selection;
|
|
if (!selection.isValid) {
|
|
return (start: value.text.length, end: value.text.length);
|
|
}
|
|
return (start: selection.start.clamp(0, value.text.length), end: selection.end.clamp(0, value.text.length));
|
|
}
|
|
|
|
void _insertText({
|
|
required TextEditingController controller,
|
|
required String text,
|
|
List<TextInputFormatter>? inputFormatters,
|
|
int? maxLength,
|
|
ValueChanged<String>? onChanged,
|
|
}) {
|
|
final value = controller.value;
|
|
final range = _normalizedSelectionRange(value);
|
|
final start = range.start;
|
|
final end = range.end;
|
|
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 selectionRange = _normalizedSelectionRange(value);
|
|
final start = selectionRange.start;
|
|
final end = selectionRange.end;
|
|
if (start == end && start == 0) return;
|
|
|
|
final codeUnitRange = start == end ? TextRange(start: start - 1, end: start) : TextRange(start: start, end: end);
|
|
final range = expandToGraphemeRange(value.text, codeUnitRange);
|
|
if (range.isCollapsed) return;
|
|
_replaceTextRange(
|
|
controller,
|
|
range.start,
|
|
range.end,
|
|
inputFormatters: inputFormatters,
|
|
maxLength: maxLength,
|
|
onChanged: onChanged,
|
|
);
|
|
}
|
|
|
|
void _deleteForward({
|
|
required TextEditingController controller,
|
|
List<TextInputFormatter>? inputFormatters,
|
|
int? maxLength,
|
|
ValueChanged<String>? onChanged,
|
|
}) {
|
|
final value = controller.value;
|
|
final selectionRange = _normalizedSelectionRange(value);
|
|
final start = selectionRange.start;
|
|
final end = selectionRange.end;
|
|
if (start == end && start >= value.text.length) return;
|
|
|
|
final codeUnitRange = start == end ? TextRange(start: start, end: start + 1) : TextRange(start: start, end: end);
|
|
final range = expandToGraphemeRange(value.text, codeUnitRange);
|
|
if (range.isCollapsed) return;
|
|
_replaceTextRange(
|
|
controller,
|
|
range.start,
|
|
range.end,
|
|
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;
|
|
final InputDecoration? decoration;
|
|
final TextInputType? keyboardType;
|
|
final TextInputAction? textInputAction;
|
|
final List<TextInputFormatter>? inputFormatters;
|
|
final ValueChanged<String>? onChanged;
|
|
final ValueChanged<String>? onSubmitted;
|
|
final VoidCallback? onEditingComplete;
|
|
final VoidCallback? onSelect;
|
|
final VoidCallback? onBack;
|
|
final bool autofocus;
|
|
final bool enabled;
|
|
final TvTextInputPresentation tvTextInputPresentation;
|
|
final TvTextInputAutoOpenBehavior tvTextInputAutoOpenBehavior;
|
|
final TvTextInputController? tvTextInputController;
|
|
final bool obscureText;
|
|
final bool autocorrect;
|
|
final bool enableSuggestions;
|
|
final bool? enableInteractiveSelection;
|
|
final int? maxLength;
|
|
final int? maxLines;
|
|
final int? minLines;
|
|
final TextAlign textAlign;
|
|
final TextCapitalization textCapitalization;
|
|
final TextStyle? style;
|
|
|
|
final VoidCallback? onNavigateLeft;
|
|
final VoidCallback? onNavigateRight;
|
|
final VoidCallback? onNavigateUp;
|
|
final VoidCallback? onNavigateDown;
|
|
|
|
const _FocusableTextInputBase({
|
|
super.key,
|
|
required this.controller,
|
|
this.focusNode,
|
|
this.decoration,
|
|
this.keyboardType,
|
|
this.textInputAction,
|
|
this.inputFormatters,
|
|
this.onChanged,
|
|
this.onSubmitted,
|
|
this.onEditingComplete,
|
|
this.onSelect,
|
|
this.onBack,
|
|
this.autofocus = false,
|
|
this.enabled = true,
|
|
this.tvTextInputPresentation = TvTextInputPresentation.automatic,
|
|
this.tvTextInputAutoOpenBehavior = TvTextInputAutoOpenBehavior.onFocus,
|
|
this.tvTextInputController,
|
|
this.obscureText = false,
|
|
this.autocorrect = true,
|
|
this.enableSuggestions = true,
|
|
this.enableInteractiveSelection,
|
|
this.maxLength,
|
|
this.maxLines = 1,
|
|
this.minLines,
|
|
this.textAlign = TextAlign.start,
|
|
this.textCapitalization = TextCapitalization.none,
|
|
this.style,
|
|
this.onNavigateLeft,
|
|
this.onNavigateRight,
|
|
this.onNavigateUp,
|
|
this.onNavigateDown,
|
|
});
|
|
|
|
bool get _hasTvKeyboard =>
|
|
_usesTvKeyboard(presentation: tvTextInputPresentation, keyboardType: keyboardType, maxLines: maxLines);
|
|
bool get _usesNativeTvKeyboard => PlatformDetector.isTV() && !_hasTvKeyboard;
|
|
|
|
VoidCallback? get _effectiveOnEditingComplete {
|
|
if (onEditingComplete != null) return onEditingComplete;
|
|
if (_usesNativeTvKeyboard && onSubmitted == null) return _handleTvKeyboardAction;
|
|
return null;
|
|
}
|
|
|
|
void _handleTvKeyboardAction() {
|
|
if (onEditingComplete != null) {
|
|
onEditingComplete!();
|
|
} else if (onSelect != null) {
|
|
onSelect!();
|
|
} else if (onNavigateDown != null) {
|
|
onNavigateDown!();
|
|
} else {
|
|
_defaultEditingComplete(textInputAction);
|
|
}
|
|
}
|
|
|
|
({
|
|
TextInputType? keyboardType,
|
|
bool readOnly,
|
|
bool? showCursor,
|
|
bool? enableInteractiveSelection,
|
|
VoidCallback? onTap,
|
|
})
|
|
_tvInputConfiguration({
|
|
required bool usesTvKeyboard,
|
|
required bool nativeTextInputReadOnly,
|
|
required VoidCallback openKeyboard,
|
|
required VoidCallback activateNativeTextInput,
|
|
}) {
|
|
return (
|
|
keyboardType: usesTvKeyboard ? TextInputType.none : keyboardType,
|
|
readOnly: usesTvKeyboard || nativeTextInputReadOnly,
|
|
showCursor: usesTvKeyboard || nativeTextInputReadOnly ? true : null,
|
|
enableInteractiveSelection: usesTvKeyboard ? false : enableInteractiveSelection,
|
|
onTap: usesTvKeyboard
|
|
? openKeyboard
|
|
: nativeTextInputReadOnly
|
|
? activateNativeTextInput
|
|
: null,
|
|
);
|
|
}
|
|
|
|
KeyEventResult _handleKey(
|
|
BuildContext context,
|
|
FocusNode node,
|
|
KeyEvent event,
|
|
VoidCallback openKeyboard, {
|
|
required bool activateNativeTextInput,
|
|
required VoidCallback activateNativeTextInputCallback,
|
|
}) {
|
|
return _handleInputKey(
|
|
controller: controller,
|
|
node: node,
|
|
usesTvKeyboard: _hasTvKeyboard,
|
|
enabled: enabled,
|
|
openKeyboard: openKeyboard,
|
|
activateNativeTextInput: activateNativeTextInput,
|
|
activateNativeTextInputCallback: activateNativeTextInputCallback,
|
|
event: event,
|
|
keyboardType: keyboardType,
|
|
textInputAction: textInputAction,
|
|
inputFormatters: inputFormatters,
|
|
onChanged: onChanged,
|
|
onSubmitted: onSubmitted,
|
|
onEditingComplete: onEditingComplete,
|
|
maxLength: maxLength,
|
|
maxLines: maxLines,
|
|
onSelect: onSelect,
|
|
onBack: onBack,
|
|
onNavigateLeft: onNavigateLeft,
|
|
onNavigateRight: onNavigateRight,
|
|
onNavigateUp: onNavigateUp,
|
|
onNavigateDown: onNavigateDown,
|
|
);
|
|
}
|
|
|
|
Widget buildFocusableInput(_FocusableTextInputBuilder builder) {
|
|
return _FocusableTextInputHost(input: this, builder: builder);
|
|
}
|
|
}
|
|
|
|
typedef _FocusableTextInputBuilder =
|
|
Widget Function({
|
|
required bool usesTvKeyboard,
|
|
required bool nativeTextInputReadOnly,
|
|
required FocusNode focusNode,
|
|
required VoidCallback openKeyboard,
|
|
required VoidCallback activateNativeTextInput,
|
|
});
|
|
|
|
class _FocusableTextInputHost extends StatefulWidget {
|
|
final _FocusableTextInputBase input;
|
|
final _FocusableTextInputBuilder builder;
|
|
|
|
const _FocusableTextInputHost({required this.input, required this.builder});
|
|
|
|
@override
|
|
State<_FocusableTextInputHost> createState() => _FocusableTextInputHostState();
|
|
}
|
|
|
|
class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
|
final OwnedFocusNodeBinding _focusNodeBinding = OwnedFocusNodeBinding();
|
|
FocusNode? _installedFocusNode;
|
|
FocusOnKeyEventCallback? _previousOnKeyEvent;
|
|
late final FocusOnKeyEventCallback _keyHandler = _handleKey;
|
|
late final VoidCallback _focusListener = _handleFocusChanged;
|
|
final Object _nativeFocusToken = Object();
|
|
bool _reportedNativeTextInputFocused = false;
|
|
TvVirtualKeyboardHandle? _tvKeyboardHandle;
|
|
bool _tvKeyboardOpen = false;
|
|
bool _tvKeyboardOpenScheduled = false;
|
|
bool _suppressTvKeyboardAutoOpen = false;
|
|
bool _hasSeenTvKeyboardFocus = false;
|
|
bool _suppressTvKeyboardForCurrentFocus = false;
|
|
bool _nativeTextInputActivated = false;
|
|
bool _hasSeenNativeTextInputFocus = false;
|
|
bool _suppressNativeTextInputForCurrentFocus = false;
|
|
|
|
FocusNode get _effectiveFocusNode => _focusNodeBinding.node;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_focusNodeBinding.bind(externalNode: widget.input.focusNode, debugLabel: 'FocusableTextInput');
|
|
widget.input.tvTextInputController?._attach(this);
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(_FocusableTextInputHost oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
if (!identical(oldWidget.input.tvTextInputController, widget.input.tvTextInputController)) {
|
|
oldWidget.input.tvTextInputController?._detach(this);
|
|
widget.input.tvTextInputController?._attach(this);
|
|
}
|
|
if (oldWidget.input.focusNode != widget.input.focusNode) {
|
|
// An open keyboard dialog intentionally survives rebuilds and focusNode
|
|
// swaps; it is closed only when this host unmounts — see dispose.
|
|
_restoreInstalledHandler();
|
|
_focusNodeBinding.bind(externalNode: widget.input.focusNode, debugLabel: 'FocusableTextInput');
|
|
_suppressTvKeyboardAutoOpen = false;
|
|
_tvKeyboardOpenScheduled = false;
|
|
_hasSeenTvKeyboardFocus = false;
|
|
_suppressTvKeyboardForCurrentFocus = false;
|
|
_nativeTextInputActivated = false;
|
|
_hasSeenNativeTextInputFocus = false;
|
|
_suppressNativeTextInputForCurrentFocus = false;
|
|
}
|
|
if (oldWidget.input.tvTextInputPresentation != widget.input.tvTextInputPresentation ||
|
|
oldWidget.input.tvTextInputAutoOpenBehavior != widget.input.tvTextInputAutoOpenBehavior) {
|
|
_nativeTextInputActivated = false;
|
|
_hasSeenNativeTextInputFocus = false;
|
|
_suppressNativeTextInputForCurrentFocus = false;
|
|
}
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) _handleFocusChanged();
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
widget.input.tvTextInputController?._detach(this);
|
|
_restoreInstalledHandler();
|
|
// The keyboard is a navigator route — it must not outlive the field that
|
|
// opened it (e.g. a form section swapped out while the keyboard is up).
|
|
// Navigator mutation is unsafe during tree finalization; defer a frame.
|
|
final keyboard = _tvKeyboardHandle;
|
|
if (keyboard != null) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) => keyboard.close());
|
|
}
|
|
_focusNodeBinding.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _handleFocusChanged() {
|
|
_syncNativeTextInputActivation();
|
|
_syncNativeTextInputFocus();
|
|
_syncTvKeyboardAutoOpen();
|
|
}
|
|
|
|
void _syncNativeTextInputActivation() {
|
|
final input = widget.input;
|
|
final focused = _installedFocusNode?.hasFocus == true && input.enabled && input._usesNativeTvKeyboard;
|
|
if (!focused) {
|
|
_suppressNativeTextInputForCurrentFocus = false;
|
|
_setNativeTextInputActivated(false);
|
|
return;
|
|
}
|
|
if (_suppressNativeTextInputForCurrentFocus) return;
|
|
|
|
switch (input.tvTextInputAutoOpenBehavior) {
|
|
case TvTextInputAutoOpenBehavior.onFocus:
|
|
_hasSeenNativeTextInputFocus = true;
|
|
_setNativeTextInputActivated(true);
|
|
case TvTextInputAutoOpenBehavior.afterFirstFocus:
|
|
if (!_hasSeenNativeTextInputFocus) {
|
|
_hasSeenNativeTextInputFocus = true;
|
|
_suppressNativeTextInputForCurrentFocus = true;
|
|
return;
|
|
}
|
|
_setNativeTextInputActivated(true);
|
|
case TvTextInputAutoOpenBehavior.never:
|
|
return;
|
|
}
|
|
}
|
|
|
|
void _setNativeTextInputActivated(bool activated) {
|
|
if (_nativeTextInputActivated == activated) return;
|
|
if (!mounted) {
|
|
_nativeTextInputActivated = activated;
|
|
return;
|
|
}
|
|
setState(() => _nativeTextInputActivated = activated);
|
|
}
|
|
|
|
void _activateNativeTextInput() {
|
|
if (!widget.input.enabled || !widget.input._usesNativeTvKeyboard) return;
|
|
_hasSeenNativeTextInputFocus = true;
|
|
_suppressNativeTextInputForCurrentFocus = false;
|
|
_setNativeTextInputActivated(true);
|
|
}
|
|
|
|
void _syncNativeTextInputFocus() {
|
|
final focused = _installedFocusNode?.hasFocus == true && widget.input.enabled && widget.input._usesNativeTvKeyboard;
|
|
if (TextInputDiagnostics.enabled) {
|
|
_logTvTextInput(
|
|
'Host.syncNativeTextInputFocus focused=$focused installed=${_installedFocusNode?.debugLabel} '
|
|
'hasFocus=${_installedFocusNode?.hasFocus} enabled=${widget.input.enabled} '
|
|
'usesNativeTvKeyboard=${widget.input._usesNativeTvKeyboard}',
|
|
);
|
|
}
|
|
_setNativeTextInputFocused(focused);
|
|
}
|
|
|
|
void _syncTvKeyboardAutoOpen() {
|
|
final focused = _installedFocusNode?.hasFocus == true && widget.input.enabled && widget.input._hasTvKeyboard;
|
|
final visible = _canShowTvKeyboard;
|
|
if (TextInputDiagnostics.enabled) {
|
|
_logTvTextInput(
|
|
'Host.syncTvKeyboardAutoOpen focused=$focused open=$_tvKeyboardOpen scheduled=$_tvKeyboardOpenScheduled '
|
|
'suppressed=$_suppressTvKeyboardAutoOpen behavior=${widget.input.tvTextInputAutoOpenBehavior} '
|
|
'seenFocus=$_hasSeenTvKeyboardFocus suppressCurrent=$_suppressTvKeyboardForCurrentFocus '
|
|
'installed=${_installedFocusNode?.debugLabel} '
|
|
'hasFocus=${_installedFocusNode?.hasFocus} enabled=${widget.input.enabled} '
|
|
'usesTvKeyboard=${widget.input._hasTvKeyboard} visible=$visible',
|
|
);
|
|
}
|
|
|
|
if (!focused) {
|
|
_suppressTvKeyboardForCurrentFocus = false;
|
|
if (!_tvKeyboardOpen && !_tvKeyboardOpenScheduled) {
|
|
_suppressTvKeyboardAutoOpen = false;
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!visible) return;
|
|
if (!_shouldAutoOpenTvKeyboardForCurrentFocus()) return;
|
|
if (_suppressTvKeyboardAutoOpen || _tvKeyboardOpen || _tvKeyboardOpenScheduled) return;
|
|
|
|
_tvKeyboardOpenScheduled = true;
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!mounted) return;
|
|
_tvKeyboardOpenScheduled = false;
|
|
final stillFocused = _installedFocusNode?.hasFocus == true && widget.input.enabled && widget.input._hasTvKeyboard;
|
|
if (!stillFocused || !_canShowTvKeyboard || _suppressTvKeyboardAutoOpen || _tvKeyboardOpen) return;
|
|
_openTvKeyboard();
|
|
});
|
|
}
|
|
|
|
bool get _canShowTvKeyboard {
|
|
final route = ModalRoute.of(context);
|
|
return TickerMode.valuesOf(context).enabled && (route?.isCurrent ?? true);
|
|
}
|
|
|
|
bool _shouldAutoOpenTvKeyboardForCurrentFocus() {
|
|
switch (widget.input.tvTextInputAutoOpenBehavior) {
|
|
case TvTextInputAutoOpenBehavior.onFocus:
|
|
return true;
|
|
case TvTextInputAutoOpenBehavior.afterFirstFocus:
|
|
if (!_hasSeenTvKeyboardFocus) {
|
|
_hasSeenTvKeyboardFocus = true;
|
|
_suppressTvKeyboardForCurrentFocus = true;
|
|
return false;
|
|
}
|
|
return !_suppressTvKeyboardForCurrentFocus;
|
|
case TvTextInputAutoOpenBehavior.never:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
void _openTvKeyboard() {
|
|
if (!mounted || !widget.input.enabled || !widget.input._hasTvKeyboard || !_canShowTvKeyboard || _tvKeyboardOpen) {
|
|
return;
|
|
}
|
|
|
|
_tvKeyboardOpenScheduled = false;
|
|
_tvKeyboardOpen = true;
|
|
_hasSeenTvKeyboardFocus = true;
|
|
_suppressTvKeyboardForCurrentFocus = false;
|
|
_suppressTvKeyboardAutoOpen = true;
|
|
_logTvTextInput('Host.openTvKeyboard node=${_installedFocusNode?.debugLabel}');
|
|
// The dialog outlives input rebuilds (e.g. a search field whose
|
|
// onNavigateDown appears once results arrive while the keyboard is up),
|
|
// so only static configuration may be snapshotted here — the callbacks
|
|
// must resolve against widget.input at invoke time.
|
|
final input = widget.input;
|
|
final keyboard = showTvVirtualKeyboard(
|
|
context: context,
|
|
controller: input.controller,
|
|
hintText: _keyboardHint(input.decoration),
|
|
keyboardType: input.keyboardType,
|
|
textInputAction: input.textInputAction,
|
|
inputFormatters: input.inputFormatters,
|
|
obscureText: input.obscureText,
|
|
maxLength: input.maxLength,
|
|
maxLines: input.maxLines,
|
|
onChanged: (text) {
|
|
if (!mounted) return;
|
|
widget.input.onChanged?.call(text);
|
|
},
|
|
onSubmitted: (text) {
|
|
if (!mounted) return;
|
|
final current = widget.input;
|
|
if (current.onSubmitted != null) {
|
|
current.onSubmitted!(text);
|
|
} else {
|
|
current._handleTvKeyboardAction();
|
|
}
|
|
},
|
|
);
|
|
if (keyboard == null) {
|
|
_tvKeyboardOpen = false;
|
|
return;
|
|
}
|
|
_tvKeyboardHandle = keyboard;
|
|
unawaited(
|
|
keyboard.closed.whenComplete(() {
|
|
_tvKeyboardHandle = null;
|
|
if (!mounted) return;
|
|
_tvKeyboardOpen = false;
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!mounted) return;
|
|
if (_installedFocusNode?.hasFocus != true) {
|
|
_suppressTvKeyboardAutoOpen = false;
|
|
}
|
|
_syncTvKeyboardAutoOpen();
|
|
});
|
|
}),
|
|
);
|
|
}
|
|
|
|
/// Dismiss an open OSK imperatively (e.g. a companion-remote search that must
|
|
/// show results instead of the keyboard). Suppresses auto-reopen so a field
|
|
/// that regains focus when the dialog route pops does not relaunch it.
|
|
void _dismissTvKeyboard() {
|
|
// No-op when nothing is up: setting the suppress flag here (with no
|
|
// compensating unfocus to clear it) would block a later legitimate
|
|
// auto-open when the user deliberately focuses the field.
|
|
if (!_tvKeyboardOpen && !_tvKeyboardOpenScheduled) return;
|
|
_tvKeyboardOpenScheduled = false;
|
|
_suppressTvKeyboardAutoOpen = true;
|
|
_tvKeyboardHandle?.close();
|
|
}
|
|
|
|
/// Focus the field without opening either native or Flutter text input for
|
|
/// this focus entry. Suppression is set before requestFocus so the resulting
|
|
/// focus-change callback cannot open either presentation.
|
|
void _focusWithoutKeyboard() {
|
|
_suppressTvKeyboardAutoOpen = true;
|
|
_suppressNativeTextInputForCurrentFocus = true;
|
|
_tvKeyboardOpenScheduled = false;
|
|
if (widget.input._usesNativeTvKeyboard) {
|
|
_setNativeTextInputActivated(false);
|
|
}
|
|
final focusNode = _installedFocusNode ?? _effectiveFocusNode;
|
|
focusNode.requestFocus();
|
|
scheduleMicrotask(() {
|
|
if (!mounted || focusNode.hasFocus || _tvKeyboardOpen || _tvKeyboardOpenScheduled) return;
|
|
_suppressTvKeyboardAutoOpen = false;
|
|
_suppressNativeTextInputForCurrentFocus = false;
|
|
});
|
|
}
|
|
|
|
void _setNativeTextInputFocused(bool focused) {
|
|
if (_reportedNativeTextInputFocused == focused) {
|
|
_logTvTextInput('Host.setNativeTextInputFocused no-op focused=$focused');
|
|
return;
|
|
}
|
|
_logTvTextInput('Host.setNativeTextInputFocused old=$_reportedNativeTextInputFocused new=$focused');
|
|
_reportedNativeTextInputFocused = focused;
|
|
_NativeTvTextInputFocusBridge.setFocused(_nativeFocusToken, focused);
|
|
}
|
|
|
|
KeyEventResult _handleKey(FocusNode node, KeyEvent event) {
|
|
final previous = _previousOnKeyEvent;
|
|
if (previous != null && !identical(previous, _keyHandler)) {
|
|
final result = previous(node, event);
|
|
_logTvTextInput(
|
|
'Host.previousOnKeyEvent node=${node.debugLabel} result=$result key=(${_describeTextInputKey(event)})',
|
|
);
|
|
if (result != KeyEventResult.ignored) return result;
|
|
}
|
|
var activateNativeTextInput = widget.input._usesNativeTvKeyboard && !_nativeTextInputActivated;
|
|
final isRemoteNavigation = event.logicalKey.isDpadDirection || event.logicalKey.isBackKey || event.isTvSelectEvent;
|
|
if (PlatformDetector.isAppleTV() &&
|
|
widget.input._usesNativeTvKeyboard &&
|
|
_nativeTextInputActivated &&
|
|
event is KeyDownEvent &&
|
|
isRemoteNavigation) {
|
|
// Remote navigation events are system-owned while the native keyboard
|
|
// is active. Receiving one here proves that UIKit has dismissed the
|
|
// keyboard while Flutter focus stayed on the field. Restore the
|
|
// read-only gate so this press navigates Flutter instead of reopening
|
|
// the input connection.
|
|
_suppressNativeTextInputForCurrentFocus = true;
|
|
_setNativeTextInputActivated(false);
|
|
activateNativeTextInput = true;
|
|
if (event.logicalKey.isBackKey) {
|
|
// This is the Menu press that dismissed UIKit's keyboard. Consume its
|
|
// Flutter continuation so one press cannot also pop the app route.
|
|
return KeyEventResult.handled;
|
|
}
|
|
if (event.isTvSelectEvent) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) _activateNativeTextInput();
|
|
});
|
|
return KeyEventResult.handled;
|
|
}
|
|
}
|
|
return widget.input._handleKey(
|
|
context,
|
|
node,
|
|
event,
|
|
_openTvKeyboard,
|
|
activateNativeTextInput: activateNativeTextInput,
|
|
activateNativeTextInputCallback: _activateNativeTextInput,
|
|
);
|
|
}
|
|
|
|
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;
|
|
_logTvTextInput('Host.reinstalled key handler node=${node.debugLabel} previous=${_previousOnKeyEvent != null}');
|
|
return;
|
|
}
|
|
|
|
_restoreInstalledHandler();
|
|
_installedFocusNode = node;
|
|
_previousOnKeyEvent = node.onKeyEvent;
|
|
node.onKeyEvent = _keyHandler;
|
|
node.addListener(_focusListener);
|
|
_logTvTextInput('Host.installed key handler node=${node.debugLabel} previous=${_previousOnKeyEvent != null}');
|
|
}
|
|
|
|
void _restoreInstalledHandler() {
|
|
_logTvTextInput('Host.restoreInstalledHandler node=${_installedFocusNode?.debugLabel}');
|
|
_setNativeTextInputFocused(false);
|
|
_nativeTextInputActivated = false;
|
|
_suppressNativeTextInputForCurrentFocus = false;
|
|
final node = _installedFocusNode;
|
|
if (node != null) {
|
|
node.removeListener(_focusListener);
|
|
if (identical(node.onKeyEvent, _keyHandler)) {
|
|
node.onKeyEvent = _previousOnKeyEvent;
|
|
}
|
|
}
|
|
_installedFocusNode = null;
|
|
_previousOnKeyEvent = null;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final focusNode = _effectiveFocusNode;
|
|
_installKeyHandler(focusNode);
|
|
_syncNativeTextInputFocus();
|
|
_syncTvKeyboardAutoOpen();
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) _handleFocusChanged();
|
|
});
|
|
final usesTvKeyboard = widget.input._hasTvKeyboard;
|
|
return widget.builder(
|
|
usesTvKeyboard: usesTvKeyboard,
|
|
nativeTextInputReadOnly: widget.input._usesNativeTvKeyboard && !_nativeTextInputActivated,
|
|
focusNode: focusNode,
|
|
openKeyboard: _openTvKeyboard,
|
|
activateNativeTextInput: _activateNativeTextInput,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// A [TextField] wrapper that exposes D-pad navigation callbacks with
|
|
/// caret-aware edge escapes — so LEFT at the start of the field and RIGHT
|
|
/// at the end escape to neighbouring focus targets instead of bouncing
|
|
/// against the caret boundary, while UP/DOWN always escape.
|
|
///
|
|
/// Collapsed selection only: if text is selected, LEFT/RIGHT fall through
|
|
/// to the TextField's default caret movement.
|
|
class FocusableTextField extends _FocusableTextInputBase {
|
|
const FocusableTextField({
|
|
super.key,
|
|
required super.controller,
|
|
super.focusNode,
|
|
super.decoration,
|
|
super.keyboardType,
|
|
super.textInputAction,
|
|
super.inputFormatters,
|
|
super.onChanged,
|
|
super.onSubmitted,
|
|
super.onEditingComplete,
|
|
super.onSelect,
|
|
super.onBack,
|
|
super.autofocus,
|
|
super.enabled,
|
|
super.tvTextInputPresentation,
|
|
super.tvTextInputAutoOpenBehavior,
|
|
super.tvTextInputController,
|
|
super.obscureText,
|
|
super.autocorrect,
|
|
super.enableSuggestions,
|
|
super.enableInteractiveSelection,
|
|
super.maxLength,
|
|
super.maxLines,
|
|
super.minLines,
|
|
super.textAlign,
|
|
super.textCapitalization,
|
|
super.style,
|
|
super.onNavigateLeft,
|
|
super.onNavigateRight,
|
|
super.onNavigateUp,
|
|
super.onNavigateDown,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return buildFocusableInput(({
|
|
required bool usesTvKeyboard,
|
|
required bool nativeTextInputReadOnly,
|
|
required FocusNode focusNode,
|
|
required VoidCallback openKeyboard,
|
|
required VoidCallback activateNativeTextInput,
|
|
}) {
|
|
final tvInput = _tvInputConfiguration(
|
|
usesTvKeyboard: usesTvKeyboard,
|
|
nativeTextInputReadOnly: nativeTextInputReadOnly,
|
|
openKeyboard: openKeyboard,
|
|
activateNativeTextInput: activateNativeTextInput,
|
|
);
|
|
return TextField(
|
|
controller: controller,
|
|
focusNode: focusNode,
|
|
enabled: enabled,
|
|
decoration: decoration,
|
|
keyboardType: tvInput.keyboardType,
|
|
textInputAction: textInputAction,
|
|
inputFormatters: inputFormatters,
|
|
onChanged: onChanged,
|
|
onSubmitted: onSubmitted,
|
|
onEditingComplete: _effectiveOnEditingComplete,
|
|
autofocus: autofocus,
|
|
autocorrect: autocorrect,
|
|
enableSuggestions: enableSuggestions,
|
|
obscureText: obscureText,
|
|
maxLength: maxLength,
|
|
maxLines: maxLines,
|
|
minLines: minLines,
|
|
textAlign: textAlign,
|
|
textCapitalization: textCapitalization,
|
|
style: style,
|
|
readOnly: tvInput.readOnly,
|
|
showCursor: tvInput.showCursor,
|
|
enableInteractiveSelection: tvInput.enableInteractiveSelection,
|
|
onTap: tvInput.onTap,
|
|
);
|
|
});
|
|
}
|
|
}
|
|
|
|
class FocusableTextFormField extends _FocusableTextInputBase {
|
|
final ValueChanged<String>? onFieldSubmitted;
|
|
final FormFieldValidator<String>? validator;
|
|
final AutovalidateMode? autovalidateMode;
|
|
final FormFieldSetter<String>? onSaved;
|
|
|
|
const FocusableTextFormField({
|
|
super.key,
|
|
required super.controller,
|
|
super.focusNode,
|
|
super.decoration,
|
|
super.keyboardType,
|
|
super.textInputAction,
|
|
super.inputFormatters,
|
|
super.onChanged,
|
|
this.onFieldSubmitted,
|
|
super.onEditingComplete,
|
|
super.onSelect,
|
|
super.onBack,
|
|
this.validator,
|
|
this.autovalidateMode,
|
|
this.onSaved,
|
|
super.autofocus,
|
|
super.enabled,
|
|
super.tvTextInputPresentation,
|
|
super.tvTextInputAutoOpenBehavior,
|
|
super.tvTextInputController,
|
|
super.obscureText,
|
|
super.autocorrect,
|
|
super.enableSuggestions,
|
|
super.enableInteractiveSelection,
|
|
super.maxLength,
|
|
super.maxLines,
|
|
super.minLines,
|
|
super.textAlign,
|
|
super.textCapitalization,
|
|
super.style,
|
|
super.onNavigateLeft,
|
|
super.onNavigateRight,
|
|
super.onNavigateUp,
|
|
super.onNavigateDown,
|
|
}) : super(onSubmitted: onFieldSubmitted);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return buildFocusableInput(({
|
|
required bool usesTvKeyboard,
|
|
required bool nativeTextInputReadOnly,
|
|
required FocusNode focusNode,
|
|
required VoidCallback openKeyboard,
|
|
required VoidCallback activateNativeTextInput,
|
|
}) {
|
|
final tvInput = _tvInputConfiguration(
|
|
usesTvKeyboard: usesTvKeyboard,
|
|
nativeTextInputReadOnly: nativeTextInputReadOnly,
|
|
openKeyboard: openKeyboard,
|
|
activateNativeTextInput: activateNativeTextInput,
|
|
);
|
|
return TextFormField(
|
|
controller: controller,
|
|
focusNode: focusNode,
|
|
enabled: enabled,
|
|
decoration: decoration,
|
|
keyboardType: tvInput.keyboardType,
|
|
textInputAction: textInputAction,
|
|
inputFormatters: inputFormatters,
|
|
onChanged: onChanged,
|
|
onFieldSubmitted: onFieldSubmitted,
|
|
onEditingComplete: _effectiveOnEditingComplete,
|
|
validator: validator,
|
|
autovalidateMode: autovalidateMode,
|
|
onSaved: onSaved,
|
|
autofocus: autofocus,
|
|
autocorrect: autocorrect,
|
|
enableSuggestions: enableSuggestions,
|
|
obscureText: obscureText,
|
|
maxLength: maxLength,
|
|
maxLines: maxLines,
|
|
minLines: minLines,
|
|
textAlign: textAlign,
|
|
textCapitalization: textCapitalization,
|
|
style: style,
|
|
readOnly: tvInput.readOnly,
|
|
showCursor: tvInput.showCursor,
|
|
enableInteractiveSelection: tvInput.enableInteractiveSelection,
|
|
onTap: tvInput.onTap,
|
|
);
|
|
});
|
|
}
|
|
}
|