580 lines
18 KiB
Dart
580 lines
18 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:material_symbols_icons/symbols.dart';
|
|
|
|
import '../focus/dpad_navigator.dart';
|
|
import '../i18n/strings.g.dart';
|
|
import '../mixins/mounted_set_state_mixin.dart';
|
|
import '../utils/platform_detector.dart';
|
|
import 'clickable_cursor.dart';
|
|
|
|
Future<void> 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.isAppleTV()) return Future.value();
|
|
|
|
return showDialog<void>(
|
|
context: context,
|
|
barrierDismissible: true,
|
|
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,
|
|
),
|
|
);
|
|
}
|
|
|
|
enum _TvKeyType { spacer, character, shift, 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 = '';
|
|
}
|
|
|
|
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> 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;
|
|
|
|
List<List<_TvKey>> get _rows => _buildRows();
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_column = _firstFocusableColumn(_row) ?? 0;
|
|
widget.controller.addListener(_handleTextChanged);
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) _focusNode.requestFocus();
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
widget.controller.removeListener(_handleTextChanged);
|
|
_focusNode.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _handleTextChanged() {
|
|
setStateIfMounted(() {});
|
|
}
|
|
|
|
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>> _buildRows() {
|
|
if (_isNumberKeyboard) {
|
|
return [
|
|
_characters('123'),
|
|
_characters('456'),
|
|
_characters('789'),
|
|
[
|
|
_TvKey.action(t.common.clear, _TvKeyType.clear, icon: Icons.clear_all_rounded),
|
|
const _TvKey.character('0'),
|
|
const _TvKey.action('Del', _TvKeyType.backspace, icon: Icons.backspace_outlined),
|
|
],
|
|
[
|
|
_TvKey.action(t.common.cancel, _TvKeyType.cancel, icon: Icons.close_rounded),
|
|
const _TvKey.character('.'),
|
|
_TvKey.action(_doneLabel(), _TvKeyType.done, icon: _doneIcon()),
|
|
],
|
|
];
|
|
}
|
|
|
|
final actionRow = [
|
|
const _TvKey.action('Space', _TvKeyType.space, icon: Icons.space_bar_rounded),
|
|
const _TvKey.character('@'),
|
|
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('&'),
|
|
_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()),
|
|
];
|
|
|
|
return [
|
|
[const _TvKey.spacer(), ..._characters('1234567890')],
|
|
[const _TvKey.spacer(), ..._characters('qwertyuiop')],
|
|
[const _TvKey.spacer(), ..._characters('asdfghjkl'), const _TvKey.character("'")],
|
|
[
|
|
_TvKey.action('Shift', _TvKeyType.shift, icon: Symbols.shift),
|
|
..._characters('zxcvbnm.-'),
|
|
const _TvKey.action('Del', _TvKeyType.backspace, icon: Icons.backspace_outlined),
|
|
],
|
|
[const _TvKey.spacer(), ...actionRow],
|
|
];
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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 Icons.search_rounded;
|
|
case TextInputAction.next:
|
|
return Icons.arrow_forward_rounded;
|
|
case TextInputAction.go:
|
|
return Icons.keyboard_double_arrow_right_rounded;
|
|
default:
|
|
return Icons.check_rounded;
|
|
}
|
|
}
|
|
|
|
KeyEventResult _handleKey(FocusNode _, KeyEvent event) {
|
|
final key = event.logicalKey;
|
|
|
|
if (key.isBackKey) {
|
|
if (event is KeyDownEvent) Navigator.of(context).pop();
|
|
return KeyEventResult.handled;
|
|
}
|
|
|
|
if (event is KeyDownEvent || event is KeyRepeatEvent) {
|
|
if (key == LogicalKeyboardKey.backspace || key == LogicalKeyboardKey.delete) {
|
|
_backspace();
|
|
return KeyEventResult.handled;
|
|
}
|
|
|
|
if (event.isPhysicalKeyboardEnter) {
|
|
if (_isMultiline) {
|
|
_insert('\n');
|
|
} else if (event is KeyDownEvent) {
|
|
_submit();
|
|
}
|
|
return KeyEventResult.handled;
|
|
}
|
|
|
|
if (event.isTvSelectEvent) {
|
|
_activate(_rows[_row][_column]);
|
|
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.handled;
|
|
}
|
|
|
|
void _moveHorizontal(int delta) {
|
|
final nextColumn = _nextFocusableColumn(_row, _column + delta, delta);
|
|
if (nextColumn == null) return;
|
|
setState(() {
|
|
_column = nextColumn;
|
|
});
|
|
}
|
|
|
|
void _moveVertical(int delta) {
|
|
final rows = _rows;
|
|
final nextRow = (_row + delta).clamp(0, rows.length - 1).toInt();
|
|
final nextColumn = _nearestFocusableColumn(nextRow, _column);
|
|
setState(() {
|
|
_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);
|
|
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 _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 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 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 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;
|
|
|
|
if (start != end) {
|
|
_replace(
|
|
value.copyWith(
|
|
text: value.text.replaceRange(start, end, ''),
|
|
selection: TextSelection.collapsed(offset: start),
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
if (start == 0) return;
|
|
|
|
_replace(
|
|
value.copyWith(
|
|
text: value.text.replaceRange(start - 1, start, ''),
|
|
selection: TextSelection.collapsed(offset: start - 1),
|
|
composing: TextRange.empty,
|
|
),
|
|
);
|
|
}
|
|
|
|
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;
|
|
widget.onChanged?.call(formattedValue.text);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
final text = widget.controller.text;
|
|
|
|
return Dialog(
|
|
insetPadding: const EdgeInsets.symmetric(horizontal: 56, vertical: 32),
|
|
backgroundColor: Colors.transparent,
|
|
child: Focus(
|
|
focusNode: _focusNode,
|
|
onKeyEvent: _handleKey,
|
|
child: Container(
|
|
constraints: const BoxConstraints(maxWidth: 860),
|
|
padding: const EdgeInsets.all(14),
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.surface.withValues(alpha: 0.96),
|
|
borderRadius: BorderRadius.circular(28),
|
|
),
|
|
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),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildPreview(BuildContext context, String text) {
|
|
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;
|
|
|
|
return Container(
|
|
height: multiline ? 86 : 60,
|
|
alignment: Alignment.centerLeft,
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.7),
|
|
borderRadius: BorderRadius.circular(18),
|
|
),
|
|
child: multiline
|
|
? SingleChildScrollView(
|
|
reverse: true,
|
|
child: Text(
|
|
previewText,
|
|
maxLines: 3,
|
|
style: theme.textTheme.titleLarge?.copyWith(
|
|
color: isEmpty ? colorScheme.onSurfaceVariant : colorScheme.onSurface,
|
|
),
|
|
),
|
|
)
|
|
: SingleChildScrollView(
|
|
scrollDirection: Axis.horizontal,
|
|
reverse: true,
|
|
child: Text(
|
|
previewText,
|
|
maxLines: 1,
|
|
style: theme.textTheme.headlineSmall?.copyWith(
|
|
color: isEmpty ? colorScheme.onSurfaceVariant : colorScheme.onSurface,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildRow(BuildContext context, int row) {
|
|
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),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildKey(BuildContext context, _TvKey key, int row, int column) {
|
|
if (key.type == _TvKeyType.spacer) {
|
|
return const SizedBox(width: _keySize, height: _keySize);
|
|
}
|
|
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
final selected = row == _row && column == _column;
|
|
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: () {
|
|
setState(() {
|
|
_row = row;
|
|
_column = column;
|
|
});
|
|
_activate(key);
|
|
},
|
|
child: AnimatedContainer(
|
|
duration: const Duration(milliseconds: 120),
|
|
width: _keySize,
|
|
height: _keySize,
|
|
alignment: Alignment.center,
|
|
decoration: BoxDecoration(color: background, borderRadius: BorderRadius.circular(16)),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 4),
|
|
child: _buildKeyContent(context, key, foreground),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildKeyContent(BuildContext context, _TvKey key, Color foreground) {
|
|
final icon = key.icon;
|
|
if (icon != null) {
|
|
return Icon(icon, color: foreground, size: key.type == _TvKeyType.space ? 34 : 30);
|
|
}
|
|
|
|
return FittedBox(
|
|
fit: BoxFit.scaleDown,
|
|
child: Text(
|
|
key.label,
|
|
maxLines: 1,
|
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(color: foreground, fontWeight: FontWeight.w800),
|
|
),
|
|
);
|
|
}
|
|
}
|