From 876a44b2445bc49ffa747c24da06e41fb7ce02ee Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 3 May 2026 01:32:58 +0200 Subject: [PATCH] feat(tvos): add virtual keyboard input --- lib/focus/focusable_text_field.dart | 322 ++++++++-- lib/screens/auth_screen.dart | 3 +- .../mobile_remote_screen.dart | 3 +- lib/screens/profile/profile_name_field.dart | 4 +- lib/screens/search_screen.dart | 80 +-- lib/screens/settings/add_jellyfin_screen.dart | 7 +- .../settings/external_player_screen.dart | 5 +- lib/screens/settings/mpv_config_screen.dart | 4 +- lib/screens/settings/settings_screen.dart | 3 +- lib/screens/settings/settings_utils.dart | 5 +- lib/utils/dialogs.dart | 6 +- .../screens/watch_together_screen.dart | 3 +- .../widgets/join_session_dialog.dart | 3 +- .../companion_remote/discovery_view.dart | 3 +- lib/widgets/media_context_menu.dart | 3 +- lib/widgets/tag_edit_dialog.dart | 3 +- lib/widgets/tv_color_picker.dart | 3 +- lib/widgets/tv_virtual_keyboard.dart | 566 ++++++++++++++++++ 18 files changed, 909 insertions(+), 117 deletions(-) create mode 100644 lib/widgets/tv_virtual_keyboard.dart diff --git a/lib/focus/focusable_text_field.dart b/lib/focus/focusable_text_field.dart index ff4994f9..2fc49097 100644 --- a/lib/focus/focusable_text_field.dart +++ b/lib/focus/focusable_text_field.dart @@ -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? inputFormatters; final ValueChanged? onChanged; final ValueChanged? 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? onFieldSubmitted; + final FormFieldValidator? validator; + final AutovalidateMode? autovalidateMode; + final FormFieldSetter? 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, ), ); } diff --git a/lib/screens/auth_screen.dart b/lib/screens/auth_screen.dart index caec22d4..1e4ad9db 100644 --- a/lib/screens/auth_screen.dart +++ b/lib/screens/auth_screen.dart @@ -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', diff --git a/lib/screens/companion_remote/mobile_remote_screen.dart b/lib/screens/companion_remote/mobile_remote_screen.dart index 1ce40e11..fee38404 100644 --- a/lib/screens/companion_remote/mobile_remote_screen.dart +++ b/lib/screens/companion_remote/mobile_remote_screen.dart @@ -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( diff --git a/lib/screens/profile/profile_name_field.dart b/lib/screens/profile/profile_name_field.dart index e3f778c9..9d24aa21 100644 --- a/lib/screens/profile/profile_name_field.dart +++ b/lib/screens/profile/profile_name_field.dart @@ -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()), diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index 8479f6fd..18596f48 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -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 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(); final showServerName = multiServer.totalServerCount > 1; @@ -234,25 +203,34 @@ class _SearchScreenState extends State 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, ), ), ), diff --git a/lib/screens/settings/add_jellyfin_screen.dart b/lib/screens/settings/add_jellyfin_screen.dart index 98598ba3..ddb05ec2 100644 --- a/lib/screens/settings/add_jellyfin_screen.dart +++ b/lib/screens/settings/add_jellyfin_screen.dart @@ -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 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 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 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, diff --git a/lib/screens/settings/external_player_screen.dart b/lib/screens/settings/external_player_screen.dart index 2a7fa073..a26f9e78 100644 --- a/lib/screens/settings/external_player_screen.dart +++ b/lib/screens/settings/external_player_screen.dart @@ -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 _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 _showAddCustomPlayerDialog(BuildContext context) async { ), ), const SizedBox(height: 16), - TextField( + FocusableTextField( controller: valueController, focusNode: valueFocusNode, decoration: InputDecoration(labelText: fieldLabel, hintText: fieldHint), diff --git a/lib/screens/settings/mpv_config_screen.dart b/lib/screens/settings/mpv_config_screen.dart index 01fb2e92..fed69b5a 100644 --- a/lib/screens/settings/mpv_config_screen.dart +++ b/lib/screens/settings/mpv_config_screen.dart @@ -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 with SettingsEffectMi } return KeyEventResult.ignored; }, - child: TextField( + child: FocusableTextField( controller: _textController, focusNode: _textFieldFocusNode, + keyboardType: TextInputType.multiline, maxLines: null, minLines: 12, decoration: InputDecoration( diff --git a/lib/screens/settings/settings_screen.dart b/lib/screens/settings/settings_screen.dart index c0beda4a..26763853 100644 --- a/lib/screens/settings/settings_screen.dart +++ b/lib/screens/settings/settings_screen.dart @@ -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 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, diff --git a/lib/screens/settings/settings_utils.dart b/lib/screens/settings/settings_utils.dart index 556200b3..e4c41ab0 100644 --- a/lib/screens/settings/settings_utils.dart +++ b/lib/screens/settings/settings_utils.dart @@ -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, diff --git a/lib/utils/dialogs.dart b/lib/utils/dialogs.dart index f06b67d9..f35b49e2 100644 --- a/lib/utils/dialogs.dart +++ b/lib/utils/dialogs.dart @@ -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), diff --git a/lib/watch_together/screens/watch_together_screen.dart b/lib/watch_together/screens/watch_together_screen.dart index a3481291..6bf359b9 100644 --- a/lib/watch_together/screens/watch_together_screen.dart +++ b/lib/watch_together/screens/watch_together_screen.dart @@ -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), diff --git a/lib/watch_together/widgets/join_session_dialog.dart b/lib/watch_together/widgets/join_session_dialog.dart index 9a4cdf04..eef08e9e 100644 --- a/lib/watch_together/widgets/join_session_dialog.dart +++ b/lib/watch_together/widgets/join_session_dialog.dart @@ -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 with ControllerDi const SizedBox(height: 24), // Session ID input - TextFormField( + FocusableTextFormField( controller: _sessionIdController, decoration: InputDecoration( labelText: t.watchTogether.sessionCode, diff --git a/lib/widgets/companion_remote/discovery_view.dart b/lib/widgets/companion_remote/discovery_view.dart index 6352dc93..238bc139 100644 --- a/lib/widgets/companion_remote/discovery_view.dart +++ b/lib/widgets/companion_remote/discovery_view.dart @@ -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 with ControllerDisposerMi child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - TextFormField( + FocusableTextFormField( controller: _hostAddressController, decoration: InputDecoration( labelText: t.companionRemote.session.hostAddress, diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index c1e7f45f..b5cdef92 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -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( diff --git a/lib/widgets/tag_edit_dialog.dart b/lib/widgets/tag_edit_dialog.dart index ea1bc52f..08d3ee55 100644 --- a/lib/widgets/tag_edit_dialog.dart +++ b/lib/widgets/tag_edit_dialog.dart @@ -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 with ControllerDisposerMi child: Column( mainAxisSize: MainAxisSize.min, children: [ - TextField( + FocusableTextField( controller: _controller, focusNode: _textFieldFocusNode, autofocus: true, diff --git a/lib/widgets/tv_color_picker.dart b/lib/widgets/tv_color_picker.dart index 4d42e9d2..4d2390ac 100644 --- a/lib/widgets/tv_color_picker.dart +++ b/lib/widgets/tv_color_picker.dart @@ -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 with ControllerDisposerMi ), const SizedBox(height: 16), // Hex input - TextField( + FocusableTextField( controller: _hexController, focusNode: _hexFocusNode, decoration: const InputDecoration(prefixText: '#', labelText: 'Hex', border: OutlineInputBorder()), diff --git a/lib/widgets/tv_virtual_keyboard.dart b/lib/widgets/tv_virtual_keyboard.dart new file mode 100644 index 00000000..e96f666e --- /dev/null +++ b/lib/widgets/tv_virtual_keyboard.dart @@ -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 showTvVirtualKeyboard({ + required BuildContext context, + required TextEditingController controller, + String? hintText, + TextInputType? keyboardType, + TextInputAction? textInputAction, + List? inputFormatters, + bool obscureText = false, + int? maxLength, + int? maxLines, + ValueChanged? onChanged, + ValueChanged? onSubmitted, + VoidCallback? onAction, +}) { + if (!PlatformDetector.isAppleTV()) return Future.value(); + + return showDialog( + 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? inputFormatters; + final bool obscureText; + final int? maxLength; + final int? maxLines; + final ValueChanged? onChanged; + final ValueChanged? onSubmitted; + final VoidCallback? onAction; + + const _TvVirtualKeyboardDialog({ + required this.controller, + this.hintText, + this.keyboardType, + this.textInputAction, + this.inputFormatters, + this.obscureText = false, + this.maxLength, + this.maxLines, + this.onChanged, + this.onSubmitted, + this.onAction, + }); + + @override + State<_TvVirtualKeyboardDialog> createState() => _TvVirtualKeyboardDialogState(); +} + +class _TvVirtualKeyboardDialogState extends State<_TvVirtualKeyboardDialog> { + 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> 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> _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), + ), + ); + } +}