diff --git a/lib/focus/focus_theme.dart b/lib/focus/focus_theme.dart index bd1e6ba6..3e7054e7 100644 --- a/lib/focus/focus_theme.dart +++ b/lib/focus/focus_theme.dart @@ -29,8 +29,9 @@ class FocusTheme { BuildContext context, { required bool isFocused, double borderRadius = defaultBorderRadius, + Color? color, }) { - final focusColor = getFocusBorderColor(context); + final focusColor = color ?? getFocusBorderColor(context); return BoxDecoration( borderRadius: BorderRadius.circular(borderRadius), diff --git a/lib/focus/focusable_button.dart b/lib/focus/focusable_button.dart new file mode 100644 index 00000000..ae09ebac --- /dev/null +++ b/lib/focus/focusable_button.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; + +import 'focus_theme.dart'; +import 'focusable_wrapper.dart'; +import 'input_mode_tracker.dart'; + +/// A focusable button wrapper for D-pad navigation on TV. +/// +/// Wraps any button widget with [FocusableWrapper] and adds a white overlay +/// + contrasting border when focused. Tracks focus state internally so callers +/// don't need manual state management. +/// +/// ```dart +/// FocusableButton( +/// autofocus: true, +/// onPressed: _doSomething, +/// child: FilledButton.icon( +/// onPressed: _doSomething, +/// icon: Icon(Symbols.add_rounded), +/// label: Text('Create'), +/// ), +/// ) +/// ``` +class FocusableButton extends StatefulWidget { + final Widget child; + final VoidCallback? onPressed; + final bool autofocus; + final FocusNode? focusNode; + + /// Navigation callbacks for explicit focus control (e.g. horizontal button rows). + final VoidCallback? onNavigateUp; + final VoidCallback? onNavigateDown; + final VoidCallback? onNavigateLeft; + final VoidCallback? onNavigateRight; + final VoidCallback? onBack; + + /// Whether to scroll the widget into view when focused. + final bool autoScroll; + + const FocusableButton({ + super.key, + required this.child, + this.onPressed, + this.autofocus = false, + this.focusNode, + this.onNavigateUp, + this.onNavigateDown, + this.onNavigateLeft, + this.onNavigateRight, + this.onBack, + this.autoScroll = true, + }); + + @override + State createState() => _FocusableButtonState(); +} + +class _FocusableButtonState extends State { + bool _isFocused = false; + + @override + Widget build(BuildContext context) { + final isKeyboard = InputModeTracker.isKeyboardMode(context); + final showFocus = _isFocused && isKeyboard; + final duration = FocusTheme.getAnimationDuration(context); + // In dpad mode: focused = full opacity, unfocused = dimmed + final opacity = isKeyboard && !_isFocused ? 0.6 : 1.0; + + return FocusableWrapper( + autofocus: widget.autofocus, + focusNode: widget.focusNode, + disableScale: true, + borderRadius: 100, + descendantsAreFocusable: false, + onFocusChange: (f) => setState(() => _isFocused = f), + autoScroll: widget.autoScroll, + onSelect: widget.onPressed, + onNavigateUp: widget.onNavigateUp, + onNavigateDown: widget.onNavigateDown, + onNavigateLeft: widget.onNavigateLeft, + onNavigateRight: widget.onNavigateRight, + onBack: widget.onBack, + child: AnimatedOpacity( + opacity: showFocus ? 1.0 : opacity, + duration: duration, + child: widget.child, + ), + ); + } +} diff --git a/lib/focus/focusable_wrapper.dart b/lib/focus/focusable_wrapper.dart index 825f72cc..8d116bb1 100644 --- a/lib/focus/focusable_wrapper.dart +++ b/lib/focus/focusable_wrapper.dart @@ -33,6 +33,9 @@ class FocusableWrapper extends StatefulWidget { /// Called when the user presses UP and there's no focusable item above. final VoidCallback? onNavigateUp; + /// Called when the user presses DOWN and there's no focusable item below. + final VoidCallback? onNavigateDown; + /// Called when the user presses LEFT and there's no focusable item to the left. final VoidCallback? onNavigateLeft; @@ -83,10 +86,19 @@ class FocusableWrapper extends StatefulWidget { /// Useful for video controls where outline doesn't look good. final bool useBackgroundFocus; + /// Custom color for the focus border. Only used when [useBackgroundFocus] is false. + /// Useful for filled buttons where the default primary border blends in. + final Color? focusColor; + /// Whether to disable the scale animation on focus. /// Useful for elements like sliders where scaling looks odd. final bool disableScale; + /// Whether descendants can receive focus. + /// Set to false when the child widget has its own Focus (e.g. buttons) + /// that would compete with this wrapper's focus handling. + final bool descendantsAreFocusable; + const FocusableWrapper({ super.key, required this.child, @@ -94,6 +106,7 @@ class FocusableWrapper extends StatefulWidget { this.onLongPress, this.onFocusChange, this.onNavigateUp, + this.onNavigateDown, this.onNavigateLeft, this.onNavigateRight, this.onBack, @@ -109,7 +122,9 @@ class FocusableWrapper extends StatefulWidget { this.enableLongPress = false, this.longPressDuration = const Duration(milliseconds: 500), this.useBackgroundFocus = false, + this.focusColor, this.disableScale = false, + this.descendantsAreFocusable = true, }); @override @@ -367,6 +382,12 @@ class _FocusableWrapperState extends State with SingleTickerPr return KeyEventResult.handled; } + // DOWN arrow - if callback provided, navigate down + if (key == LogicalKeyboardKey.arrowDown && widget.onNavigateDown != null) { + widget.onNavigateDown!(); + return KeyEventResult.handled; + } + // LEFT arrow - if callback provided, navigate left (caller is responsible // for only providing this callback when the item is at the left edge) if (key == LogicalKeyboardKey.arrowLeft && widget.onNavigateLeft != null) { @@ -398,11 +419,12 @@ class _FocusableWrapperState extends State with SingleTickerPr // Choose decoration based on useBackgroundFocus final decoration = widget.useBackgroundFocus ? FocusTheme.focusBackgroundDecoration(isFocused: showFocus, borderRadius: widget.borderRadius) - : FocusTheme.focusDecoration(context, isFocused: showFocus, borderRadius: widget.borderRadius); + : FocusTheme.focusDecoration(context, isFocused: showFocus, borderRadius: widget.borderRadius, color: widget.focusColor); Widget result = Focus( focusNode: _focusNode, autofocus: widget.autofocus, + descendantsAreFocusable: widget.descendantsAreFocusable, onFocusChange: _handleFocusChange, onKeyEvent: _handleKeyEvent, child: AnimatedBuilder( diff --git a/lib/screens/auth_screen.dart b/lib/screens/auth_screen.dart index 18d8702d..5dc25f79 100644 --- a/lib/screens/auth_screen.dart +++ b/lib/screens/auth_screen.dart @@ -16,6 +16,7 @@ import '../i18n/strings.g.dart'; import '../theme/mono_tokens.dart'; import '../utils/app_logger.dart'; import '../utils/platform_detector.dart'; +import '../focus/focusable_button.dart'; import 'main_screen.dart'; class AuthScreen extends StatefulWidget { @@ -398,7 +399,7 @@ class _AuthScreenState extends State { children: [ if (isTV) ...[ // On TV: QR is primary, browser is secondary - ElevatedButton( + FocusableButton( autofocus: true, onPressed: () { setState(() { @@ -406,14 +407,25 @@ class _AuthScreenState extends State { }); _startAuthentication(); }, - style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)), - child: Text(t.auth.showQRCode), + child: ElevatedButton( + onPressed: () { + setState(() { + _useQrFlow = true; + }); + _startAuthentication(); + }, + style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)), + child: Text(t.auth.showQRCode), + ), ), const SizedBox(height: 12), - OutlinedButton( + FocusableButton( onPressed: _startAuthentication, - style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)), - child: Text(t.auth.useBrowser), + child: OutlinedButton( + onPressed: _startAuthentication, + style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)), + child: Text(t.auth.useBrowser), + ), ), ] else ...[ // On other platforms: Browser is primary, QR is secondary @@ -500,22 +512,33 @@ class _AuthScreenState extends State { Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - OutlinedButton( + FocusableButton( autofocus: true, onPressed: _retryAuthentication, - style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 24)), - child: Text(t.common.retry), + child: OutlinedButton( + onPressed: _retryAuthentication, + style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 24)), + child: Text(t.common.retry), + ), ), const SizedBox(width: 16), - OutlinedButton( + FocusableButton( onPressed: () { setState(() { _useQrFlow = false; }); _startAuthentication(); }, - style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 24)), - child: Text(t.auth.useBrowser), + child: OutlinedButton( + onPressed: () { + setState(() { + _useQrFlow = false; + }); + _startAuthentication(); + }, + style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 24)), + child: Text(t.auth.useBrowser), + ), ), ], ), diff --git a/lib/screens/companion_remote/pairing_screen.dart b/lib/screens/companion_remote/pairing_screen.dart index fff75dfe..2539be5b 100644 --- a/lib/screens/companion_remote/pairing_screen.dart +++ b/lib/screens/companion_remote/pairing_screen.dart @@ -5,6 +5,7 @@ import 'package:flutter/services.dart'; import 'package:mobile_scanner/mobile_scanner.dart'; import 'package:provider/provider.dart'; +import '../../focus/focusable_button.dart'; import '../../i18n/strings.g.dart'; import '../../providers/companion_remote_provider.dart'; import '../../utils/formatters.dart'; @@ -441,8 +442,18 @@ class _PairingScreenState extends State { title: Text(t.companionRemote.pairing.removeRecentConnection), content: Text(t.companionRemote.pairing.removeConfirm(name: session.deviceName)), actions: [ - TextButton(onPressed: () => Navigator.pop(context, false), child: Text(t.common.cancel)), - TextButton(onPressed: () => Navigator.pop(context, true), child: Text(t.common.remove)), + FocusableButton( + autofocus: true, + onPressed: () => Navigator.pop(context, false), + child: TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text(t.common.cancel), + ), + ), + FocusableButton( + onPressed: () => Navigator.pop(context, true), + child: TextButton(onPressed: () => Navigator.pop(context, true), child: Text(t.common.remove)), + ), ], ), ); diff --git a/lib/screens/libraries/libraries_screen.dart b/lib/screens/libraries/libraries_screen.dart index f81b91b6..9bd1d5dc 100644 --- a/lib/screens/libraries/libraries_screen.dart +++ b/lib/screens/libraries/libraries_screen.dart @@ -4,6 +4,7 @@ import 'package:material_symbols_icons/symbols.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import 'package:dio/dio.dart'; +import '../../focus/focusable_button.dart'; import '../../focus/dpad_navigator.dart'; import '../../focus/focus_theme.dart'; import '../../focus/input_mode_tracker.dart'; @@ -1480,11 +1481,21 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { title: Text(selectedItem.confirmationTitle ?? t.dialog.confirmAction), content: Text(selectedItem.confirmationMessage ?? t.libraries.confirmActionMessage), actions: [ - TextButton(onPressed: () => Navigator.pop(context, false), child: Text(t.common.cancel)), - TextButton( + FocusableButton( + autofocus: true, + onPressed: () => Navigator.pop(context, false), + child: TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text(t.common.cancel), + ), + ), + FocusableButton( onPressed: () => Navigator.pop(context, true), - style: selectedItem.isDestructive ? TextButton.styleFrom(foregroundColor: Colors.red) : null, - child: Text(t.common.confirm), + child: TextButton( + onPressed: () => Navigator.pop(context, true), + style: selectedItem.isDestructive ? TextButton.styleFrom(foregroundColor: Colors.red) : null, + child: Text(t.common.confirm), + ), ), ], ), diff --git a/lib/screens/livetv/dvr_recordings_screen.dart b/lib/screens/livetv/dvr_recordings_screen.dart index 00c6e9a7..c3b53ac4 100644 --- a/lib/screens/livetv/dvr_recordings_screen.dart +++ b/lib/screens/livetv/dvr_recordings_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; +import '../../focus/focusable_button.dart'; import '../../focus/focusable_wrapper.dart'; import '../../focus/key_event_utils.dart'; import '../../i18n/strings.g.dart'; @@ -103,8 +104,18 @@ class _DvrRecordingsScreenState extends State with SingleTi title: Text(t.liveTv.deleteSubscription), content: Text(t.liveTv.deleteSubscriptionConfirm), actions: [ - TextButton(onPressed: () => Navigator.of(context).pop(false), child: Text(t.common.cancel)), - FilledButton(onPressed: () => Navigator.of(context).pop(true), child: Text(t.common.delete)), + FocusableButton( + autofocus: true, + onPressed: () => Navigator.of(context).pop(false), + child: TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text(t.common.cancel), + ), + ), + FocusableButton( + onPressed: () => Navigator.of(context).pop(true), + child: FilledButton(onPressed: () => Navigator.of(context).pop(true), child: Text(t.common.delete)), + ), ], ), ); @@ -320,6 +331,7 @@ class _SubscriptionEditDialog extends StatefulWidget { class _SubscriptionEditDialogState extends State<_SubscriptionEditDialog> { late Map _prefs; final Map _textControllers = {}; + final _saveFocusNode = FocusNode(); @override void initState() { @@ -332,6 +344,7 @@ class _SubscriptionEditDialogState extends State<_SubscriptionEditDialog> { for (final controller in _textControllers.values) { controller.dispose(); } + _saveFocusNode.dispose(); super.dispose(); } @@ -351,8 +364,18 @@ class _SubscriptionEditDialogState extends State<_SubscriptionEditDialog> { ), ), actions: [ - TextButton(onPressed: () => Navigator.of(context).pop(null), child: Text(t.common.cancel)), - FilledButton(onPressed: () => Navigator.of(context).pop(_prefs), child: Text(t.common.save)), + FocusableButton( + onPressed: () => Navigator.of(context).pop(null), + child: TextButton(onPressed: () => Navigator.of(context).pop(null), child: Text(t.common.cancel)), + ), + FocusableButton( + focusNode: _saveFocusNode, + onPressed: () => Navigator.of(context).pop(_prefs), + child: FilledButton( + onPressed: () => Navigator.of(context).pop(_prefs), + child: Text(t.common.save), + ), + ), ], ); } @@ -399,9 +422,11 @@ class _SubscriptionEditDialogState extends State<_SubscriptionEditDialog> { title: Text(setting.label ?? setting.id), subtitle: TextField( controller: controller, + textInputAction: TextInputAction.done, onChanged: (newValue) { _prefs[setting.id] = newValue; }, + onSubmitted: (_) => _saveFocusNode.requestFocus(), ), ); } diff --git a/lib/screens/livetv/program_details_sheet.dart b/lib/screens/livetv/program_details_sheet.dart index adea1db6..fe3d2ce5 100644 --- a/lib/screens/livetv/program_details_sheet.dart +++ b/lib/screens/livetv/program_details_sheet.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; -import '../../focus/focusable_wrapper.dart'; +import '../../focus/focusable_button.dart'; import '../../i18n/strings.g.dart'; import '../../models/livetv_channel.dart'; import '../../models/livetv_program.dart'; @@ -119,18 +119,15 @@ class _ProgramDetailsSheetContentState extends State<_ProgramDetailsSheetContent if (program.isCurrentlyAiring && widget.onTuneChannel != null) { final idx = buttonIndex; buttons.add( - FocusableWrapper( + FocusableButton( focusNode: _buttonFocusNodes[idx], - onSelect: () { + onPressed: () { closeSheet(); widget.onTuneChannel!(); }, onNavigateLeft: idx > 0 ? () => _focusButton(idx - 1) : null, onNavigateRight: idx < _buttonFocusNodes.length - 1 ? () => _focusButton(idx + 1) : null, onBack: closeSheet, - borderRadius: 100, - useBackgroundFocus: true, - disableScale: true, child: FilledButton.icon( style: FilledButton.styleFrom(tapTargetSize: MaterialTapTargetSize.shrinkWrap), onPressed: () { @@ -181,18 +178,15 @@ class _ProgramDetailsSheetContentState extends State<_ProgramDetailsSheetContent buttons.add(const SizedBox(width: 8)); final idx = buttonIndex; buttons.add( - FocusableWrapper( + FocusableButton( focusNode: _buttonFocusNodes[idx], - onSelect: () { + onPressed: () { closeSheet(); widget.onTuneChannel!(); }, onNavigateLeft: idx > 0 ? () => _focusButton(idx - 1) : null, onNavigateRight: idx < _buttonFocusNodes.length - 1 ? () => _focusButton(idx + 1) : null, onBack: closeSheet, - borderRadius: 100, - useBackgroundFocus: true, - disableScale: true, child: OutlinedButton.icon( style: OutlinedButton.styleFrom(tapTargetSize: MaterialTapTargetSize.shrinkWrap), onPressed: () { diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index e069a12a..8917fd79 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -10,6 +10,7 @@ import '../../services/plex_client.dart'; import '../i18n/strings.g.dart'; import '../services/update_service.dart'; import '../utils/app_logger.dart'; +import '../focus/focusable_button.dart'; import '../utils/dialogs.dart'; import '../utils/provider_extensions.dart'; import '../utils/platform_detector.dart'; @@ -226,27 +227,36 @@ class _MainScreenState extends State with RouteAware, WindowListener ], ), actions: [ - TextButton( + FocusableButton( autofocus: true, onPressed: () => Navigator.pop(dialogContext), - style: TextButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14), - shape: const StadiumBorder(), + child: TextButton( + onPressed: () => Navigator.pop(dialogContext), + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14), + shape: const StadiumBorder(), + ), + child: Text(t.common.later), ), - child: Text(t.common.later), ), - TextButton( + FocusableButton( onPressed: () async { await UpdateService.skipVersion(updateInfo['latestVersion']); if (dialogContext.mounted) Navigator.pop(dialogContext); }, - style: TextButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14), - shape: const StadiumBorder(), + child: TextButton( + onPressed: () async { + await UpdateService.skipVersion(updateInfo['latestVersion']); + if (dialogContext.mounted) Navigator.pop(dialogContext); + }, + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14), + shape: const StadiumBorder(), + ), + child: Text(t.update.skipVersion), ), - child: Text(t.update.skipVersion), ), - FilledButton( + FocusableButton( onPressed: () async { final url = Uri.parse(updateInfo['releaseUrl']); if (await canLaunchUrl(url)) { @@ -254,7 +264,16 @@ class _MainScreenState extends State with RouteAware, WindowListener } if (dialogContext.mounted) Navigator.pop(dialogContext); }, - child: Text(t.update.viewRelease), + child: FilledButton( + onPressed: () async { + final url = Uri.parse(updateInfo['releaseUrl']); + if (await canLaunchUrl(url)) { + await launchUrl(url, mode: LaunchMode.externalApplication); + } + if (dialogContext.mounted) Navigator.pop(dialogContext); + }, + child: Text(t.update.viewRelease), + ), ), ], ); diff --git a/lib/screens/profile/pin_entry_dialog.dart b/lib/screens/profile/pin_entry_dialog.dart index 2dddaf6a..a8056592 100644 --- a/lib/screens/profile/pin_entry_dialog.dart +++ b/lib/screens/profile/pin_entry_dialog.dart @@ -8,6 +8,7 @@ import '../../focus/dpad_navigator.dart'; import '../../focus/focus_theme.dart'; import '../../focus/input_mode_tracker.dart'; import '../../focus/key_event_utils.dart'; +import '../../focus/focusable_button.dart'; import '../../i18n/strings.g.dart'; import '../../utils/platform_detector.dart'; import '../../widgets/app_icon.dart'; @@ -100,9 +101,18 @@ class _PinEntryDialogState extends State with SingleTickerProvid ], ), actions: [ - TextButton(onPressed: _cancel, child: Text(t.common.cancel)), + FocusableButton( + onPressed: _cancel, + child: TextButton(onPressed: _cancel, child: Text(t.common.cancel)), + ), if (!isMobile) - FilledButton(onPressed: () => _pinInputKey.currentState?._trySubmit(), child: Text(t.common.submit)), + FocusableButton( + onPressed: () => _pinInputKey.currentState?._trySubmit(), + child: FilledButton( + onPressed: () => _pinInputKey.currentState?._trySubmit(), + child: Text(t.common.submit), + ), + ), ], ), ); diff --git a/lib/screens/settings/external_player_screen.dart b/lib/screens/settings/external_player_screen.dart index a52f3f37..69b99f78 100644 --- a/lib/screens/settings/external_player_screen.dart +++ b/lib/screens/settings/external_player_screen.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; +import '../../focus/focusable_button.dart'; import '../../i18n/strings.g.dart'; import '../../models/external_player_models.dart'; import '../../services/settings_service.dart'; @@ -183,6 +184,8 @@ class _ExternalPlayerScreenState extends State { Future _showAddCustomPlayerDialog() async { final nameController = TextEditingController(); final valueController = TextEditingController(); + final valueFocusNode = FocusNode(); + final saveFocusNode = FocusNode(); var selectedType = CustomPlayerType.command; final result = await showDialog( @@ -215,6 +218,7 @@ class _ExternalPlayerScreenState extends State { decoration: InputDecoration(labelText: t.externalPlayer.playerName, hintText: 'My Player'), autofocus: true, textInputAction: TextInputAction.next, + onSubmitted: (_) => primaryFocus?.nextFocus(), ), const SizedBox(height: 16), SizedBox( @@ -238,21 +242,34 @@ class _ExternalPlayerScreenState extends State { const SizedBox(height: 16), TextField( controller: valueController, + focusNode: valueFocusNode, decoration: InputDecoration(labelText: fieldLabel, hintText: fieldHint), textInputAction: TextInputAction.done, + onSubmitted: (_) => saveFocusNode.requestFocus(), ), ], ), ), actions: [ - TextButton(onPressed: () => Navigator.pop(context), child: Text(t.common.cancel)), - FilledButton( + FocusableButton( + onPressed: () => Navigator.pop(context), + child: TextButton(onPressed: () => Navigator.pop(context), child: Text(t.common.cancel)), + ), + FocusableButton( + focusNode: saveFocusNode, onPressed: () { if (nameController.text.isNotEmpty && valueController.text.isNotEmpty) { Navigator.pop(context, true); } }, - child: Text(t.common.save), + child: FilledButton( + onPressed: () { + if (nameController.text.isNotEmpty && valueController.text.isNotEmpty) { + Navigator.pop(context, true); + } + }, + child: Text(t.common.save), + ), ), ], ); @@ -260,6 +277,9 @@ class _ExternalPlayerScreenState extends State { ), ); + valueFocusNode.dispose(); + saveFocusNode.dispose(); + if (result != true) return; final id = 'custom_${DateTime.now().millisecondsSinceEpoch}'; diff --git a/lib/screens/settings/logs_screen.dart b/lib/screens/settings/logs_screen.dart index 37e95ac8..f1319b8e 100644 --- a/lib/screens/settings/logs_screen.dart +++ b/lib/screens/settings/logs_screen.dart @@ -6,6 +6,7 @@ import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:flutter/services.dart'; import 'package:logger/logger.dart'; +import '../../focus/focusable_button.dart'; import '../../i18n/strings.g.dart'; import '../../utils/app_logger.dart'; import '../../utils/snackbar_helper.dart'; @@ -120,7 +121,16 @@ class _LogsScreenState extends State { ), ], ), - actions: [TextButton(onPressed: () => Navigator.of(ctx).pop(), child: Text(t.common.close))], + actions: [ + FocusableButton( + autofocus: true, + onPressed: () => Navigator.of(ctx).pop(), + child: TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: Text(t.common.close), + ), + ), + ], ), ); } catch (_) { diff --git a/lib/screens/settings/mpv_config_screen.dart b/lib/screens/settings/mpv_config_screen.dart index 489028df..486ee2ba 100644 --- a/lib/screens/settings/mpv_config_screen.dart +++ b/lib/screens/settings/mpv_config_screen.dart @@ -3,6 +3,7 @@ import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../i18n/strings.g.dart'; import '../../models/mpv_config_models.dart'; +import '../../focus/focusable_button.dart'; import '../../utils/dialogs.dart'; import '../../utils/snackbar_helper.dart'; import '../../services/settings_service.dart'; @@ -111,15 +112,25 @@ class _MpvConfigScreenState extends State { ], ), actions: [ - TextButton(onPressed: () => Navigator.pop(context, false), child: Text(t.common.cancel)), - TextButton( + FocusableButton( + onPressed: () => Navigator.pop(context, false), + child: TextButton(onPressed: () => Navigator.pop(context, false), child: Text(t.common.cancel)), + ), + FocusableButton( focusNode: saveFocusNode, onPressed: () { if (keyController.text.isNotEmpty && valueController.text.isNotEmpty) { Navigator.pop(context, true); } }, - child: Text(t.common.save), + child: TextButton( + onPressed: () { + if (keyController.text.isNotEmpty && valueController.text.isNotEmpty) { + Navigator.pop(context, true); + } + }, + child: Text(t.common.save), + ), ), ], ), @@ -164,6 +175,7 @@ class _MpvConfigScreenState extends State { if (_entries.isEmpty) return; final nameController = TextEditingController(); + final saveFocusNode = FocusNode(); final result = await showDialog( context: context, @@ -173,21 +185,36 @@ class _MpvConfigScreenState extends State { controller: nameController, decoration: InputDecoration(labelText: t.mpvConfig.presetName, hintText: t.mpvConfig.presetNameHint), autofocus: true, + textInputAction: TextInputAction.done, + onSubmitted: (_) => saveFocusNode.requestFocus(), ), actions: [ - TextButton(onPressed: () => Navigator.pop(context, false), child: Text(t.common.cancel)), - TextButton( + FocusableButton( + onPressed: () => Navigator.pop(context, false), + child: TextButton(onPressed: () => Navigator.pop(context, false), child: Text(t.common.cancel)), + ), + FocusableButton( + focusNode: saveFocusNode, onPressed: () { if (nameController.text.isNotEmpty) { Navigator.pop(context, true); } }, - child: Text(t.common.save), + child: FilledButton( + onPressed: () { + if (nameController.text.isNotEmpty) { + Navigator.pop(context, true); + } + }, + child: Text(t.common.save), + ), ), ], ), ); + saveFocusNode.dispose(); + if (result == true) { await _settingsService.saveMpvPreset(nameController.text.trim(), _entries); if (!mounted) return; diff --git a/lib/screens/settings/settings_screen.dart b/lib/screens/settings/settings_screen.dart index 551bf7ee..92805d5c 100644 --- a/lib/screens/settings/settings_screen.dart +++ b/lib/screens/settings/settings_screen.dart @@ -9,6 +9,7 @@ import '../../models/hotkey_model.dart'; import 'package:provider/provider.dart'; import 'package:url_launcher/url_launcher.dart'; + import '../../focus/focus_memory_tracker.dart'; import '../../focus/input_mode_tracker.dart'; import '../../i18n/strings.g.dart'; @@ -1408,7 +1409,11 @@ class _SettingsScreenState extends State with FocusableTab { title: Text(t.settings.clearCache), content: Text(t.settings.clearCacheDescription), actions: [ - TextButton(onPressed: () => Navigator.pop(context), child: Text(t.common.cancel)), + TextButton( + autofocus: true, + onPressed: () => Navigator.pop(context), + child: Text(t.common.cancel), + ), TextButton( onPressed: () async { final navigator = Navigator.of(context); @@ -1434,7 +1439,11 @@ class _SettingsScreenState extends State with FocusableTab { title: Text(t.settings.resetSettings), content: Text(t.settings.resetSettingsDescription), actions: [ - TextButton(onPressed: () => Navigator.pop(context), child: Text(t.common.cancel)), + TextButton( + autofocus: true, + onPressed: () => Navigator.pop(context), + child: Text(t.common.cancel), + ), TextButton( onPressed: () async { final navigator = Navigator.of(context); @@ -1443,7 +1452,6 @@ class _SettingsScreenState extends State with FocusableTab { if (mounted) { navigator.pop(); showSuccessSnackBar(this.context, t.settings.resetSettingsSuccess); - // Reload settings _loadSettings(); } }, @@ -1581,7 +1589,11 @@ class _SettingsScreenState extends State with FocusableTab { ], ), actions: [ - TextButton(onPressed: () => Navigator.pop(context), child: Text(t.common.close)), + TextButton( + autofocus: true, + onPressed: () => Navigator.pop(context), + child: Text(t.common.close), + ), FilledButton( onPressed: () async { final url = Uri.parse(_updateInfo!['releaseUrl']); diff --git a/lib/screens/settings/subtitle_styling_screen.dart b/lib/screens/settings/subtitle_styling_screen.dart index 127c98ca..6340f895 100644 --- a/lib/screens/settings/subtitle_styling_screen.dart +++ b/lib/screens/settings/subtitle_styling_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:flex_color_picker/flex_color_picker.dart'; +import '../../focus/focusable_button.dart'; import '../../i18n/strings.g.dart'; import '../../services/settings_service.dart'; import '../../utils/platform_detector.dart'; @@ -198,14 +199,23 @@ class _SubtitleStylingScreenState extends State { ], ), actions: [ - TextButton(onPressed: () => Navigator.pop(dialogContext), child: Text(t.common.cancel)), - TextButton( + FocusableButton( + onPressed: () => Navigator.pop(dialogContext), + child: TextButton(onPressed: () => Navigator.pop(dialogContext), child: Text(t.common.cancel)), + ), + FocusableButton( focusNode: saveFocusNode, onPressed: () { onSave(spinnerValue); Navigator.pop(dialogContext); }, - child: Text(t.common.save), + child: TextButton( + onPressed: () { + onSave(spinnerValue); + Navigator.pop(dialogContext); + }, + child: Text(t.common.save), + ), ), ], ); @@ -263,14 +273,23 @@ class _SubtitleStylingScreenState extends State { onConfirm: () => saveFocusNode.requestFocus(), ), actions: [ - TextButton(onPressed: () => Navigator.pop(dialogContext), child: Text(t.common.cancel)), - TextButton( + FocusableButton( + onPressed: () => Navigator.pop(dialogContext), + child: TextButton(onPressed: () => Navigator.pop(dialogContext), child: Text(t.common.cancel)), + ), + FocusableButton( focusNode: saveFocusNode, onPressed: () { onColorSelected(_colorToHex(pickerColor)); Navigator.pop(dialogContext); }, - child: Text(t.common.save), + child: TextButton( + onPressed: () { + onColorSelected(_colorToHex(pickerColor)); + Navigator.pop(dialogContext); + }, + child: Text(t.common.save), + ), ), ], ); diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index f5631af5..dd2bad11 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -57,7 +57,7 @@ import '../utils/plex_url_helper.dart'; import '../utils/video_player_navigation.dart'; import '../widgets/overlay_sheet.dart'; import '../widgets/video_controls/video_controls.dart'; -import '../focus/focusable_wrapper.dart'; +import '../focus/focusable_button.dart'; import '../focus/input_mode_tracker.dart'; import '../focus/dpad_navigator.dart'; import '../focus/key_event_utils.dart'; @@ -2500,27 +2500,13 @@ class VideoPlayerScreenState extends State with WidgetsBindin Row( children: [ Expanded( - child: FocusableWrapper( + child: FocusableButton( focusNode: _playNextCancelFocusNode, - onSelect: _cancelAutoPlay, - useBackgroundFocus: true, + onPressed: _cancelAutoPlay, autoScroll: false, - borderRadius: 20, - onKeyEvent: (node, event) { - if (event is KeyDownEvent) { - // RIGHT arrow moves focus to Play Next button - if (event.logicalKey == LogicalKeyboardKey.arrowRight) { - _playNextConfirmFocusNode.requestFocus(); - return KeyEventResult.handled; - } - // Trap focus - consume UP/DOWN to prevent escape - if (event.logicalKey == LogicalKeyboardKey.arrowUp || - event.logicalKey == LogicalKeyboardKey.arrowDown) { - return KeyEventResult.handled; - } - } - return KeyEventResult.ignored; - }, + onNavigateRight: () => _playNextConfirmFocusNode.requestFocus(), + onNavigateUp: () {}, // Trap focus + onNavigateDown: () {}, // Trap focus child: OutlinedButton( onPressed: _cancelAutoPlay, style: OutlinedButton.styleFrom( @@ -2534,27 +2520,13 @@ class VideoPlayerScreenState extends State with WidgetsBindin ), const SizedBox(width: 8), Expanded( - child: FocusableWrapper( + child: FocusableButton( focusNode: _playNextConfirmFocusNode, - onSelect: _playNext, - useBackgroundFocus: true, + onPressed: _playNext, autoScroll: false, - borderRadius: 20, - onKeyEvent: (node, event) { - if (event is KeyDownEvent) { - // LEFT arrow moves focus to Cancel button - if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { - _playNextCancelFocusNode.requestFocus(); - return KeyEventResult.handled; - } - // Trap focus - consume UP/DOWN to prevent escape - if (event.logicalKey == LogicalKeyboardKey.arrowUp || - event.logicalKey == LogicalKeyboardKey.arrowDown) { - return KeyEventResult.handled; - } - } - return KeyEventResult.ignored; - }, + onNavigateLeft: () => _playNextCancelFocusNode.requestFocus(), + onNavigateUp: () {}, // Trap focus + onNavigateDown: () {}, // Trap focus child: FilledButton( onPressed: _playNext, style: FilledButton.styleFrom( diff --git a/lib/utils/dialogs.dart b/lib/utils/dialogs.dart index 8664705b..d3a1c27d 100644 --- a/lib/utils/dialogs.dart +++ b/lib/utils/dialogs.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import '../focus/focusable_button.dart'; import '../i18n/strings.g.dart'; /// Utility functions for showing common dialogs @@ -24,18 +25,24 @@ Future showConfirmDialog( title: Text(title), content: Text(message), actions: [ - TextButton( + FocusableButton( autofocus: true, onPressed: () => Navigator.pop(dialogContext, false), - style: TextButton.styleFrom(padding: _buttonPadding, shape: _buttonShape), - child: Text(cancelText ?? t.common.cancel), + child: TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + style: TextButton.styleFrom(padding: _buttonPadding, shape: _buttonShape), + child: Text(cancelText ?? t.common.cancel), + ), ), - FilledButton( + FocusableButton( onPressed: () => Navigator.pop(dialogContext, true), - style: isDestructive - ? FilledButton.styleFrom(backgroundColor: colorScheme.error, foregroundColor: colorScheme.onError) - : null, - child: Text(confirmText), + child: FilledButton( + onPressed: () => Navigator.pop(dialogContext, true), + style: isDestructive + ? FilledButton.styleFrom(backgroundColor: colorScheme.error, foregroundColor: colorScheme.onError) + : null, + child: Text(confirmText), + ), ), ], ); @@ -80,15 +87,21 @@ Future<({bool confirmed, bool checked})> showConfirmDialogWithCheckbox( ], ), actions: [ - TextButton( + FocusableButton( autofocus: true, onPressed: () => Navigator.pop(dialogContext, false), - style: TextButton.styleFrom(padding: _buttonPadding, shape: _buttonShape), - child: Text(cancelText ?? t.common.cancel), + child: TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + style: TextButton.styleFrom(padding: _buttonPadding, shape: _buttonShape), + child: Text(cancelText ?? t.common.cancel), + ), ), - FilledButton( + FocusableButton( onPressed: () => Navigator.pop(dialogContext, true), - child: Text(confirmText), + child: FilledButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: Text(confirmText), + ), ), ], ); @@ -136,6 +149,7 @@ class _TextInputDialog extends StatefulWidget { class _TextInputDialogState extends State<_TextInputDialog> { late final TextEditingController _controller; + final _saveFocusNode = FocusNode(); @override void initState() { @@ -146,6 +160,7 @@ class _TextInputDialogState extends State<_TextInputDialog> { @override void dispose() { _controller.dispose(); + _saveFocusNode.dispose(); super.dispose(); } @@ -163,11 +178,19 @@ class _TextInputDialogState extends State<_TextInputDialog> { controller: _controller, autofocus: true, decoration: InputDecoration(labelText: widget.labelText, hintText: widget.hintText), - onSubmitted: (_) => _submit(), + textInputAction: TextInputAction.done, + onSubmitted: (_) => _saveFocusNode.requestFocus(), ), actions: [ - TextButton(onPressed: () => Navigator.pop(context), child: Text(t.common.cancel)), - TextButton(onPressed: _submit, child: Text(t.common.save)), + FocusableButton( + onPressed: () => Navigator.pop(context), + child: TextButton(onPressed: () => Navigator.pop(context), child: Text(t.common.cancel)), + ), + FocusableButton( + focusNode: _saveFocusNode, + onPressed: _submit, + child: TextButton(onPressed: _submit, child: Text(t.common.save)), + ), ], ); } diff --git a/lib/watch_together/screens/watch_together_screen.dart b/lib/watch_together/screens/watch_together_screen.dart index 31ea81fb..dd6d2295 100644 --- a/lib/watch_together/screens/watch_together_screen.dart +++ b/lib/watch_together/screens/watch_together_screen.dart @@ -6,6 +6,7 @@ import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; import '../../i18n/strings.g.dart'; +import '../../focus/focusable_button.dart'; import '../../focus/focusable_wrapper.dart'; import '../../utils/app_logger.dart'; import '../../utils/dialogs.dart'; @@ -145,12 +146,9 @@ class _NotInSessionViewState extends State<_NotInSessionView> { const SizedBox(height: 48), SizedBox( width: double.infinity, - child: FocusableWrapper( + child: FocusableButton( autofocus: true, - useBackgroundFocus: true, - disableScale: true, - borderRadius: 100, - onSelect: _isCreating || _isJoining ? null : _createSession, + onPressed: _isCreating || _isJoining ? null : _createSession, child: FilledButton.icon( onPressed: _isCreating || _isJoining ? null : _createSession, icon: _isCreating @@ -163,11 +161,8 @@ class _NotInSessionViewState extends State<_NotInSessionView> { const SizedBox(height: 16), SizedBox( width: double.infinity, - child: FocusableWrapper( - useBackgroundFocus: true, - disableScale: true, - borderRadius: 100, - onSelect: _isCreating || _isJoining ? null : _joinSession, + child: FocusableButton( + onPressed: _isCreating || _isJoining ? null : _joinSession, child: OutlinedButton.icon( onPressed: _isCreating || _isJoining ? null : _joinSession, icon: _isJoining @@ -213,34 +208,25 @@ class _NotInSessionViewState extends State<_NotInSessionView> { title: Text(t.watchTogether.controlMode), content: Text(t.watchTogether.controlModeQuestion), actions: [ - FocusableWrapper( + FocusableButton( autofocus: true, - useBackgroundFocus: true, - disableScale: true, - borderRadius: 100, - onSelect: () => Navigator.pop(context), + onPressed: () => Navigator.pop(context), child: TextButton( onPressed: () => Navigator.pop(context), style: TextButton.styleFrom(padding: buttonPadding, shape: buttonShape), child: Text(t.common.cancel), ), ), - FocusableWrapper( - useBackgroundFocus: true, - disableScale: true, - borderRadius: 100, - onSelect: () => Navigator.pop(context, ControlMode.hostOnly), + FocusableButton( + onPressed: () => Navigator.pop(context, ControlMode.hostOnly), child: TextButton( onPressed: () => Navigator.pop(context, ControlMode.hostOnly), style: TextButton.styleFrom(padding: buttonPadding, shape: buttonShape), child: Text(t.watchTogether.hostOnly), ), ), - FocusableWrapper( - useBackgroundFocus: true, - disableScale: true, - borderRadius: 100, - onSelect: () => Navigator.pop(context, ControlMode.anyone), + FocusableButton( + onPressed: () => Navigator.pop(context, ControlMode.anyone), child: FilledButton( onPressed: () => Navigator.pop(context, ControlMode.anyone), child: Text(t.watchTogether.anyone), @@ -408,12 +394,9 @@ class _ActiveSessionContent extends StatelessWidget { // Leave/End Session Button SizedBox( width: double.infinity, - child: FocusableWrapper( + child: FocusableButton( autofocus: true, - useBackgroundFocus: true, - disableScale: true, - borderRadius: 100, - onSelect: () => _leaveSession(context), + onPressed: () => _leaveSession(context), child: OutlinedButton.icon( onPressed: () => _leaveSession(context), style: OutlinedButton.styleFrom( diff --git a/lib/watch_together/widgets/join_session_dialog.dart b/lib/watch_together/widgets/join_session_dialog.dart index 900cd59e..5f98269e 100644 --- a/lib/watch_together/widgets/join_session_dialog.dart +++ b/lib/watch_together/widgets/join_session_dialog.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:material_symbols_icons/symbols.dart'; +import '../../focus/focusable_button.dart'; import '../../focus/focusable_wrapper.dart'; import '../../i18n/strings.g.dart'; @@ -100,11 +101,8 @@ class _JoinSessionDialogState extends State { const SizedBox(height: 24), // Join button - FocusableWrapper( - useBackgroundFocus: true, - disableScale: true, - borderRadius: 100, - onSelect: _join, + FocusableButton( + onPressed: _join, child: FilledButton.icon( onPressed: _join, icon: const Icon(Symbols.group_add), diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index 0f33ef08..4c19e6bc 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -17,6 +17,7 @@ import '../utils/snackbar_helper.dart'; import '../utils/dialogs.dart'; import '../utils/focus_utils.dart'; import '../services/external_player_service.dart'; +import '../focus/focusable_button.dart'; import '../focus/dpad_navigator.dart'; import '../screens/media_detail_screen.dart'; import '../screens/season_detail_screen.dart'; @@ -1252,7 +1253,16 @@ class _PlaylistSelectionDialog extends StatelessWidget { }, ), ), - actions: [TextButton(onPressed: () => Navigator.pop(context), child: Text(t.common.cancel))], + actions: [ + FocusableButton( + autofocus: true, + onPressed: () => Navigator.pop(context), + child: TextButton( + onPressed: () => Navigator.pop(context), + child: Text(t.common.cancel), + ), + ), + ], ); } } @@ -1292,7 +1302,16 @@ class _CollectionSelectionDialog extends StatelessWidget { }, ), ), - actions: [TextButton(onPressed: () => Navigator.pop(context), child: Text(t.common.cancel))], + actions: [ + FocusableButton( + autofocus: true, + onPressed: () => Navigator.pop(context), + child: TextButton( + onPressed: () => Navigator.pop(context), + child: Text(t.common.cancel), + ), + ), + ], ); } }