diff --git a/lib/focus/focusable_text_field.dart b/lib/focus/focusable_text_field.dart index cda74ec2..7c4c7ad0 100644 --- a/lib/focus/focusable_text_field.dart +++ b/lib/focus/focusable_text_field.dart @@ -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 _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, ), ); } diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index a25a61fa..ee52493f 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -1012,13 +1012,22 @@ class _MainScreenState extends State }); } - 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 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, diff --git a/lib/widgets/tv_virtual_keyboard.dart b/lib/widgets/tv_virtual_keyboard.dart index 346358a1..fe9a373e 100644 --- a/lib/widgets/tv_virtual_keyboard.dart +++ b/lib/widgets/tv_virtual_keyboard.dart @@ -22,11 +22,13 @@ Future showTvVirtualKeyboard({ ValueChanged? onSubmitted, VoidCallback? onAction, }) { - if (!PlatformDetector.isAppleTV()) return Future.value(); + if (!PlatformDetector.isTV()) return Future.value(); return showDialog( 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 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> get _rows => _buildRows(); + List> 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> _buildRows() { + List> _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> _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 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, + }); +} diff --git a/test/widgets/focusable_text_field_test.dart b/test/widgets/focusable_text_field_test.dart index e53fd21d..8c33f855 100644 --- a/test/widgets/focusable_text_field_test.dart +++ b/test/widgets/focusable_text_field_test.dart @@ -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 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 = []; @@ -497,8 +574,15 @@ void main() { expect(tester.widget(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 _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), diff --git a/test/widgets/tv_virtual_keyboard_test.dart b/test/widgets/tv_virtual_keyboard_test.dart index f98dec3d..6df797e5 100644 --- a/test/widgets/tv_virtual_keyboard_test.dart +++ b/test/widgets/tv_virtual_keyboard_test.dart @@ -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); }); }