feat(tvos): add virtual keyboard input
This commit is contained in:
@@ -1,16 +1,75 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../utils/platform_detector.dart';
|
||||
import '../widgets/tv_virtual_keyboard.dart';
|
||||
import 'dpad_navigator.dart';
|
||||
|
||||
/// A [TextField] wrapper that exposes D-pad navigation callbacks with
|
||||
/// caret-aware edge escapes — so LEFT at the start of the field and RIGHT
|
||||
/// at the end escape to neighbouring focus targets instead of bouncing
|
||||
/// against the caret boundary, while UP/DOWN always escape.
|
||||
///
|
||||
/// Collapsed selection only: if text is selected, LEFT/RIGHT fall through
|
||||
/// to the TextField's default caret movement.
|
||||
class FocusableTextField extends StatelessWidget {
|
||||
bool _usesTvKeyboard(bool enableTvKeyboard) => enableTvKeyboard && PlatformDetector.isAppleTV();
|
||||
|
||||
String? _keyboardHint(InputDecoration? decoration) => decoration?.hintText ?? decoration?.labelText;
|
||||
|
||||
KeyEventResult _handleInputKey({
|
||||
required TextEditingController controller,
|
||||
required bool usesTvKeyboard,
|
||||
required bool enabled,
|
||||
required VoidCallback openKeyboard,
|
||||
required KeyEvent event,
|
||||
VoidCallback? onSelect,
|
||||
VoidCallback? onBack,
|
||||
VoidCallback? onNavigateLeft,
|
||||
VoidCallback? onNavigateRight,
|
||||
VoidCallback? onNavigateUp,
|
||||
VoidCallback? onNavigateDown,
|
||||
}) {
|
||||
final key = event.logicalKey;
|
||||
|
||||
if (usesTvKeyboard && enabled && key.isSelectKey) {
|
||||
if (event is KeyDownEvent) openKeyboard();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
if (onBack != null && key.isBackKey) {
|
||||
if (event is KeyDownEvent) onBack();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
// Enter/numpad enter are left to TextField.onSubmitted. Handle only
|
||||
// non-text submit keys that TV remotes/gamepads may send while editing.
|
||||
if (!usesTvKeyboard &&
|
||||
onSelect != null &&
|
||||
(key == LogicalKeyboardKey.select || key == LogicalKeyboardKey.gameButtonA)) {
|
||||
if (event is KeyDownEvent) onSelect();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
if (!event.isActionable) return KeyEventResult.ignored;
|
||||
|
||||
if (key.isUpKey && onNavigateUp != null) {
|
||||
onNavigateUp();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key.isDownKey && onNavigateDown != null) {
|
||||
onNavigateDown();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
final sel = controller.selection;
|
||||
if (sel.isCollapsed) {
|
||||
if (key.isLeftKey && sel.baseOffset == 0 && onNavigateLeft != null) {
|
||||
onNavigateLeft();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key.isRightKey && sel.baseOffset == controller.text.length && onNavigateRight != null) {
|
||||
onNavigateRight();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
}
|
||||
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
abstract class _FocusableTextInputBase extends StatelessWidget {
|
||||
final TextEditingController controller;
|
||||
final FocusNode? focusNode;
|
||||
final InputDecoration? decoration;
|
||||
@@ -19,15 +78,29 @@ class FocusableTextField extends StatelessWidget {
|
||||
final List<TextInputFormatter>? inputFormatters;
|
||||
final ValueChanged<String>? onChanged;
|
||||
final ValueChanged<String>? onSubmitted;
|
||||
final VoidCallback? onEditingComplete;
|
||||
final VoidCallback? onSelect;
|
||||
final VoidCallback? onBack;
|
||||
final bool autofocus;
|
||||
final bool enabled;
|
||||
final bool enableTvKeyboard;
|
||||
final bool obscureText;
|
||||
final bool autocorrect;
|
||||
final bool enableSuggestions;
|
||||
final bool? enableInteractiveSelection;
|
||||
final int? maxLength;
|
||||
final int? maxLines;
|
||||
final int? minLines;
|
||||
final TextAlign textAlign;
|
||||
final TextCapitalization textCapitalization;
|
||||
final TextStyle? style;
|
||||
|
||||
final VoidCallback? onNavigateLeft;
|
||||
final VoidCallback? onNavigateRight;
|
||||
final VoidCallback? onNavigateUp;
|
||||
final VoidCallback? onNavigateDown;
|
||||
|
||||
const FocusableTextField({
|
||||
const _FocusableTextInputBase({
|
||||
super.key,
|
||||
required this.controller,
|
||||
this.focusNode,
|
||||
@@ -37,64 +110,221 @@ class FocusableTextField extends StatelessWidget {
|
||||
this.inputFormatters,
|
||||
this.onChanged,
|
||||
this.onSubmitted,
|
||||
this.onEditingComplete,
|
||||
this.onSelect,
|
||||
this.onBack,
|
||||
this.autofocus = false,
|
||||
this.enabled = true,
|
||||
this.enableTvKeyboard = true,
|
||||
this.obscureText = false,
|
||||
this.autocorrect = true,
|
||||
this.enableSuggestions = true,
|
||||
this.enableInteractiveSelection,
|
||||
this.maxLength,
|
||||
this.maxLines = 1,
|
||||
this.minLines,
|
||||
this.textAlign = TextAlign.start,
|
||||
this.textCapitalization = TextCapitalization.none,
|
||||
this.style,
|
||||
this.onNavigateLeft,
|
||||
this.onNavigateRight,
|
||||
this.onNavigateUp,
|
||||
this.onNavigateDown,
|
||||
});
|
||||
|
||||
KeyEventResult _handleKey(FocusNode _, KeyEvent event) {
|
||||
final key = event.logicalKey;
|
||||
bool get _hasTvKeyboard => _usesTvKeyboard(enableTvKeyboard);
|
||||
|
||||
// Enter/numpad enter are left to TextField.onSubmitted. Handle only
|
||||
// non-text submit keys that TV remotes/gamepads may send while editing.
|
||||
if (onSelect != null && (key == LogicalKeyboardKey.select || key == LogicalKeyboardKey.gameButtonA)) {
|
||||
if (event is KeyDownEvent) onSelect!();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
if (!event.isActionable) return KeyEventResult.ignored;
|
||||
|
||||
if (key.isUpKey && onNavigateUp != null) {
|
||||
onNavigateUp!();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key.isDownKey && onNavigateDown != null) {
|
||||
onNavigateDown!();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
final sel = controller.selection;
|
||||
if (sel.isCollapsed) {
|
||||
if (key.isLeftKey && sel.baseOffset == 0 && onNavigateLeft != null) {
|
||||
onNavigateLeft!();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key.isRightKey && sel.baseOffset == controller.text.length && onNavigateRight != null) {
|
||||
onNavigateRight!();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
}
|
||||
|
||||
return KeyEventResult.ignored;
|
||||
void _showTvKeyboard(BuildContext context) {
|
||||
if (!enabled) return;
|
||||
showTvVirtualKeyboard(
|
||||
context: context,
|
||||
controller: controller,
|
||||
hintText: _keyboardHint(decoration),
|
||||
keyboardType: keyboardType,
|
||||
textInputAction: textInputAction,
|
||||
inputFormatters: inputFormatters,
|
||||
obscureText: obscureText,
|
||||
maxLength: maxLength,
|
||||
maxLines: maxLines,
|
||||
onChanged: onChanged,
|
||||
onSubmitted: onSubmitted,
|
||||
onAction: onEditingComplete ?? onSelect,
|
||||
);
|
||||
}
|
||||
|
||||
KeyEventResult _handleKey(BuildContext context, FocusNode _, KeyEvent event) {
|
||||
return _handleInputKey(
|
||||
controller: controller,
|
||||
usesTvKeyboard: _hasTvKeyboard,
|
||||
enabled: enabled,
|
||||
openKeyboard: () => _showTvKeyboard(context),
|
||||
event: event,
|
||||
onSelect: onSelect,
|
||||
onBack: onBack,
|
||||
onNavigateLeft: onNavigateLeft,
|
||||
onNavigateRight: onNavigateRight,
|
||||
onNavigateUp: onNavigateUp,
|
||||
onNavigateDown: onNavigateDown,
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildFocusableInput(BuildContext context, Widget Function(bool usesTvKeyboard) builder) {
|
||||
final usesTvKeyboard = _hasTvKeyboard;
|
||||
return Focus(
|
||||
canRequestFocus: enabled,
|
||||
onKeyEvent: (node, event) => _handleKey(context, node, event),
|
||||
child: builder(usesTvKeyboard),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A [TextField] wrapper that exposes D-pad navigation callbacks with
|
||||
/// caret-aware edge escapes — so LEFT at the start of the field and RIGHT
|
||||
/// at the end escape to neighbouring focus targets instead of bouncing
|
||||
/// against the caret boundary, while UP/DOWN always escape.
|
||||
///
|
||||
/// Collapsed selection only: if text is selected, LEFT/RIGHT fall through
|
||||
/// to the TextField's default caret movement.
|
||||
class FocusableTextField extends _FocusableTextInputBase {
|
||||
const FocusableTextField({
|
||||
super.key,
|
||||
required super.controller,
|
||||
super.focusNode,
|
||||
super.decoration,
|
||||
super.keyboardType,
|
||||
super.textInputAction,
|
||||
super.inputFormatters,
|
||||
super.onChanged,
|
||||
super.onSubmitted,
|
||||
super.onEditingComplete,
|
||||
super.onSelect,
|
||||
super.onBack,
|
||||
super.autofocus,
|
||||
super.enabled,
|
||||
super.enableTvKeyboard,
|
||||
super.obscureText,
|
||||
super.autocorrect,
|
||||
super.enableSuggestions,
|
||||
super.enableInteractiveSelection,
|
||||
super.maxLength,
|
||||
super.maxLines,
|
||||
super.minLines,
|
||||
super.textAlign,
|
||||
super.textCapitalization,
|
||||
super.style,
|
||||
super.onNavigateLeft,
|
||||
super.onNavigateRight,
|
||||
super.onNavigateUp,
|
||||
super.onNavigateDown,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Focus(
|
||||
onKeyEvent: _handleKey,
|
||||
child: TextField(
|
||||
return buildFocusableInput(
|
||||
context,
|
||||
(usesTvKeyboard) => TextField(
|
||||
controller: controller,
|
||||
focusNode: focusNode,
|
||||
enabled: enabled,
|
||||
decoration: decoration,
|
||||
keyboardType: keyboardType,
|
||||
keyboardType: usesTvKeyboard ? TextInputType.none : keyboardType,
|
||||
textInputAction: textInputAction,
|
||||
inputFormatters: inputFormatters,
|
||||
onChanged: onChanged,
|
||||
onSubmitted: onSubmitted,
|
||||
onEditingComplete: onEditingComplete,
|
||||
autofocus: autofocus,
|
||||
autocorrect: autocorrect,
|
||||
enableSuggestions: enableSuggestions,
|
||||
obscureText: obscureText,
|
||||
maxLength: maxLength,
|
||||
maxLines: maxLines,
|
||||
minLines: minLines,
|
||||
textAlign: textAlign,
|
||||
textCapitalization: textCapitalization,
|
||||
style: style,
|
||||
readOnly: usesTvKeyboard,
|
||||
showCursor: usesTvKeyboard ? true : null,
|
||||
enableInteractiveSelection: usesTvKeyboard ? false : enableInteractiveSelection,
|
||||
onTap: usesTvKeyboard ? () => _showTvKeyboard(context) : null,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class FocusableTextFormField extends _FocusableTextInputBase {
|
||||
final ValueChanged<String>? onFieldSubmitted;
|
||||
final FormFieldValidator<String>? validator;
|
||||
final AutovalidateMode? autovalidateMode;
|
||||
final FormFieldSetter<String>? onSaved;
|
||||
|
||||
const FocusableTextFormField({
|
||||
super.key,
|
||||
required super.controller,
|
||||
super.focusNode,
|
||||
super.decoration,
|
||||
super.keyboardType,
|
||||
super.textInputAction,
|
||||
super.inputFormatters,
|
||||
super.onChanged,
|
||||
this.onFieldSubmitted,
|
||||
super.onEditingComplete,
|
||||
super.onSelect,
|
||||
super.onBack,
|
||||
this.validator,
|
||||
this.autovalidateMode,
|
||||
this.onSaved,
|
||||
super.autofocus,
|
||||
super.enabled,
|
||||
super.enableTvKeyboard,
|
||||
super.obscureText,
|
||||
super.autocorrect,
|
||||
super.enableSuggestions,
|
||||
super.enableInteractiveSelection,
|
||||
super.maxLength,
|
||||
super.maxLines,
|
||||
super.minLines,
|
||||
super.textAlign,
|
||||
super.textCapitalization,
|
||||
super.style,
|
||||
super.onNavigateLeft,
|
||||
super.onNavigateRight,
|
||||
super.onNavigateUp,
|
||||
super.onNavigateDown,
|
||||
}) : super(onSubmitted: onFieldSubmitted);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return buildFocusableInput(
|
||||
context,
|
||||
(usesTvKeyboard) => TextFormField(
|
||||
controller: controller,
|
||||
focusNode: focusNode,
|
||||
enabled: enabled,
|
||||
decoration: decoration,
|
||||
keyboardType: usesTvKeyboard ? TextInputType.none : keyboardType,
|
||||
textInputAction: textInputAction,
|
||||
inputFormatters: inputFormatters,
|
||||
onChanged: onChanged,
|
||||
onFieldSubmitted: onFieldSubmitted,
|
||||
onEditingComplete: onEditingComplete,
|
||||
validator: validator,
|
||||
autovalidateMode: autovalidateMode,
|
||||
onSaved: onSaved,
|
||||
autofocus: autofocus,
|
||||
autocorrect: autocorrect,
|
||||
enableSuggestions: enableSuggestions,
|
||||
obscureText: obscureText,
|
||||
maxLength: maxLength,
|
||||
maxLines: maxLines,
|
||||
minLines: minLines,
|
||||
textAlign: textAlign,
|
||||
textCapitalization: textCapitalization,
|
||||
style: style,
|
||||
readOnly: usesTvKeyboard,
|
||||
showCursor: usesTvKeyboard ? true : null,
|
||||
enableInteractiveSelection: usesTvKeyboard ? false : enableInteractiveSelection,
|
||||
onTap: usesTvKeyboard ? () => _showTvKeyboard(context) : null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import '../i18n/strings.g.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/platform_detector.dart';
|
||||
import '../focus/focusable_button.dart';
|
||||
import '../focus/focusable_text_field.dart';
|
||||
import '../media/media_backend.dart';
|
||||
import '../utils/navigation_transitions.dart';
|
||||
import '../widgets/backend_badge.dart';
|
||||
@@ -459,7 +460,7 @@ class _DebugTokenDialogState extends State<_DebugTokenDialog> with ControllerDis
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextFormField(
|
||||
FocusableTextFormField(
|
||||
controller: _tokenController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Plex Auth Token',
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../focus/focusable_text_field.dart';
|
||||
import '../../models/companion_remote/remote_command.dart';
|
||||
import '../../models/companion_remote/remote_session.dart';
|
||||
import '../../i18n/strings.g.dart';
|
||||
@@ -678,7 +679,7 @@ class _SearchBottomSheetState extends State<_SearchBottomSheet> with ControllerD
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
FocusableTextField(
|
||||
controller: _controller,
|
||||
autofocus: true,
|
||||
decoration: pillInputDecoration(
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../focus/focusable_text_field.dart';
|
||||
|
||||
/// Bordered "Profile name" text field used by both the new-profile flow and
|
||||
/// the profile-detail rename row. Optional [trailing] slot for an inline Save
|
||||
/// button — pass `null` when the screen saves elsewhere (e.g. on Continue).
|
||||
@@ -13,7 +15,7 @@ class ProfileNameField extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final field = TextField(
|
||||
final field = FocusableTextField(
|
||||
controller: controller,
|
||||
textInputAction: TextInputAction.done,
|
||||
decoration: InputDecoration(hintText: hintText, border: const OutlineInputBorder()),
|
||||
|
||||
@@ -4,7 +4,7 @@ import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:rate_limiter/rate_limiter.dart';
|
||||
|
||||
import '../focus/dpad_navigator.dart';
|
||||
import '../focus/focusable_text_field.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../media/media_item.dart';
|
||||
import '../mixins/controller_disposer_mixin.dart';
|
||||
@@ -168,37 +168,6 @@ class _SearchScreenState extends State<SearchScreen>
|
||||
MainScreenFocusScope.of(context)?.focusSidebar();
|
||||
}
|
||||
|
||||
/// Handle key events on the search input for D-pad navigation
|
||||
KeyEventResult _handleSearchInputKeyEvent(FocusNode _, KeyEvent event) {
|
||||
if (!event.isActionable) return KeyEventResult.ignored;
|
||||
|
||||
final key = event.logicalKey;
|
||||
|
||||
// DOWN: Focus first result if results exist and not loading
|
||||
if (key.isDownKey && _searchResults.isNotEmpty && !_isSearching) {
|
||||
_firstResultFocusNode.requestFocus();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
// LEFT at cursor position 0: Navigate to sidebar
|
||||
if (key.isLeftKey && _searchController.selection.baseOffset == 0) {
|
||||
_navigateToSidebar();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
// BACK: Clear search or navigate to sidebar
|
||||
if (key.isBackKey) {
|
||||
if (_searchController.text.isNotEmpty) {
|
||||
_searchController.clear();
|
||||
} else {
|
||||
_navigateToSidebar();
|
||||
}
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
Widget _buildResultsList(BuildContext context) {
|
||||
final multiServer = context.watch<MultiServerProvider>();
|
||||
final showServerName = multiServer.totalServerCount > 1;
|
||||
@@ -234,25 +203,34 @@ class _SearchScreenState extends State<SearchScreen>
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 16, right: 16, bottom: 16),
|
||||
child: Focus(
|
||||
onKeyEvent: _handleSearchInputKeyEvent,
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
focusNode: _searchFocusNode,
|
||||
decoration: pillInputDecoration(
|
||||
context,
|
||||
hintText: t.search.hint,
|
||||
prefixIcon: const AppIcon(Symbols.search_rounded, fill: 1),
|
||||
suffixIcon: _searchController.text.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const AppIcon(Symbols.clear_rounded, fill: 1),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
// State update handled by listener
|
||||
},
|
||||
)
|
||||
: null,
|
||||
),
|
||||
child: FocusableTextField(
|
||||
controller: _searchController,
|
||||
focusNode: _searchFocusNode,
|
||||
textInputAction: TextInputAction.search,
|
||||
onNavigateLeft: _navigateToSidebar,
|
||||
onNavigateDown: _searchResults.isNotEmpty && !_isSearching
|
||||
? _firstResultFocusNode.requestFocus
|
||||
: null,
|
||||
onBack: () {
|
||||
if (_searchController.text.isNotEmpty) {
|
||||
_searchController.clear();
|
||||
} else {
|
||||
_navigateToSidebar();
|
||||
}
|
||||
},
|
||||
decoration: pillInputDecoration(
|
||||
context,
|
||||
hintText: t.search.hint,
|
||||
prefixIcon: const AppIcon(Symbols.search_rounded, fill: 1),
|
||||
suffixIcon: _searchController.text.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const AppIcon(Symbols.clear_rounded, fill: 1),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
// State update handled by listener
|
||||
},
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../../connection/connection.dart';
|
||||
import '../../exceptions/media_server_exceptions.dart';
|
||||
import '../../focus/focusable_text_field.dart';
|
||||
import '../../i18n/strings.g.dart';
|
||||
import '../../mixins/controller_disposer_mixin.dart';
|
||||
import '../../profiles/active_profile_binder.dart';
|
||||
@@ -338,7 +339,7 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
|
||||
return [
|
||||
Text(t.addServer.jellyfinUrlIntro, style: theme.textTheme.bodyMedium),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
FocusableTextFormField(
|
||||
controller: _urlController,
|
||||
keyboardType: TextInputType.url,
|
||||
autocorrect: false,
|
||||
@@ -363,7 +364,7 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
|
||||
const SizedBox(height: 16),
|
||||
_buildServerCard(theme),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
FocusableTextFormField(
|
||||
controller: _usernameController,
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
@@ -377,7 +378,7 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
|
||||
validator: (v) => v == null || v.trim().isEmpty ? t.addServer.required : null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
FocusableTextFormField(
|
||||
controller: _passwordController,
|
||||
focusNode: _passwordFocus,
|
||||
obscureText: true,
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:plezy/widgets/app_icon.dart';
|
||||
|
||||
import '../../focus/focusable_button.dart';
|
||||
import '../../focus/focusable_text_field.dart';
|
||||
import '../../i18n/strings.g.dart';
|
||||
import '../../models/external_player_models.dart';
|
||||
import '../../services/settings_service.dart';
|
||||
@@ -154,7 +155,7 @@ Future<void> _showAddCustomPlayerDialog(BuildContext context) async {
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
FocusableTextField(
|
||||
controller: nameController,
|
||||
decoration: InputDecoration(labelText: t.externalPlayer.playerName, hintText: 'My Player'),
|
||||
autofocus: true,
|
||||
@@ -179,7 +180,7 @@ Future<void> _showAddCustomPlayerDialog(BuildContext context) async {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
FocusableTextField(
|
||||
controller: valueController,
|
||||
focusNode: valueFocusNode,
|
||||
decoration: InputDecoration(labelText: fieldLabel, hintText: fieldHint),
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
|
||||
import 'package:plezy/widgets/app_icon.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import '../../focus/dpad_navigator.dart';
|
||||
import '../../focus/focusable_text_field.dart';
|
||||
import '../../focus/key_event_utils.dart';
|
||||
import '../../i18n/strings.g.dart';
|
||||
import '../../mixins/controller_disposer_mixin.dart';
|
||||
@@ -164,9 +165,10 @@ class _MpvConfigScreenState extends State<MpvConfigScreen> with SettingsEffectMi
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
},
|
||||
child: TextField(
|
||||
child: FocusableTextField(
|
||||
controller: _textController,
|
||||
focusNode: _textFieldFocusNode,
|
||||
keyboardType: TextInputType.multiline,
|
||||
maxLines: null,
|
||||
minLines: 12,
|
||||
decoration: InputDecoration(
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'package:provider/provider.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../../focus/focus_memory_tracker.dart';
|
||||
import '../../focus/focusable_text_field.dart';
|
||||
import '../../focus/input_mode_tracker.dart';
|
||||
import '../../i18n/strings.g.dart';
|
||||
import '../main_screen.dart';
|
||||
@@ -654,7 +655,7 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab {
|
||||
builder: (BuildContext dialogContext) {
|
||||
return AlertDialog(
|
||||
title: Text(t.settings.watchTogetherRelay),
|
||||
content: TextField(
|
||||
content: FocusableTextField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(labelText: 'URL', hintText: t.settings.watchTogetherRelayHint),
|
||||
autofocus: true,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flex_color_picker/flex_color_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../focus/focusable_text_field.dart';
|
||||
import '../../focus/input_mode_tracker.dart';
|
||||
import '../../i18n/strings.g.dart';
|
||||
import '../../widgets/dialog_action_button.dart';
|
||||
@@ -211,7 +212,7 @@ void _showNumericInputDialogStandard({
|
||||
context: context,
|
||||
title: title,
|
||||
contentBuilder: (_, _, setDialogState, saveFocusNode) {
|
||||
return TextField(
|
||||
return FocusableTextField(
|
||||
controller: controller,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: InputDecoration(
|
||||
@@ -350,7 +351,7 @@ void showRegexInputDialog({
|
||||
context: context,
|
||||
title: title,
|
||||
contentBuilder: (_, _, setDialogState, saveFocusNode) {
|
||||
return TextField(
|
||||
return FocusableTextField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(labelText: 'Regex', errorText: errorText),
|
||||
autofocus: true,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import '../focus/focusable_button.dart';
|
||||
import '../focus/focusable_text_field.dart';
|
||||
import '../focus/input_mode_tracker.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../mixins/controller_disposer_mixin.dart';
|
||||
@@ -254,10 +255,11 @@ class _MultilineTextInputDialogState extends State<_MultilineTextInputDialog>
|
||||
title: Text(widget.title),
|
||||
content: SizedBox(
|
||||
width: 400,
|
||||
child: TextField(
|
||||
child: FocusableTextField(
|
||||
controller: _controller,
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(labelText: widget.labelText),
|
||||
keyboardType: TextInputType.multiline,
|
||||
maxLines: 8,
|
||||
minLines: 3,
|
||||
),
|
||||
@@ -315,7 +317,7 @@ class _TextInputDialogState extends State<_TextInputDialog>
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text(widget.title),
|
||||
content: TextField(
|
||||
content: FocusableTextField(
|
||||
controller: _controller,
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(labelText: widget.labelText, hintText: widget.hintText),
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:provider/provider.dart';
|
||||
|
||||
import '../../i18n/strings.g.dart';
|
||||
import '../../focus/focusable_button.dart';
|
||||
import '../../focus/focusable_text_field.dart';
|
||||
import '../../focus/focusable_wrapper.dart';
|
||||
import '../../profiles/active_profile_provider.dart';
|
||||
import '../../services/settings_service.dart';
|
||||
@@ -313,7 +314,7 @@ class _NotInSessionViewState extends State<_NotInSessionView> {
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(t.watchTogether.renameRoom),
|
||||
content: TextField(
|
||||
content: FocusableTextField(
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(hintText: room.code),
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
import '../../focus/focusable_button.dart';
|
||||
import '../../focus/focusable_text_field.dart';
|
||||
import '../../focus/focusable_wrapper.dart';
|
||||
import '../../i18n/strings.g.dart';
|
||||
import '../../mixins/controller_disposer_mixin.dart';
|
||||
@@ -53,7 +54,7 @@ class _JoinSessionDialogState extends State<JoinSessionDialog> with ControllerDi
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Session ID input
|
||||
TextFormField(
|
||||
FocusableTextFormField(
|
||||
controller: _sessionIdController,
|
||||
decoration: InputDecoration(
|
||||
labelText: t.watchTogether.sessionCode,
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../connection/connection_registry.dart';
|
||||
import '../../focus/focusable_text_field.dart';
|
||||
import '../../i18n/strings.g.dart';
|
||||
import '../../mixins/controller_disposer_mixin.dart';
|
||||
import '../../models/plex/plex_home.dart';
|
||||
@@ -305,7 +306,7 @@ class _DiscoveryViewState extends State<DiscoveryView> with ControllerDisposerMi
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TextFormField(
|
||||
FocusableTextFormField(
|
||||
controller: _hostAddressController,
|
||||
decoration: InputDecoration(
|
||||
labelText: t.companionRemote.session.hostAddress,
|
||||
|
||||
@@ -35,6 +35,7 @@ import '../utils/dialogs.dart';
|
||||
import '../utils/focus_utils.dart';
|
||||
import '../services/external_player_service.dart';
|
||||
import '../focus/focusable_button.dart';
|
||||
import '../focus/focusable_text_field.dart';
|
||||
import '../focus/dpad_navigator.dart';
|
||||
import '../screens/plex_match_screen.dart';
|
||||
import '../screens/media_detail_screen.dart';
|
||||
@@ -1610,7 +1611,7 @@ class _CollectionSelectionDialogState extends State<_CollectionSelectionDialog>
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (widget.collections.length >= 10) ...[
|
||||
TextField(
|
||||
FocusableTextField(
|
||||
controller: _filterController,
|
||||
autofocus: true,
|
||||
decoration: pillInputDecoration(
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import '../focus/dpad_navigator.dart';
|
||||
import '../focus/focusable_button.dart';
|
||||
import '../focus/focusable_text_field.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../mixins/controller_disposer_mixin.dart';
|
||||
import '../widgets/app_icon.dart';
|
||||
@@ -71,7 +72,7 @@ class _TagEditDialogState extends State<TagEditDialog> with ControllerDisposerMi
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
FocusableTextField(
|
||||
controller: _controller,
|
||||
focusNode: _textFieldFocusNode,
|
||||
autofocus: true,
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
|
||||
|
||||
import '../focus/dpad_navigator.dart';
|
||||
import '../focus/focus_theme.dart';
|
||||
import '../focus/focusable_text_field.dart';
|
||||
import '../focus/input_mode_tracker.dart';
|
||||
import '../focus/key_repeat_helper.dart';
|
||||
import '../mixins/controller_disposer_mixin.dart';
|
||||
@@ -169,7 +170,7 @@ class _TvColorPickerState extends State<TvColorPicker> with ControllerDisposerMi
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Hex input
|
||||
TextField(
|
||||
FocusableTextField(
|
||||
controller: _hexController,
|
||||
focusNode: _hexFocusNode,
|
||||
decoration: const InputDecoration(prefixText: '#', labelText: 'Hex', border: OutlineInputBorder()),
|
||||
|
||||
@@ -0,0 +1,566 @@
|
||||
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 '../utils/platform_detector.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> {
|
||||
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() {
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
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 (key.isSelectKey) {
|
||||
_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 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user