1124 lines
38 KiB
Dart
1124 lines
38 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';
|
|
|
|
bool _usesTvKeyboard(bool enableTvKeyboard) => enableTvKeyboard && PlatformDetector.isTV();
|
|
|
|
String? _keyboardHint(InputDecoration? decoration) => decoration?.hintText ?? decoration?.labelText;
|
|
|
|
enum TvKeyboardAutoOpenBehavior {
|
|
/// Open the TV virtual keyboard whenever the field receives focus.
|
|
onFocus,
|
|
|
|
/// Keep initial focus on the field without opening the keyboard, then open
|
|
/// automatically on later focus entries. Explicit tap/select still opens it.
|
|
afterFirstFocus,
|
|
|
|
/// Never auto-open the TV virtual keyboard on focus. Explicit tap/select
|
|
/// still opens it.
|
|
never,
|
|
}
|
|
|
|
/// Imperative handle to the TV on-screen keyboard of a [FocusableTextField] /
|
|
/// [FocusableTextFormField]. Pass the same instance to the field's
|
|
/// `tvKeyboardController`; the field's host attaches itself on mount.
|
|
///
|
|
/// Only meaningful on TV. On other platforms the OSK never exists, so every
|
|
/// method is an effective no-op.
|
|
class TvKeyboardController {
|
|
_FocusableTextInputHostState? _host;
|
|
|
|
void _attach(_FocusableTextInputHostState host) => _host = host;
|
|
void _detach(_FocusableTextInputHostState host) {
|
|
if (identical(_host, host)) _host = null;
|
|
}
|
|
|
|
/// Dismiss the OSK if it is open (and prevent it from auto-reopening while
|
|
/// the field keeps focus). No-op when nothing is open.
|
|
void closeKeyboard() => _host?._dismissTvKeyboard();
|
|
|
|
/// Focus the field but do NOT open the OSK for this focus entry. Used as a
|
|
/// fallback landing spot (e.g. a remote search that returned no results) so
|
|
/// the remote isn't stranded on an off-screen element.
|
|
void focusInputWithoutKeyboard() => _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 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 (_shouldPassNativeTvKeyToPlatform(usesTvKeyboard: usesTvKeyboard, 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 enabled, required KeyEvent event}) {
|
|
if (!enabled || usesTvKeyboard || !PlatformDetector.isTV()) {
|
|
if (TextInputDiagnostics.enabled) {
|
|
_logTvTextInput(
|
|
'native-pass=false reason=disabled-or-custom-keyboard enabled=$enabled usesTvKeyboard=$usesTvKeyboard '
|
|
'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 bool enableTvKeyboard;
|
|
final TvKeyboardAutoOpenBehavior tvKeyboardAutoOpenBehavior;
|
|
final TvKeyboardController? tvKeyboardController;
|
|
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.enableTvKeyboard = true,
|
|
this.tvKeyboardAutoOpenBehavior = TvKeyboardAutoOpenBehavior.onFocus,
|
|
this.tvKeyboardController,
|
|
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(enableTvKeyboard);
|
|
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(bool usesTvKeyboard, VoidCallback openKeyboard) {
|
|
return (
|
|
keyboardType: usesTvKeyboard ? TextInputType.none : keyboardType,
|
|
readOnly: usesTvKeyboard,
|
|
showCursor: usesTvKeyboard ? true : null,
|
|
enableInteractiveSelection: usesTvKeyboard ? false : enableInteractiveSelection,
|
|
onTap: usesTvKeyboard ? openKeyboard : null,
|
|
);
|
|
}
|
|
|
|
KeyEventResult _handleKey(BuildContext context, FocusNode node, KeyEvent event, VoidCallback openKeyboard) {
|
|
return _handleInputKey(
|
|
controller: controller,
|
|
node: node,
|
|
usesTvKeyboard: _hasTvKeyboard,
|
|
enabled: enabled,
|
|
openKeyboard: openKeyboard,
|
|
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(
|
|
Widget Function(bool usesTvKeyboard, FocusNode focusNode, VoidCallback openKeyboard) builder,
|
|
) {
|
|
return _FocusableTextInputHost(input: this, builder: builder);
|
|
}
|
|
}
|
|
|
|
class _FocusableTextInputHost extends StatefulWidget {
|
|
final _FocusableTextInputBase input;
|
|
final Widget Function(bool usesTvKeyboard, FocusNode focusNode, VoidCallback openKeyboard) 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;
|
|
|
|
FocusNode get _effectiveFocusNode => _focusNodeBinding.node;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_focusNodeBinding.bind(externalNode: widget.input.focusNode, debugLabel: 'FocusableTextInput');
|
|
widget.input.tvKeyboardController?._attach(this);
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(_FocusableTextInputHost oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
if (!identical(oldWidget.input.tvKeyboardController, widget.input.tvKeyboardController)) {
|
|
oldWidget.input.tvKeyboardController?._detach(this);
|
|
widget.input.tvKeyboardController?._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;
|
|
}
|
|
_handleFocusChanged();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
widget.input.tvKeyboardController?._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() {
|
|
_syncNativeTextInputFocus();
|
|
_syncTvKeyboardAutoOpen();
|
|
}
|
|
|
|
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.tvKeyboardAutoOpenBehavior} '
|
|
'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.tvKeyboardAutoOpenBehavior) {
|
|
case TvKeyboardAutoOpenBehavior.onFocus:
|
|
return true;
|
|
case TvKeyboardAutoOpenBehavior.afterFirstFocus:
|
|
if (!_hasSeenTvKeyboardFocus) {
|
|
_hasSeenTvKeyboardFocus = true;
|
|
_suppressTvKeyboardForCurrentFocus = true;
|
|
return false;
|
|
}
|
|
return !_suppressTvKeyboardForCurrentFocus;
|
|
case TvKeyboardAutoOpenBehavior.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 the OSK for this focus entry. Suppress is
|
|
/// set BEFORE requestFocus so the resulting focus-change sync already sees it;
|
|
/// the flag persists for this focus and resets on the next unfocus. If a
|
|
/// same-turn focus request supersedes this one, clear the unused suppression
|
|
/// after Flutter resolves its pending focus change.
|
|
void _focusWithoutKeyboard() {
|
|
_suppressTvKeyboardAutoOpen = true;
|
|
_tvKeyboardOpenScheduled = false;
|
|
final focusNode = _installedFocusNode ?? _effectiveFocusNode;
|
|
focusNode.requestFocus();
|
|
scheduleMicrotask(() {
|
|
if (!mounted || focusNode.hasFocus || _tvKeyboardOpen || _tvKeyboardOpenScheduled) return;
|
|
_suppressTvKeyboardAutoOpen = 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;
|
|
}
|
|
return widget.input._handleKey(context, node, event, _openTvKeyboard);
|
|
}
|
|
|
|
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);
|
|
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);
|
|
_handleFocusChanged();
|
|
return widget.builder(widget.input._hasTvKeyboard, focusNode, _openTvKeyboard);
|
|
}
|
|
}
|
|
|
|
/// 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.enableTvKeyboard,
|
|
super.tvKeyboardAutoOpenBehavior,
|
|
super.tvKeyboardController,
|
|
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((usesTvKeyboard, effectiveFocusNode, openKeyboard) {
|
|
final tvInput = _tvInputConfiguration(usesTvKeyboard, openKeyboard);
|
|
return TextField(
|
|
controller: controller,
|
|
focusNode: effectiveFocusNode,
|
|
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.enableTvKeyboard,
|
|
super.tvKeyboardAutoOpenBehavior,
|
|
super.tvKeyboardController,
|
|
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((usesTvKeyboard, effectiveFocusNode, openKeyboard) {
|
|
final tvInput = _tvInputConfiguration(usesTvKeyboard, openKeyboard);
|
|
return TextFormField(
|
|
controller: controller,
|
|
focusNode: effectiveFocusNode,
|
|
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,
|
|
);
|
|
});
|
|
}
|
|
}
|