fix(tv): improve virtual keyboard input

This commit is contained in:
edde746
2026-05-26 14:01:20 +02:00
parent 3fc3baef83
commit 15cc85147a
5 changed files with 584 additions and 169 deletions
+89 -17
View File
@@ -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,
),
);
}
+15 -5
View File
@@ -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,
+278 -75
View File
@@ -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,
});
}
+145 -71
View File
@@ -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),
+57 -1
View File
@@ -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);
});
}