fix(tv): improve virtual keyboard input
This commit is contained in:
@@ -9,7 +9,7 @@ import '../utils/text_input_diagnostics.dart';
|
||||
import '../widgets/tv_virtual_keyboard.dart';
|
||||
import 'dpad_navigator.dart';
|
||||
|
||||
bool _usesTvKeyboard(bool enableTvKeyboard) => enableTvKeyboard && PlatformDetector.isAppleTV();
|
||||
bool _usesTvKeyboard(bool enableTvKeyboard) => enableTvKeyboard && PlatformDetector.isTV();
|
||||
|
||||
String? _keyboardHint(InputDecoration? decoration) => decoration?.hintText ?? decoration?.labelText;
|
||||
|
||||
@@ -541,9 +541,9 @@ abstract class _FocusableTextInputBase extends StatelessWidget {
|
||||
return null;
|
||||
}
|
||||
|
||||
void _showTvKeyboard(BuildContext context) {
|
||||
if (!enabled) return;
|
||||
showTvVirtualKeyboard(
|
||||
Future<void> _showTvKeyboard(BuildContext context) {
|
||||
if (!enabled) return Future.value();
|
||||
return showTvVirtualKeyboard(
|
||||
context: context,
|
||||
controller: controller,
|
||||
hintText: _keyboardHint(decoration),
|
||||
@@ -571,12 +571,12 @@ abstract class _FocusableTextInputBase extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
KeyEventResult _handleKey(BuildContext context, FocusNode _, KeyEvent event) {
|
||||
KeyEventResult _handleKey(BuildContext context, FocusNode _, KeyEvent event, VoidCallback openKeyboard) {
|
||||
return _handleInputKey(
|
||||
controller: controller,
|
||||
usesTvKeyboard: _hasTvKeyboard,
|
||||
enabled: enabled,
|
||||
openKeyboard: () => _showTvKeyboard(context),
|
||||
openKeyboard: openKeyboard,
|
||||
event: event,
|
||||
keyboardType: keyboardType,
|
||||
textInputAction: textInputAction,
|
||||
@@ -595,14 +595,17 @@ abstract class _FocusableTextInputBase extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildFocusableInput(BuildContext context, Widget Function(bool usesTvKeyboard, FocusNode focusNode) builder) {
|
||||
Widget buildFocusableInput(
|
||||
BuildContext context,
|
||||
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) builder;
|
||||
final Widget Function(bool usesTvKeyboard, FocusNode focusNode, VoidCallback openKeyboard) builder;
|
||||
|
||||
const _FocusableTextInputHost({required this.input, required this.builder});
|
||||
|
||||
@@ -615,9 +618,12 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
||||
FocusNode? _installedFocusNode;
|
||||
FocusOnKeyEventCallback? _previousOnKeyEvent;
|
||||
late final FocusOnKeyEventCallback _keyHandler = _handleKey;
|
||||
late final VoidCallback _focusListener = _syncNativeTextInputFocus;
|
||||
late final VoidCallback _focusListener = _handleFocusChanged;
|
||||
final Object _nativeFocusToken = Object();
|
||||
bool _reportedNativeTextInputFocused = false;
|
||||
bool _tvKeyboardOpen = false;
|
||||
bool _tvKeyboardOpenScheduled = false;
|
||||
bool _suppressTvKeyboardAutoOpen = false;
|
||||
|
||||
FocusNode get _effectiveFocusNode =>
|
||||
widget.input.focusNode ?? (_ownedFocusNode ??= FocusNode(debugLabel: 'FocusableTextInput'));
|
||||
@@ -627,8 +633,10 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.input.focusNode != widget.input.focusNode) {
|
||||
_restoreInstalledHandler();
|
||||
_suppressTvKeyboardAutoOpen = false;
|
||||
_tvKeyboardOpenScheduled = false;
|
||||
}
|
||||
_syncNativeTextInputFocus();
|
||||
_handleFocusChanged();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -638,6 +646,11 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleFocusChanged() {
|
||||
_syncNativeTextInputFocus();
|
||||
_syncTvKeyboardAutoOpen();
|
||||
}
|
||||
|
||||
void _syncNativeTextInputFocus() {
|
||||
final focused = _installedFocusNode?.hasFocus == true && widget.input.enabled && widget.input._usesNativeTvKeyboard;
|
||||
_logTvTextInput(
|
||||
@@ -648,6 +661,65 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
||||
_setNativeTextInputFocused(focused);
|
||||
}
|
||||
|
||||
void _syncTvKeyboardAutoOpen() {
|
||||
final focused = _installedFocusNode?.hasFocus == true && widget.input.enabled && widget.input._hasTvKeyboard;
|
||||
final visible = _canShowTvKeyboard;
|
||||
_logTvTextInput(
|
||||
'Host.syncTvKeyboardAutoOpen focused=$focused open=$_tvKeyboardOpen scheduled=$_tvKeyboardOpenScheduled '
|
||||
'suppressed=$_suppressTvKeyboardAutoOpen installed=${_installedFocusNode?.debugLabel} '
|
||||
'hasFocus=${_installedFocusNode?.hasFocus} enabled=${widget.input.enabled} '
|
||||
'usesTvKeyboard=${widget.input._hasTvKeyboard} visible=$visible',
|
||||
);
|
||||
|
||||
if (!focused) {
|
||||
if (!_tvKeyboardOpen && !_tvKeyboardOpenScheduled) {
|
||||
_suppressTvKeyboardAutoOpen = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!visible) 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);
|
||||
}
|
||||
|
||||
void _openTvKeyboard() {
|
||||
if (!mounted || !widget.input.enabled || !widget.input._hasTvKeyboard || !_canShowTvKeyboard || _tvKeyboardOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
_tvKeyboardOpenScheduled = false;
|
||||
_tvKeyboardOpen = true;
|
||||
_suppressTvKeyboardAutoOpen = true;
|
||||
_logTvTextInput('Host.openTvKeyboard node=${_installedFocusNode?.debugLabel}');
|
||||
unawaited(
|
||||
widget.input._showTvKeyboard(context).whenComplete(() {
|
||||
if (!mounted) return;
|
||||
_tvKeyboardOpen = false;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
if (_installedFocusNode?.hasFocus != true) {
|
||||
_suppressTvKeyboardAutoOpen = false;
|
||||
}
|
||||
_syncTvKeyboardAutoOpen();
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
void _setNativeTextInputFocused(bool focused) {
|
||||
if (_reportedNativeTextInputFocused == focused) {
|
||||
_logTvTextInput('Host.setNativeTextInputFocused no-op focused=$focused');
|
||||
@@ -667,7 +739,7 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
||||
);
|
||||
if (result != KeyEventResult.ignored) return result;
|
||||
}
|
||||
return widget.input._handleKey(context, node, event);
|
||||
return widget.input._handleKey(context, node, event, _openTvKeyboard);
|
||||
}
|
||||
|
||||
void _installKeyHandler(FocusNode node) {
|
||||
@@ -707,8 +779,8 @@ class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
|
||||
Widget build(BuildContext context) {
|
||||
final focusNode = _effectiveFocusNode;
|
||||
_installKeyHandler(focusNode);
|
||||
_syncNativeTextInputFocus();
|
||||
return widget.builder(widget.input._hasTvKeyboard, focusNode);
|
||||
_handleFocusChanged();
|
||||
return widget.builder(widget.input._hasTvKeyboard, focusNode, _openTvKeyboard);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -756,7 +828,7 @@ class FocusableTextField extends _FocusableTextInputBase {
|
||||
Widget build(BuildContext context) {
|
||||
return buildFocusableInput(
|
||||
context,
|
||||
(usesTvKeyboard, effectiveFocusNode) => TextField(
|
||||
(usesTvKeyboard, effectiveFocusNode, openKeyboard) => TextField(
|
||||
controller: controller,
|
||||
focusNode: effectiveFocusNode,
|
||||
enabled: enabled,
|
||||
@@ -780,7 +852,7 @@ class FocusableTextField extends _FocusableTextInputBase {
|
||||
readOnly: usesTvKeyboard,
|
||||
showCursor: usesTvKeyboard ? true : null,
|
||||
enableInteractiveSelection: usesTvKeyboard ? false : enableInteractiveSelection,
|
||||
onTap: usesTvKeyboard ? () => _showTvKeyboard(context) : null,
|
||||
onTap: usesTvKeyboard ? openKeyboard : null,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -831,7 +903,7 @@ class FocusableTextFormField extends _FocusableTextInputBase {
|
||||
Widget build(BuildContext context) {
|
||||
return buildFocusableInput(
|
||||
context,
|
||||
(usesTvKeyboard, effectiveFocusNode) => TextFormField(
|
||||
(usesTvKeyboard, effectiveFocusNode, openKeyboard) => TextFormField(
|
||||
controller: controller,
|
||||
focusNode: effectiveFocusNode,
|
||||
enabled: enabled,
|
||||
@@ -858,7 +930,7 @@ class FocusableTextFormField extends _FocusableTextInputBase {
|
||||
readOnly: usesTvKeyboard,
|
||||
showCursor: usesTvKeyboard ? true : null,
|
||||
enableInteractiveSelection: usesTvKeyboard ? false : enableInteractiveSelection,
|
||||
onTap: usesTvKeyboard ? () => _showTvKeyboard(context) : null,
|
||||
onTap: usesTvKeyboard ? openKeyboard : null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1012,13 +1012,22 @@ class _MainScreenState extends State<MainScreen>
|
||||
});
|
||||
}
|
||||
|
||||
void _focusContent() {
|
||||
void _focusContent({bool restorePreviousFocus = true}) {
|
||||
setState(() => _isSidebarFocused = false);
|
||||
_contentFocusScope.requestFocus();
|
||||
if (restorePreviousFocus) {
|
||||
_contentFocusScope.requestFocus();
|
||||
}
|
||||
// Only programmatically focus if the scope didn't auto-restore a child.
|
||||
// This preserves the user's focus position when returning from sidebar.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_contentFocusScope.focusedChild == null) {
|
||||
if (!mounted) return;
|
||||
if (restorePreviousFocus) {
|
||||
if (_contentFocusScope.focusedChild == null) {
|
||||
if (_screenKeyFor(_currentTab)?.currentState case final FocusableTab focusable) {
|
||||
focusable.focusActiveTabIfReady();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (_screenKeyFor(_currentTab)?.currentState case final FocusableTab focusable) {
|
||||
focusable.focusActiveTabIfReady();
|
||||
}
|
||||
@@ -1430,12 +1439,13 @@ class _MainScreenState extends State<MainScreen>
|
||||
isReconnecting: _isReconnecting,
|
||||
onInteractionExpandedChanged: _handleSidebarInteractionExpandedChanged,
|
||||
onDestinationSelected: (tab) {
|
||||
final restorePreviousFocus = tab == _currentTab;
|
||||
_selectTab(tab);
|
||||
_focusContent();
|
||||
_focusContent(restorePreviousFocus: restorePreviousFocus);
|
||||
},
|
||||
onLibrarySelected: (key) {
|
||||
_selectLibrary(key);
|
||||
_focusContent();
|
||||
_focusContent(restorePreviousFocus: false);
|
||||
},
|
||||
onNavigateToContent: _focusContent,
|
||||
onReconnect: _triggerReconnect,
|
||||
|
||||
@@ -22,11 +22,13 @@ Future<void> showTvVirtualKeyboard({
|
||||
ValueChanged<String>? onSubmitted,
|
||||
VoidCallback? onAction,
|
||||
}) {
|
||||
if (!PlatformDetector.isAppleTV()) return Future.value();
|
||||
if (!PlatformDetector.isTV()) return Future.value();
|
||||
|
||||
return showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: true,
|
||||
barrierColor: Colors.black.withValues(alpha: 0.10),
|
||||
useSafeArea: false,
|
||||
builder: (context) => _TvVirtualKeyboardDialog(
|
||||
controller: controller,
|
||||
hintText: hintText,
|
||||
@@ -43,7 +45,7 @@ Future<void> showTvVirtualKeyboard({
|
||||
);
|
||||
}
|
||||
|
||||
enum _TvKeyType { spacer, character, shift, space, newline, backspace, clear, cancel, done }
|
||||
enum _TvKeyType { spacer, character, shift, symbols, space, newline, backspace, clear, cancel, done }
|
||||
|
||||
class _TvKey {
|
||||
final String label;
|
||||
@@ -88,16 +90,14 @@ class _TvVirtualKeyboardDialog extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _TvVirtualKeyboardDialogState extends State<_TvVirtualKeyboardDialog> with MountedSetStateMixin {
|
||||
static const double _keySize = 60;
|
||||
static const double _keyGap = 6;
|
||||
static const double _rowGap = 6;
|
||||
|
||||
final _focusNode = FocusNode(debugLabel: 'TvVirtualKeyboard');
|
||||
int _row = 0;
|
||||
int _column = 0;
|
||||
bool _shiftEnabled = false;
|
||||
bool _symbolsPage = false;
|
||||
|
||||
List<List<_TvKey>> get _rows => _buildRows();
|
||||
List<List<_TvKey>> get _rows => _symbolsPage ? _buildSymbolRows() : _buildMainRows();
|
||||
int get _gridColumnCount => _isNumberKeyboard ? 3 : 12;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -130,7 +130,7 @@ class _TvVirtualKeyboardDialogState extends State<_TvVirtualKeyboardDialog> with
|
||||
return type?.index == TextInputType.multiline.index || (widget.maxLines != null && widget.maxLines != 1);
|
||||
}
|
||||
|
||||
List<List<_TvKey>> _buildRows() {
|
||||
List<List<_TvKey>> _buildMainRows() {
|
||||
if (_isNumberKeyboard) {
|
||||
return [
|
||||
_characters('123'),
|
||||
@@ -156,6 +156,7 @@ class _TvVirtualKeyboardDialogState extends State<_TvVirtualKeyboardDialog> with
|
||||
const _TvKey.character('_'),
|
||||
const _TvKey.character('/'),
|
||||
const _TvKey.character(':'),
|
||||
const _TvKey.character('='),
|
||||
_isMultiline
|
||||
? const _TvKey.action('Line', _TvKeyType.newline, icon: Icons.keyboard_return_rounded)
|
||||
: const _TvKey.character('&'),
|
||||
@@ -165,10 +166,11 @@ class _TvVirtualKeyboardDialogState extends State<_TvVirtualKeyboardDialog> with
|
||||
];
|
||||
|
||||
return [
|
||||
[const _TvKey.spacer(), ..._characters('1234567890')],
|
||||
[const _TvKey.spacer(), ..._characters('qwertyuiop')],
|
||||
[const _TvKey.spacer(), ..._characters('asdfghjkl'), const _TvKey.character("'")],
|
||||
[const _TvKey.spacer(), ..._characters('1234567890'), const _TvKey.spacer()],
|
||||
[const _TvKey.spacer(), ..._characters('qwertyuiop'), const _TvKey.spacer()],
|
||||
[const _TvKey.spacer(), ..._characters('asdfghjkl'), const _TvKey.character("'"), const _TvKey.spacer()],
|
||||
[
|
||||
const _TvKey.action('', _TvKeyType.symbols, icon: Icons.functions_rounded),
|
||||
_TvKey.action('Shift', _TvKeyType.shift, icon: Symbols.shift),
|
||||
..._characters('zxcvbnm.-'),
|
||||
const _TvKey.action('Del', _TvKeyType.backspace, icon: Icons.backspace_outlined),
|
||||
@@ -177,6 +179,43 @@ class _TvVirtualKeyboardDialogState extends State<_TvVirtualKeyboardDialog> with
|
||||
];
|
||||
}
|
||||
|
||||
List<List<_TvKey>> _buildSymbolRows() {
|
||||
return [
|
||||
[
|
||||
const _TvKey.action('ABC', _TvKeyType.symbols),
|
||||
..._symbols(['!', '?', r'$', '%', '^', '*', '+', '=', '~']),
|
||||
const _TvKey.action('Del', _TvKeyType.backspace, icon: Icons.backspace_outlined),
|
||||
const _TvKey.spacer(),
|
||||
],
|
||||
[
|
||||
const _TvKey.spacer(),
|
||||
..._symbols(['`', r'\', '|', ';', ':', '"', "'", '<', '>']),
|
||||
const _TvKey.spacer(),
|
||||
const _TvKey.spacer(),
|
||||
],
|
||||
[
|
||||
const _TvKey.spacer(),
|
||||
..._symbols(['[', ']', '{', '}', '(', ')', ',', '.', '-']),
|
||||
const _TvKey.spacer(),
|
||||
const _TvKey.spacer(),
|
||||
],
|
||||
[
|
||||
const _TvKey.spacer(),
|
||||
const _TvKey.spacer(),
|
||||
const _TvKey.action('Space', _TvKeyType.space, icon: Icons.space_bar_rounded),
|
||||
const _TvKey.character('@'),
|
||||
const _TvKey.character('#'),
|
||||
const _TvKey.character('_'),
|
||||
const _TvKey.character('/'),
|
||||
_TvKey.action(t.common.clear, _TvKeyType.clear, icon: Icons.clear_all_rounded),
|
||||
_TvKey.action(t.common.cancel, _TvKeyType.cancel, icon: Icons.close_rounded),
|
||||
_TvKey.action(_doneLabel(), _TvKeyType.done, icon: _doneIcon()),
|
||||
const _TvKey.spacer(),
|
||||
const _TvKey.spacer(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
List<_TvKey> _characters(String chars) {
|
||||
return chars
|
||||
.split('')
|
||||
@@ -188,6 +227,10 @@ class _TvVirtualKeyboardDialogState extends State<_TvVirtualKeyboardDialog> with
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
List<_TvKey> _symbols(List<String> symbols) {
|
||||
return symbols.map((symbol) => _TvKey.character(symbol)).toList(growable: false);
|
||||
}
|
||||
|
||||
String _doneLabel() {
|
||||
switch (widget.textInputAction) {
|
||||
case TextInputAction.search:
|
||||
@@ -223,10 +266,16 @@ class _TvVirtualKeyboardDialogState extends State<_TvVirtualKeyboardDialog> with
|
||||
}
|
||||
|
||||
if (event is KeyDownEvent || event is KeyRepeatEvent) {
|
||||
if (key == LogicalKeyboardKey.backspace || key == LogicalKeyboardKey.delete) {
|
||||
if (_handlePhysicalKeyboardTextInput(event)) return KeyEventResult.handled;
|
||||
|
||||
if (key == LogicalKeyboardKey.backspace) {
|
||||
_backspace();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key == LogicalKeyboardKey.delete) {
|
||||
_deleteForward();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
if (event.isPhysicalKeyboardEnter) {
|
||||
if (_isMultiline) {
|
||||
@@ -269,6 +318,43 @@ class _TvVirtualKeyboardDialogState extends State<_TvVirtualKeyboardDialog> with
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
bool _handlePhysicalKeyboardTextInput(KeyEvent event) {
|
||||
if (!event.isPhysicalKeyboardEvent) return false;
|
||||
|
||||
final key = event.logicalKey;
|
||||
if (key == LogicalKeyboardKey.backspace) {
|
||||
_backspace();
|
||||
_dismissForPhysicalKeyboardInput();
|
||||
return true;
|
||||
}
|
||||
if (key == LogicalKeyboardKey.delete) {
|
||||
_deleteForward();
|
||||
_dismissForPhysicalKeyboardInput();
|
||||
return true;
|
||||
}
|
||||
if (event.isPhysicalKeyboardEnter && _isMultiline) {
|
||||
_insert('\n');
|
||||
_dismissForPhysicalKeyboardInput();
|
||||
return true;
|
||||
}
|
||||
|
||||
final character = event.character;
|
||||
if (character != null && character.isNotEmpty && !key.isNavigationKey && !_isControlCharacter(character)) {
|
||||
_insert(character);
|
||||
_dismissForPhysicalKeyboardInput();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool _isControlCharacter(String text) {
|
||||
return text.runes.every((codeUnit) => codeUnit < 0x20 || codeUnit == 0x7f);
|
||||
}
|
||||
|
||||
void _dismissForPhysicalKeyboardInput() {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
void _moveHorizontal(int delta) {
|
||||
final nextColumn = _nextFocusableColumn(_row, _column + delta, delta);
|
||||
if (nextColumn == null) return;
|
||||
@@ -331,6 +417,9 @@ class _TvVirtualKeyboardDialogState extends State<_TvVirtualKeyboardDialog> with
|
||||
case _TvKeyType.shift:
|
||||
setState(() => _shiftEnabled = !_shiftEnabled);
|
||||
return;
|
||||
case _TvKeyType.symbols:
|
||||
_toggleSymbolsPage();
|
||||
return;
|
||||
case _TvKeyType.space:
|
||||
_insert(' ');
|
||||
return;
|
||||
@@ -352,6 +441,15 @@ class _TvVirtualKeyboardDialogState extends State<_TvVirtualKeyboardDialog> with
|
||||
}
|
||||
}
|
||||
|
||||
void _toggleSymbolsPage() {
|
||||
setState(() {
|
||||
_symbolsPage = !_symbolsPage;
|
||||
final rows = _rows;
|
||||
_row = _row.clamp(0, rows.length - 1).toInt();
|
||||
_column = _nearestFocusableColumn(_row, _column);
|
||||
});
|
||||
}
|
||||
|
||||
void _submit() {
|
||||
final text = widget.controller.text;
|
||||
final onSubmitted = widget.onSubmitted;
|
||||
@@ -368,13 +466,7 @@ class _TvVirtualKeyboardDialogState extends State<_TvVirtualKeyboardDialog> with
|
||||
|
||||
void _insert(String text) {
|
||||
final value = widget.controller.value;
|
||||
final selection = value.selection;
|
||||
final start = selection.isValid
|
||||
? (selection.start < selection.end ? selection.start : selection.end)
|
||||
: value.text.length;
|
||||
final end = selection.isValid
|
||||
? (selection.start > selection.end ? selection.start : selection.end)
|
||||
: value.text.length;
|
||||
final (:start, :end) = _selectionRangeForEdit(value);
|
||||
final newText = value.text.replaceRange(start, end, text);
|
||||
_replace(
|
||||
value.copyWith(
|
||||
@@ -387,13 +479,7 @@ class _TvVirtualKeyboardDialogState extends State<_TvVirtualKeyboardDialog> with
|
||||
|
||||
void _backspace() {
|
||||
final value = widget.controller.value;
|
||||
final selection = value.selection;
|
||||
final start = selection.isValid
|
||||
? (selection.start < selection.end ? selection.start : selection.end)
|
||||
: value.text.length;
|
||||
final end = selection.isValid
|
||||
? (selection.start > selection.end ? selection.start : selection.end)
|
||||
: value.text.length;
|
||||
final (:start, :end) = _selectionRangeForEdit(value);
|
||||
|
||||
if (start != end) {
|
||||
_replace(
|
||||
@@ -415,6 +501,43 @@ class _TvVirtualKeyboardDialogState extends State<_TvVirtualKeyboardDialog> with
|
||||
);
|
||||
}
|
||||
|
||||
void _deleteForward() {
|
||||
final value = widget.controller.value;
|
||||
final (:start, :end) = _selectionRangeForEdit(value);
|
||||
|
||||
if (start != end) {
|
||||
_replace(
|
||||
value.copyWith(
|
||||
text: value.text.replaceRange(start, end, ''),
|
||||
selection: TextSelection.collapsed(offset: start),
|
||||
composing: TextRange.empty,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (start >= value.text.length) return;
|
||||
|
||||
_replace(
|
||||
value.copyWith(
|
||||
text: value.text.replaceRange(start, start + 1, ''),
|
||||
selection: TextSelection.collapsed(offset: start),
|
||||
composing: TextRange.empty,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
({int start, int end}) _selectionRangeForEdit(TextEditingValue value) {
|
||||
final selection = value.selection;
|
||||
if (!selection.isValid) return (start: value.text.length, end: value.text.length);
|
||||
if (selection.isCollapsed && selection.baseOffset == 0 && value.text.isNotEmpty) {
|
||||
return (start: value.text.length, end: value.text.length);
|
||||
}
|
||||
return (
|
||||
start: selection.start < selection.end ? selection.start : selection.end,
|
||||
end: selection.start > selection.end ? selection.start : selection.end,
|
||||
);
|
||||
}
|
||||
|
||||
void _replace(TextEditingValue nextValue) {
|
||||
final previousValue = widget.controller.value;
|
||||
var formattedValue = nextValue;
|
||||
@@ -427,100 +550,138 @@ class _TvVirtualKeyboardDialogState extends State<_TvVirtualKeyboardDialog> with
|
||||
formattedValue = formatter.formatEditUpdate(previousValue, formattedValue);
|
||||
}
|
||||
widget.controller.value = formattedValue;
|
||||
widget.onChanged?.call(formattedValue.text);
|
||||
if (formattedValue.text != previousValue.text) {
|
||||
widget.onChanged?.call(formattedValue.text);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final media = MediaQuery.of(context);
|
||||
final metrics = _metricsFor(media.size);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final text = widget.controller.text;
|
||||
|
||||
return Dialog(
|
||||
insetPadding: const EdgeInsets.symmetric(horizontal: 56, vertical: 32),
|
||||
key: const Key('tv_virtual_keyboard_dialog'),
|
||||
alignment: Alignment.bottomCenter,
|
||||
insetPadding: EdgeInsets.only(
|
||||
left: metrics.edgeInset,
|
||||
right: metrics.edgeInset,
|
||||
top: media.padding.top + 48,
|
||||
bottom: media.padding.bottom + metrics.bottomInset,
|
||||
),
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
child: Focus(
|
||||
focusNode: _focusNode,
|
||||
onKeyEvent: _handleKey,
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 860),
|
||||
padding: const EdgeInsets.all(14),
|
||||
key: const Key('tv_virtual_keyboard_panel'),
|
||||
constraints: BoxConstraints(maxWidth: metrics.panelWidth),
|
||||
padding: EdgeInsets.all(metrics.panelPadding),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface.withValues(alpha: 0.96),
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
borderRadius: BorderRadius.circular(metrics.panelRadius),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildPreview(context, text),
|
||||
const SizedBox(height: 12),
|
||||
for (var row = 0; row < _rows.length; row++) ...[
|
||||
_buildRow(context, row),
|
||||
if (row != _rows.length - 1) const SizedBox(height: _rowGap),
|
||||
child: SizedBox(
|
||||
width: metrics.gridWidth,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildPreview(context, text, metrics),
|
||||
SizedBox(height: metrics.previewGap),
|
||||
for (var row = 0; row < _rows.length; row++) ...[
|
||||
_buildRow(context, row, metrics),
|
||||
if (row != _rows.length - 1) SizedBox(height: metrics.rowGap),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPreview(BuildContext context, String text) {
|
||||
_TvKeyboardMetrics _metricsFor(Size size) {
|
||||
final columns = _gridColumnCount;
|
||||
var keySize = (size.height * 0.061).clamp(36.0, 52.0).toDouble();
|
||||
var keyGap = (keySize * 0.09).clamp(3.0, 6.0).toDouble();
|
||||
var panelPadding = (keySize * 0.24).clamp(8.0, 14.0).toDouble();
|
||||
final edgeInset = (size.width * 0.035).clamp(20.0, 56.0).toDouble();
|
||||
|
||||
final availableWidth = size.width - edgeInset * 2 - panelPadding * 2;
|
||||
final maxKeySize = (availableWidth - keyGap * (columns - 1)) / columns;
|
||||
final widthBoundKeySize = maxKeySize.clamp(24.0, 52.0).toDouble();
|
||||
if (keySize > widthBoundKeySize) keySize = widthBoundKeySize;
|
||||
keyGap = (keySize * 0.09).clamp(3.0, 6.0).toDouble();
|
||||
panelPadding = (keySize * 0.24).clamp(8.0, 14.0).toDouble();
|
||||
final gridWidth = keySize * columns + keyGap * (columns - 1);
|
||||
return _TvKeyboardMetrics(
|
||||
keySize: keySize,
|
||||
keyGap: keyGap,
|
||||
rowGap: keyGap,
|
||||
panelPadding: panelPadding,
|
||||
edgeInset: edgeInset,
|
||||
bottomInset: (size.height * 0.025).clamp(12.0, 28.0).toDouble(),
|
||||
gridWidth: gridWidth,
|
||||
panelWidth: gridWidth + panelPadding * 2,
|
||||
panelRadius: (keySize * 0.55).clamp(18.0, 28.0).toDouble(),
|
||||
keyRadius: (keySize * 0.28).clamp(10.0, 16.0).toDouble(),
|
||||
previewHeight: (_isMultiline ? keySize * 1.45 : keySize).clamp(40.0, 76.0).toDouble(),
|
||||
previewGap: (keySize * 0.18).clamp(6.0, 12.0).toDouble(),
|
||||
previewRadius: (keySize * 0.36).clamp(12.0, 18.0).toDouble(),
|
||||
iconSize: (keySize * 0.58).clamp(20.0, 30.0).toDouble(),
|
||||
keyFontSize: (keySize * 0.52).clamp(17.0, 26.0).toDouble(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPreview(BuildContext context, String text, _TvKeyboardMetrics metrics) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
final isEmpty = text.isEmpty;
|
||||
final displayText = widget.obscureText && !isEmpty ? List.filled(text.length, '*').join() : text;
|
||||
final previewText = isEmpty ? (widget.hintText ?? '') : displayText;
|
||||
final multiline = _isMultiline;
|
||||
final style = (multiline ? theme.textTheme.titleMedium : theme.textTheme.titleLarge)?.copyWith(
|
||||
color: isEmpty ? colorScheme.onSurfaceVariant : colorScheme.onSurface,
|
||||
fontSize: (metrics.keyFontSize * 1.08).clamp(16.0, 24.0).toDouble(),
|
||||
);
|
||||
|
||||
return Container(
|
||||
height: multiline ? 86 : 60,
|
||||
height: metrics.previewHeight,
|
||||
alignment: Alignment.centerLeft,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
padding: EdgeInsets.symmetric(horizontal: metrics.keySize * 0.30),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.7),
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
borderRadius: BorderRadius.circular(metrics.previewRadius),
|
||||
),
|
||||
child: multiline
|
||||
? SingleChildScrollView(
|
||||
reverse: true,
|
||||
child: Text(
|
||||
previewText,
|
||||
maxLines: 3,
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
color: isEmpty ? colorScheme.onSurfaceVariant : colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
)
|
||||
? SingleChildScrollView(reverse: true, child: Text(previewText, maxLines: 3, style: style))
|
||||
: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
reverse: true,
|
||||
child: Text(
|
||||
previewText,
|
||||
maxLines: 1,
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
color: isEmpty ? colorScheme.onSurfaceVariant : colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
child: Text(previewText, maxLines: 1, style: style),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRow(BuildContext context, int row) {
|
||||
Widget _buildRow(BuildContext context, int row, _TvKeyboardMetrics metrics) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
for (var column = 0; column < _rows[row].length; column++) ...[
|
||||
_buildKey(context, _rows[row][column], row, column),
|
||||
if (column != _rows[row].length - 1) const SizedBox(width: _keyGap),
|
||||
_buildKey(context, _rows[row][column], row, column, metrics),
|
||||
if (column != _rows[row].length - 1) SizedBox(width: metrics.keyGap),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildKey(BuildContext context, _TvKey key, int row, int column) {
|
||||
Widget _buildKey(BuildContext context, _TvKey key, int row, int column, _TvKeyboardMetrics metrics) {
|
||||
if (key.type == _TvKeyType.spacer) {
|
||||
return const SizedBox(width: _keySize, height: _keySize);
|
||||
return SizedBox(width: metrics.keySize, height: metrics.keySize);
|
||||
}
|
||||
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
@@ -548,23 +709,27 @@ class _TvVirtualKeyboardDialogState extends State<_TvVirtualKeyboardDialog> with
|
||||
},
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 120),
|
||||
width: _keySize,
|
||||
height: _keySize,
|
||||
width: metrics.keySize,
|
||||
height: metrics.keySize,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(color: background, borderRadius: BorderRadius.circular(16)),
|
||||
decoration: BoxDecoration(color: background, borderRadius: BorderRadius.circular(metrics.keyRadius)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: _buildKeyContent(context, key, foreground),
|
||||
padding: EdgeInsets.symmetric(horizontal: metrics.keySize * 0.04),
|
||||
child: _buildKeyContent(context, key, foreground, metrics),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildKeyContent(BuildContext context, _TvKey key, Color foreground) {
|
||||
Widget _buildKeyContent(BuildContext context, _TvKey key, Color foreground, _TvKeyboardMetrics metrics) {
|
||||
final icon = key.icon;
|
||||
if (icon != null) {
|
||||
return Icon(icon, color: foreground, size: key.type == _TvKeyType.space ? 34 : 30);
|
||||
return Icon(
|
||||
icon,
|
||||
color: foreground,
|
||||
size: key.type == _TvKeyType.space ? metrics.iconSize * 1.12 : metrics.iconSize,
|
||||
);
|
||||
}
|
||||
|
||||
return FittedBox(
|
||||
@@ -572,8 +737,46 @@ class _TvVirtualKeyboardDialogState extends State<_TvVirtualKeyboardDialog> with
|
||||
child: Text(
|
||||
key.label,
|
||||
maxLines: 1,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(color: foreground, fontWeight: FontWeight.w800),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleLarge?.copyWith(color: foreground, fontSize: metrics.keyFontSize, fontWeight: FontWeight.w800),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TvKeyboardMetrics {
|
||||
final double keySize;
|
||||
final double keyGap;
|
||||
final double rowGap;
|
||||
final double panelPadding;
|
||||
final double edgeInset;
|
||||
final double bottomInset;
|
||||
final double gridWidth;
|
||||
final double panelWidth;
|
||||
final double panelRadius;
|
||||
final double keyRadius;
|
||||
final double previewHeight;
|
||||
final double previewGap;
|
||||
final double previewRadius;
|
||||
final double iconSize;
|
||||
final double keyFontSize;
|
||||
|
||||
const _TvKeyboardMetrics({
|
||||
required this.keySize,
|
||||
required this.keyGap,
|
||||
required this.rowGap,
|
||||
required this.panelPadding,
|
||||
required this.edgeInset,
|
||||
required this.bottomInset,
|
||||
required this.gridWidth,
|
||||
required this.panelWidth,
|
||||
required this.panelRadius,
|
||||
required this.keyRadius,
|
||||
required this.previewHeight,
|
||||
required this.previewGap,
|
||||
required this.previewRadius,
|
||||
required this.iconSize,
|
||||
required this.keyFontSize,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -179,36 +179,7 @@ void main() {
|
||||
expect(nextFocusNode.hasPrimaryFocus, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('tvOS keyboard enter does not open virtual keyboard', (tester) async {
|
||||
TvDetectionService.debugSetAppleTVOverride(true);
|
||||
final controller = TextEditingController();
|
||||
final fieldFocusNode = FocusNode(debugLabel: 'search_field');
|
||||
String? submitted;
|
||||
addTearDown(controller.dispose);
|
||||
addTearDown(fieldFocusNode.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: FocusableTextField(
|
||||
controller: controller,
|
||||
focusNode: fieldFocusNode,
|
||||
onSubmitted: (value) => submitted = value,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
fieldFocusNode.requestFocus();
|
||||
await tester.pump();
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(Dialog), findsNothing);
|
||||
expect(submitted, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('tvOS remote select opens virtual keyboard', (tester) async {
|
||||
testWidgets('tvOS focus opens virtual keyboard', (tester) async {
|
||||
TvDetectionService.debugSetAppleTVOverride(true);
|
||||
await _setTvSurfaceSize(tester);
|
||||
final controller = TextEditingController();
|
||||
@@ -225,20 +196,71 @@ void main() {
|
||||
);
|
||||
|
||||
fieldFocusNode.requestFocus();
|
||||
await tester.pump();
|
||||
_dispatchKey(
|
||||
const KeyDownEvent(
|
||||
physicalKey: PhysicalKeyboardKey.select,
|
||||
logicalKey: LogicalKeyboardKey.select,
|
||||
timeStamp: Duration.zero,
|
||||
deviceType: ui.KeyEventDeviceType.directionalPad,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(Dialog), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('hidden TV text field does not auto-open virtual keyboard', (tester) async {
|
||||
TvDetectionService.debugSetAppleTVOverride(true);
|
||||
await _setTvSurfaceSize(tester);
|
||||
final controller = TextEditingController(text: 'query');
|
||||
final fieldFocusNode = FocusNode(debugLabel: 'hidden_search_field');
|
||||
addTearDown(controller.dispose);
|
||||
addTearDown(fieldFocusNode.dispose);
|
||||
|
||||
Future<void> pumpField({required bool visible}) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: TickerMode(
|
||||
enabled: visible,
|
||||
child: FocusableTextField(controller: controller, focusNode: fieldFocusNode),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
await pumpField(visible: false);
|
||||
fieldFocusNode.requestFocus();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(Dialog), findsNothing);
|
||||
|
||||
await pumpField(visible: true);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(Dialog), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('TV virtual keyboard does not immediately reopen after dismissal', (tester) async {
|
||||
TvDetectionService.debugSetAppleTVOverride(true);
|
||||
await _setTvSurfaceSize(tester);
|
||||
final controller = TextEditingController();
|
||||
final fieldFocusNode = FocusNode(debugLabel: 'search_field');
|
||||
addTearDown(controller.dispose);
|
||||
addTearDown(fieldFocusNode.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: FocusableTextField(controller: controller, focusNode: fieldFocusNode),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
fieldFocusNode.requestFocus();
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(Dialog), findsOneWidget);
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byType(Dialog), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('Android TV native keyboard done uses D-pad navigation', (tester) async {
|
||||
TvDetectionService.debugSetAppleTVOverride(null);
|
||||
await TvDetectionService.getInstance(forceTv: true);
|
||||
@@ -258,6 +280,7 @@ void main() {
|
||||
FocusableTextField(
|
||||
controller: controller,
|
||||
focusNode: fieldFocusNode,
|
||||
enableTvKeyboard: false,
|
||||
textInputAction: TextInputAction.done,
|
||||
onNavigateDown: nextFocusNode.requestFocus,
|
||||
),
|
||||
@@ -278,6 +301,30 @@ void main() {
|
||||
expect(find.byType(Dialog), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('Android TV focus opens the TV virtual keyboard', (tester) async {
|
||||
TvDetectionService.debugSetAppleTVOverride(null);
|
||||
await TvDetectionService.getInstance(forceTv: true);
|
||||
TvDetectionService.setForceTVSync(true);
|
||||
await _setTvSurfaceSize(tester);
|
||||
final controller = TextEditingController();
|
||||
final fieldFocusNode = FocusNode(debugLabel: 'server_url_field');
|
||||
addTearDown(controller.dispose);
|
||||
addTearDown(fieldFocusNode.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: FocusableTextFormField(controller: controller, focusNode: fieldFocusNode),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
fieldFocusNode.requestFocus();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Android TV remote keys are passed to native text input', (tester) async {
|
||||
TvDetectionService.debugSetAppleTVOverride(null);
|
||||
await TvDetectionService.getInstance(forceTv: true);
|
||||
@@ -299,6 +346,7 @@ void main() {
|
||||
FocusableTextFormField(
|
||||
controller: controller,
|
||||
focusNode: fieldFocusNode,
|
||||
enableTvKeyboard: false,
|
||||
onNavigateDown: nextFocusNode.requestFocus,
|
||||
onSelect: () => selects++,
|
||||
onBack: () => backs++,
|
||||
@@ -373,7 +421,7 @@ void main() {
|
||||
home: Scaffold(
|
||||
body: Column(
|
||||
children: [
|
||||
FocusableTextFormField(controller: controller, focusNode: fieldFocusNode),
|
||||
FocusableTextFormField(controller: controller, focusNode: fieldFocusNode, enableTvKeyboard: false),
|
||||
Focus(focusNode: otherFocusNode, child: const SizedBox.shrink()),
|
||||
],
|
||||
),
|
||||
@@ -398,12 +446,51 @@ void main() {
|
||||
expect(gamepadFocusStates, [true, false]);
|
||||
});
|
||||
|
||||
testWidgets('Android TV physical keyboard text keys fall through to the field', (tester) async {
|
||||
testWidgets('Android TV physical keyboard text keys edit the TV field', (tester) async {
|
||||
TvDetectionService.debugSetAppleTVOverride(null);
|
||||
await TvDetectionService.getInstance(forceTv: true);
|
||||
TvDetectionService.setForceTVSync(true);
|
||||
final controller = TextEditingController();
|
||||
final fieldFocusNode = FocusNode(debugLabel: 'name_field');
|
||||
String? submitted;
|
||||
addTearDown(controller.dispose);
|
||||
addTearDown(fieldFocusNode.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: FocusableTextField(
|
||||
controller: controller,
|
||||
focusNode: fieldFocusNode,
|
||||
onSubmitted: (value) => submitted = value,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
fieldFocusNode.requestFocus();
|
||||
await tester.pumpAndSettle();
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyA, character: 'a');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(controller.text, 'a');
|
||||
expect(find.byType(Dialog), findsNothing);
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(submitted, 'a');
|
||||
expect(controller.text, 'a');
|
||||
expect(find.byType(Dialog), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('Android TV physical keyboard backspace deletes existing text from end', (tester) async {
|
||||
TvDetectionService.debugSetAppleTVOverride(null);
|
||||
await TvDetectionService.getInstance(forceTv: true);
|
||||
TvDetectionService.setForceTVSync(true);
|
||||
final controller = TextEditingController(text: 'query');
|
||||
controller.selection = const TextSelection.collapsed(offset: 0);
|
||||
final fieldFocusNode = FocusNode(debugLabel: 'search_field');
|
||||
addTearDown(controller.dispose);
|
||||
addTearDown(fieldFocusNode.dispose);
|
||||
|
||||
@@ -416,24 +503,15 @@ void main() {
|
||||
);
|
||||
|
||||
fieldFocusNode.requestFocus();
|
||||
await tester.pump();
|
||||
final result = fieldFocusNode.onKeyEvent!(
|
||||
fieldFocusNode,
|
||||
const KeyDownEvent(
|
||||
physicalKey: PhysicalKeyboardKey.keyA,
|
||||
logicalKey: LogicalKeyboardKey.keyA,
|
||||
character: 'a',
|
||||
timeStamp: Duration.zero,
|
||||
deviceType: ui.KeyEventDeviceType.keyboard,
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.backspace);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(result, KeyEventResult.ignored);
|
||||
expect(fieldFocusNode.hasPrimaryFocus, isTrue);
|
||||
expect(controller.text, 'quer');
|
||||
expect(find.byType(Dialog), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('tvOS engine-synthesized select opens the virtual keyboard', (tester) async {
|
||||
testWidgets('tvOS engine-synthesized select is handled by the virtual keyboard', (tester) async {
|
||||
// The custom Flutter tvOS engine emits Siri Remote center-dpad presses
|
||||
// as `LogicalKeyboardKey.select` with `deviceType=keyboard` (via the
|
||||
// legacy `flutter/keyevent` Android DPAD_CENTER path). On Apple TV this
|
||||
@@ -470,10 +548,9 @@ void main() {
|
||||
expect(find.byType(Dialog), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('tvOS text field handles physical keyboard text editing without opening virtual keyboard', (
|
||||
tester,
|
||||
) async {
|
||||
testWidgets('tvOS text field handles physical keyboard text editing through virtual keyboard', (tester) async {
|
||||
TvDetectionService.debugSetAppleTVOverride(true);
|
||||
await _setTvSurfaceSize(tester);
|
||||
final controller = TextEditingController();
|
||||
final fieldFocusNode = FocusNode(debugLabel: 'search_field');
|
||||
final changes = <String>[];
|
||||
@@ -497,8 +574,15 @@ void main() {
|
||||
expect(tester.widget<TextField>(find.byType(TextField)).readOnly, isTrue);
|
||||
|
||||
fieldFocusNode.requestFocus();
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(Dialog), findsOneWidget);
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyA, character: 'a');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(controller.text, 'a');
|
||||
expect(find.byType(Dialog), findsNothing);
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyC, character: 'c');
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyB, character: 'b');
|
||||
await tester.pumpAndSettle();
|
||||
@@ -540,7 +624,7 @@ void main() {
|
||||
);
|
||||
|
||||
fieldFocusNode.requestFocus();
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.enter);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
@@ -554,16 +638,6 @@ Future<void> _setTvSurfaceSize(WidgetTester tester) async {
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
}
|
||||
|
||||
KeyEventResult _dispatchKey(KeyEvent event) {
|
||||
FocusNode? node = FocusManager.instance.primaryFocus;
|
||||
while (node != null) {
|
||||
final result = node.onKeyEvent?.call(node, event) ?? KeyEventResult.ignored;
|
||||
if (result != KeyEventResult.ignored) return result;
|
||||
node = node.parent;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
KeyDownEvent _remoteKey(LogicalKeyboardKey key) {
|
||||
return KeyDownEvent(
|
||||
physicalKey: _physicalKeyFor(key),
|
||||
|
||||
@@ -72,7 +72,63 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(controller.text, 'a\n');
|
||||
expect(find.byType(Dialog), findsOneWidget);
|
||||
expect(find.byType(Dialog), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('physical keyboard character inserts text and hides keyboard', (tester) async {
|
||||
final controller = TextEditingController();
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await _pumpKeyboard(tester, controller: controller);
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.keyA, character: 'a');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(controller.text, 'a');
|
||||
expect(find.byType(Dialog), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('physical keyboard backspace deletes existing text from end and hides keyboard', (tester) async {
|
||||
final controller = TextEditingController(text: 'query');
|
||||
controller.selection = const TextSelection.collapsed(offset: 0);
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await _pumpKeyboard(tester, controller: controller);
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.backspace);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(controller.text, 'quer');
|
||||
expect(find.byType(Dialog), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('keyboard is compact and bottom aligned on TV', (tester) async {
|
||||
final controller = TextEditingController();
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await _pumpKeyboard(tester, controller: controller);
|
||||
|
||||
final panelRect = tester.getRect(find.byKey(const Key('tv_virtual_keyboard_panel')));
|
||||
expect(panelRect.height, lessThanOrEqualTo(330));
|
||||
expect(panelRect.width, lessThanOrEqualTo(650));
|
||||
expect(panelRect.bottom, greaterThan(680));
|
||||
});
|
||||
|
||||
testWidgets('keyboard keeps equals on main page and exposes symbols page', (tester) async {
|
||||
final controller = TextEditingController();
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await _pumpKeyboard(tester, controller: controller);
|
||||
|
||||
expect(find.text('='), findsOneWidget);
|
||||
expect(find.byIcon(Icons.functions_rounded), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.functions_rounded));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('ABC'), findsOneWidget);
|
||||
expect(find.text('!'), findsOneWidget);
|
||||
expect(find.text('='), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user