Files
plezy/lib/widgets/tv_virtual_keyboard.dart
T
edde746 e3703892b3 fix(player): keep a keyboard Enter out of focus navigation
Pressing Enter over the player put the whole app into keyboard mode and
dropped focus onto Play/Pause, even with Video Player Navigation off. Two
independent paths did it. InputModeTracker promoted on any key satisfying
isNavigationKey, a set that unioned activation, dismissal and the menu key
with the arrows and consulted no setting at all; separately the surface's
Select handler always asked the chrome for focus. Escape had the same effect,
which on desktop reads as the mouse cursor vanishing mid-playback.

Both now ask one predicate. eventRequestsFocusNavigation decides whether the
app switches to keyboard mode and whether a key may hand focus to the chrome,
so the two cannot disagree and focus can never land on a control while focus
chrome is still suppressed. Activation and dismissal act on what already has
focus, so they answer no; Tab, the menu key, a remote's OK or BACK, and an
arrow that will really traverse answer yes. The one input the predicate cannot
read off the event, whether the focused feature owns arrow keys, rides on the
node as DirectionalShortcutFocusNode instead of on a subtree, so every sheet,
prompt and OSD button stays an ordinary traversal target with nothing to
re-enable.

playerDirectionalNavigationEnabled and videoPlayerNavigationPreference replace
five hand-copied pref-or-isTV expressions and a screen-level cache that
disagreed with the live getter after a toggle. Services whose input is
synthesized past HardwareKeyboard announce themselves through
InputModeTracker.reportNonPointerInput rather than two static callbacks and
three copies of a highlight-strategy write. That registration is now
identity-guarded: the bootstrap-to-app tree swap disposed the outgoing tracker
after the incoming one initialised and cleared both callbacks, so gamepad and
companion remote input had stopped switching to keyboard mode entirely.

Falling out of the same rule: a companion heartbeat no longer flips an idle
desktop host into keyboard mode, analog-stick drift promotes only past the
deadzone that actually navigates, Enter keeps toggling playback once the
chrome is up, Tab both reaches and traverses the OSD, and the player surface
claims the remote from mount rather than only when the chrome starts hidden,
so the first key on a desktop route is a playback shortcut instead of the
screen node's chrome-raising self-heal.

isNavigationKey becomes isReservedControlKey, since its real meaning is a
shell key rather than a text character and the old name is what invited the
conflation. The unreachable PlayerChromeFocusTarget.timeline goes with it.
2026-08-07 13:23:53 +02:00

856 lines
28 KiB
Dart

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<void> _route;
/// Completes when the keyboard leaves the navigator — submit, cancel,
/// barrier/back dismiss, or [close].
Future<void> 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<TextInputFormatter>? inputFormatters,
bool obscureText = false,
int? maxLength,
int? maxLines,
ValueChanged<String>? onChanged,
ValueChanged<String>? 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<void>(
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<TextInputFormatter>? inputFormatters;
final bool obscureText;
final int? maxLength;
final int? maxLines;
final ValueChanged<String>? onChanged;
final ValueChanged<String>? 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<List<_TvKey>> _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<List<_TvKey>> _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<List<_TvKey>> _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<String> 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.isReservedControlKey) {
_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.isReservedControlKey && !_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<TextEditingValue>(
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<bool>(
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,
});
}