import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../focus/dpad_navigator.dart'; import '../focus/key_event_utils.dart'; import '../i18n/strings.g.dart'; import '../utils/platform_detector.dart'; import 'app_icon.dart'; import 'clickable_cursor.dart'; import 'listenable_selector.dart'; bool _keyboardTextWarmedUp = false; /// One-shot warm-up of the keyboard's text layout caches. /// /// The first keyboard open lays out ~60 key labels in one frame; on low-end /// TVs the first paragraph alone measured 130ms+ (cold font/shaping caches) /// and the full first open ~315ms. Laying the keyboard's glyph set out once /// during startup idle moves that cost off the first real open. Subsequent /// calls are no-ops. void warmUpTvVirtualKeyboardText(BuildContext context) { if (_keyboardTextWarmedUp || !PlatformDetector.isTV()) return; _keyboardTextWarmedUp = true; // Matches the key cap style (titleLarge w800, see _buildKey) at the sizes // the metrics clamp to; shaping caches are per font/size. final baseStyle = Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w800); const samples = [ 'abcdefghijklmnopqrstuvwxyz', 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', '0123456789 @#_/:=&-+()[]{}<>!?\'".,;*%\\^~|', 'Space Del Line Shift Symbols Done Cancel Clear Search Next Go', ]; for (final fontSize in const [18.0, 22.0]) { final style = baseStyle?.copyWith(fontSize: fontSize); for (final sample in samples) { final painter = TextPainter( text: TextSpan(text: sample, style: style), textDirection: TextDirection.ltr, )..layout(); painter.dispose(); } } } /// Handle to a TV virtual keyboard pushed by [showTvVirtualKeyboard]. class TvVirtualKeyboardHandle { TvVirtualKeyboardHandle._(this._navigator, this._route); final NavigatorState _navigator; final DialogRoute _route; /// Completes when the keyboard leaves the navigator — submit, cancel, /// barrier/back dismiss, or [close]. Future get closed => _route.popped; /// Dismiss the keyboard if it is still up. No-ops once the route is gone /// or the navigator is being torn down. void close() { if (!_navigator.mounted) return; if (_route.isCurrent) { _navigator.pop(); } else if (_route.isActive) { // removeRoute also completes route.popped, which backs [closed]. _navigator.removeRoute(_route); } } } TvVirtualKeyboardHandle? showTvVirtualKeyboard({ required BuildContext context, required TextEditingController controller, String? hintText, TextInputType? keyboardType, TextInputAction? textInputAction, List? inputFormatters, bool obscureText = false, int? maxLength, int? maxLines, ValueChanged? onChanged, ValueChanged? onSubmitted, VoidCallback? onAction, }) { if (!PlatformDetector.isTV()) return null; // A hand-built DialogRoute instead of showDialog so the caller gets a // handle it can close when the owning field unmounts. Use the nearest // navigator so profile-owned keyboards are disposed with their profile // session. final navigator = Navigator.of(context); final route = DialogRoute( context: context, barrierDismissible: true, barrierColor: Colors.black.withValues(alpha: 0.1), useSafeArea: false, themes: InheritedTheme.capture(from: context, to: navigator.context), traversalEdgeBehavior: TraversalEdgeBehavior.closedLoop, builder: (context) => _TvVirtualKeyboardDialog( controller: controller, hintText: hintText, keyboardType: keyboardType, textInputAction: textInputAction, inputFormatters: inputFormatters, obscureText: obscureText, maxLength: maxLength, maxLines: maxLines, onChanged: onChanged, onSubmitted: onSubmitted, onAction: onAction, ), ); unawaited(navigator.push(route)); return TvVirtualKeyboardHandle._(navigator, route); } enum _TvKeyType { spacer, character, shift, symbols, space, newline, backspace, clear, cancel, done } class _TvKey { final String label; final String value; final _TvKeyType type; final IconData? icon; const _TvKey.spacer() : label = '', value = '', type = _TvKeyType.spacer, icon = null; const _TvKey.character(this.value) : label = value, type = _TvKeyType.character, icon = null; const _TvKey.action(this.label, this.type, {this.icon}) : value = ''; } typedef _TvKeyboardSelection = ({int row, int column}); class _TvVirtualKeyboardDialog extends StatefulWidget { final TextEditingController controller; final String? hintText; final TextInputType? keyboardType; final TextInputAction? textInputAction; final List? inputFormatters; final bool obscureText; final int? maxLength; final int? maxLines; final ValueChanged? onChanged; final ValueChanged? onSubmitted; final VoidCallback? onAction; const _TvVirtualKeyboardDialog({ required this.controller, this.hintText, this.keyboardType, this.textInputAction, this.inputFormatters, this.obscureText = false, this.maxLength, this.maxLines, this.onChanged, this.onSubmitted, this.onAction, }); @override State<_TvVirtualKeyboardDialog> createState() => _TvVirtualKeyboardDialogState(); } class _TvVirtualKeyboardDialogState extends State<_TvVirtualKeyboardDialog> { final _focusNode = FocusNode(debugLabel: 'TvVirtualKeyboard'); final ValueNotifier<_TvKeyboardSelection> _selection = ValueNotifier((row: 0, column: 0)); List> _rows = const []; Locale? _rowsLocale; bool _shiftEnabled = false; bool _symbolsPage = false; int get _row => _selection.value.row; int get _column => _selection.value.column; int get _gridColumnCount => _isNumberKeyboard ? 3 : 12; @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _focusNode.requestFocus(); }); } @override void didChangeDependencies() { super.didChangeDependencies(); final locale = Localizations.maybeLocaleOf(context); if (_rows.isNotEmpty && locale == _rowsLocale) return; _rowsLocale = locale; _refreshRows(resetSelection: _rows.isEmpty); } @override void dispose() { _selection.dispose(); _focusNode.dispose(); super.dispose(); } void _refreshRows({bool resetSelection = false}) { _rows = _symbolsPage ? _buildSymbolRows() : _buildMainRows(); final current = _selection.value; final row = resetSelection ? 0 : current.row.clamp(0, _rows.length - 1); final column = resetSelection ? (_firstFocusableColumn(row) ?? 0) : _nearestFocusableColumn(row, current.column); _selection.value = (row: row, column: column); } bool get _isNumberKeyboard { final type = widget.keyboardType; return type?.index == TextInputType.number.index || type?.index == TextInputType.phone.index; } bool get _isMultiline { final type = widget.keyboardType; return type?.index == TextInputType.multiline.index || (widget.maxLines != null && widget.maxLines != 1); } List> _buildMainRows() { if (_isNumberKeyboard) { return [ _characters('123'), _characters('456'), _characters('789'), [ _TvKey.action(t.common.clear, _TvKeyType.clear, icon: Symbols.clear_all_rounded), const _TvKey.character('0'), const _TvKey.action('Del', _TvKeyType.backspace, icon: Symbols.backspace_rounded), ], [ _TvKey.action(t.common.cancel, _TvKeyType.cancel, icon: Symbols.close_rounded), const _TvKey.character('.'), _TvKey.action(_doneLabel(), _TvKeyType.done, icon: _doneIcon()), ], ]; } final actionRow = [ const _TvKey.action('Space', _TvKeyType.space, icon: Symbols.space_bar_rounded), const _TvKey.character('@'), const _TvKey.character('#'), const _TvKey.character('_'), const _TvKey.character('/'), const _TvKey.character(':'), const _TvKey.character('='), _isMultiline ? const _TvKey.action('Line', _TvKeyType.newline, icon: Symbols.keyboard_return_rounded) : const _TvKey.character('&'), _TvKey.action(t.common.clear, _TvKeyType.clear, icon: Symbols.clear_all_rounded), _TvKey.action(t.common.cancel, _TvKeyType.cancel, icon: Symbols.close_rounded), _TvKey.action(_doneLabel(), _TvKeyType.done, icon: _doneIcon()), ]; return [ [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: Symbols.functions_rounded), _TvKey.action('Shift', _TvKeyType.shift, icon: Symbols.shift_rounded), ..._characters('zxcvbnm.-'), const _TvKey.action('Del', _TvKeyType.backspace, icon: Symbols.backspace_rounded), ], [const _TvKey.spacer(), ...actionRow], ]; } List> _buildSymbolRows() { return [ [ const _TvKey.action('ABC', _TvKeyType.symbols), ..._symbols(['!', '?', r'$', '%', '^', '*', '+', '=', '~']), const _TvKey.action('Del', _TvKeyType.backspace, icon: Symbols.backspace_rounded), 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: Symbols.space_bar_rounded), const _TvKey.character('@'), const _TvKey.character('#'), const _TvKey.character('_'), const _TvKey.character('/'), _TvKey.action(t.common.clear, _TvKeyType.clear, icon: Symbols.clear_all_rounded), _TvKey.action(t.common.cancel, _TvKeyType.cancel, icon: Symbols.close_rounded), _TvKey.action(_doneLabel(), _TvKeyType.done, icon: _doneIcon()), const _TvKey.spacer(), const _TvKey.spacer(), ], ]; } List<_TvKey> _characters(String chars) { return chars .split('') .map((c) { final code = c.codeUnitAt(0); final shifted = _shiftEnabled && code >= 0x61 && code <= 0x7a ? c.toUpperCase() : c; return _TvKey.character(shifted); }) .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: return t.common.search; case TextInputAction.next: return t.companionRemote.remote.next; case TextInputAction.go: return t.common.submit; default: return t.common.ok; } } IconData _doneIcon() { switch (widget.textInputAction) { case TextInputAction.search: return Symbols.search_rounded; case TextInputAction.next: return Symbols.arrow_forward_rounded; case TextInputAction.go: return Symbols.keyboard_double_arrow_right_rounded; default: return Symbols.check_rounded; } } KeyEventResult _handleKey(FocusNode _, KeyEvent event) { final key = event.logicalKey; if (key.isBackKey) { if (event is KeyUpEvent) Navigator.of(context).pop(); return KeyEventResult.handled; } if (event.isTvSelectEvent) { return handleOneShotSelect(event, () => _activate(_rows[_row][_column])); } if (event is KeyDownEvent || event is KeyRepeatEvent) { 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) { _insert('\n'); } else if (event is KeyDownEvent) { _submit(); } return KeyEventResult.handled; } if (key.isUpKey) { _moveVertical(-1); return KeyEventResult.handled; } if (key.isDownKey) { _moveVertical(1); return KeyEventResult.handled; } if (key.isLeftKey) { _moveHorizontal(-1); return KeyEventResult.handled; } if (key.isRightKey) { _moveHorizontal(1); return KeyEventResult.handled; } final character = event.character; if (character != null && character.isNotEmpty && !key.isNavigationKey) { _insert(character); return KeyEventResult.handled; } } return KeyEventResult.ignored; } 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) { var nextColumn = _nextFocusableColumn(_row, _column + delta, delta); if (nextColumn == null) { final wrapStart = delta > 0 ? 0 : _rows[_row].length - 1; nextColumn = _nextFocusableColumn(_row, wrapStart, delta); } if (nextColumn == null) return; final column = nextColumn; _selection.value = (row: _row, column: column); } void _moveVertical(int delta) { final rows = _rows; final nextRow = (_row + delta) % rows.length; final nextColumn = _nearestFocusableColumn(nextRow, _column); _selection.value = (row: nextRow, column: nextColumn); } int? _firstFocusableColumn(int row) { final rows = _rows; if (row < 0 || row >= rows.length) return null; final index = rows[row].indexWhere(_isFocusableKey); return index == -1 ? null : index; } int? _nextFocusableColumn(int row, int column, int delta) { final rows = _rows; if (row < 0 || row >= rows.length) return null; for (var c = column; c >= 0 && c < rows[row].length; c += delta) { if (_isFocusableKey(rows[row][c])) return c; } return null; } int _nearestFocusableColumn(int row, int preferredColumn) { final rows = _rows; if (row < 0 || row >= rows.length) return preferredColumn; final keys = rows[row]; if (preferredColumn >= 0 && preferredColumn < keys.length && _isFocusableKey(keys[preferredColumn])) { return preferredColumn; } for (var offset = 1; offset < keys.length; offset++) { final right = preferredColumn + offset; if (right >= 0 && right < keys.length && _isFocusableKey(keys[right])) return right; final left = preferredColumn - offset; if (left >= 0 && left < keys.length && _isFocusableKey(keys[left])) return left; } return _firstFocusableColumn(row) ?? 0; } bool _isFocusableKey(_TvKey key) => key.type != _TvKeyType.spacer; void _activate(_TvKey key) { switch (key.type) { case _TvKeyType.spacer: return; case _TvKeyType.character: _insert(key.value); return; case _TvKeyType.shift: setState(() { _shiftEnabled = !_shiftEnabled; _refreshRows(); }); return; case _TvKeyType.symbols: _toggleSymbolsPage(); return; case _TvKeyType.space: _insert(' '); return; case _TvKeyType.newline: _insert('\n'); return; case _TvKeyType.backspace: _backspace(); return; case _TvKeyType.clear: _replace(TextEditingValue.empty); return; case _TvKeyType.cancel: Navigator.of(context).pop(); return; case _TvKeyType.done: _submit(); return; } } void _toggleSymbolsPage() { setState(() { _symbolsPage = !_symbolsPage; _refreshRows(); }); } void _submit() { final text = widget.controller.text; final onSubmitted = widget.onSubmitted; final onAction = widget.onAction; Navigator.of(context).pop(); WidgetsBinding.instance.addPostFrameCallback((_) { if (onSubmitted != null) { onSubmitted(text); } else { onAction?.call(); } }); } void _insert(String text) { final value = widget.controller.value; final (:start, :end) = _selectionRangeForEdit(value); final newText = value.text.replaceRange(start, end, text); _replace( value.copyWith( text: newText, selection: TextSelection.collapsed(offset: start + text.length), composing: TextRange.empty, ), ); } void _backspace() { final value = widget.controller.value; final (:start, :end) = _selectionRangeForEdit(value); if (start == end && start == 0) return; final codeUnitRange = start == end ? TextRange(start: start - 1, end: start) : TextRange(start: start, end: end); final range = expandToGraphemeRange(value.text, codeUnitRange); if (range.isCollapsed) return; _replace( value.copyWith( text: value.text.replaceRange(range.start, range.end, ''), selection: TextSelection.collapsed(offset: range.start), composing: TextRange.empty, ), ); } void _deleteForward() { final value = widget.controller.value; final (:start, :end) = _selectionRangeForEdit(value); if (start == end && start >= value.text.length) return; final codeUnitRange = start == end ? TextRange(start: start, end: start + 1) : TextRange(start: start, end: end); final range = expandToGraphemeRange(value.text, codeUnitRange); if (range.isCollapsed) return; _replace( value.copyWith( text: value.text.replaceRange(range.start, range.end, ''), selection: TextSelection.collapsed(offset: range.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; final maxLength = widget.maxLength; final formatters = [ ...?widget.inputFormatters, if (maxLength != null && maxLength > 0) LengthLimitingTextInputFormatter(maxLength), ]; for (final formatter in formatters) { formattedValue = formatter.formatEditUpdate(previousValue, formattedValue); } widget.controller.value = formattedValue; 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; return Dialog( key: const Key('tv_virtual_keyboard_dialog'), alignment: .bottomCenter, insetPadding: .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( key: const Key('tv_virtual_keyboard_panel'), constraints: BoxConstraints(maxWidth: metrics.panelWidth), padding: .all(metrics.panelPadding), decoration: BoxDecoration( color: colorScheme.surface.withValues(alpha: 0.96), borderRadius: BorderRadius.circular(metrics.panelRadius), ), child: SizedBox( width: metrics.gridWidth, child: Column( mainAxisSize: .min, crossAxisAlignment: .stretch, children: [ ValueListenableBuilder( valueListenable: widget.controller, builder: (context, value, _) => _buildPreview(context, value.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), ], ], ), ), ), ), ); } _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: metrics.previewHeight, alignment: .centerLeft, padding: .symmetric(horizontal: metrics.keySize * 0.3), decoration: BoxDecoration( color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.7), borderRadius: BorderRadius.circular(metrics.previewRadius), ), child: multiline ? SingleChildScrollView(reverse: true, child: Text(previewText, maxLines: 3, style: style)) : SingleChildScrollView( scrollDirection: Axis.horizontal, reverse: true, child: Text(previewText, maxLines: 1, style: style), ), ); } Widget _buildRow(BuildContext context, int row, _TvKeyboardMetrics metrics) { return Row( mainAxisAlignment: .center, children: [ for (var column = 0; column < _rows[row].length; column++) ...[ _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, _TvKeyboardMetrics metrics) { if (key.type == _TvKeyType.spacer) { return SizedBox(width: metrics.keySize, height: metrics.keySize); } return ListenableSelector( listenable: _selection, selector: () => _selection.value == (row: row, column: column), builder: (context, selected, _) { final colorScheme = Theme.of(context).colorScheme; final active = key.type == _TvKeyType.shift && _shiftEnabled; final background = selected ? colorScheme.primary : active ? colorScheme.secondaryContainer : colorScheme.surfaceContainerHighest.withValues(alpha: 0.88); final foreground = selected ? colorScheme.onPrimary : active ? colorScheme.onSecondaryContainer : colorScheme.onSurface; return ClickableCursor( child: GestureDetector( onTap: () { _selection.value = (row: row, column: column); _activate(key); }, child: AnimatedContainer( duration: const Duration(milliseconds: 120), width: metrics.keySize, height: metrics.keySize, alignment: .center, decoration: BoxDecoration(color: background, borderRadius: BorderRadius.circular(metrics.keyRadius)), child: Padding( padding: .symmetric(horizontal: metrics.keySize * 0.04), child: _buildKeyContent(context, key, foreground, metrics), ), ), ), ); }, ); } Widget _buildKeyContent(BuildContext context, _TvKey key, Color foreground, _TvKeyboardMetrics metrics) { final icon = key.icon; if (icon != null) { return AppIcon( icon, color: foreground, size: key.type == _TvKeyType.space ? metrics.iconSize * 1.12 : metrics.iconSize, ); } return FittedBox( fit: BoxFit.scaleDown, child: Text( key.label, maxLines: 1, style: Theme.of( context, ).textTheme.titleLarge?.copyWith(color: foreground, fontSize: metrics.keyFontSize, 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, }); }