From cc30bee83aa6beb2f912037845ad02ef585466a4 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 27 Feb 2026 11:55:19 +0100 Subject: [PATCH] refactor: extract FocusableActionBar widget for reusable app bar actions Replace duplicated focusable action button pattern across 8 screens with a shared FocusableActionBar widget that handles focus nodes, D-pad navigation, back key, and focus decoration automatically. --- lib/focus/focusable_action_bar.dart | 184 +++++++++ lib/screens/collection_detail_screen.dart | 29 +- lib/screens/discover_screen.dart | 372 ++++++------------ .../focusable_detail_screen_mixin.dart | 137 +------ lib/screens/hub_detail_screen.dart | 55 +-- lib/screens/libraries/libraries_screen.dart | 98 +---- lib/screens/livetv/live_tv_screen.dart | 61 +-- .../playlist/playlist_detail_screen.dart | 37 +- lib/screens/settings/logs_screen.dart | 115 ++---- 9 files changed, 407 insertions(+), 681 deletions(-) create mode 100644 lib/focus/focusable_action_bar.dart diff --git a/lib/focus/focusable_action_bar.dart b/lib/focus/focusable_action_bar.dart new file mode 100644 index 00000000..89f9c551 --- /dev/null +++ b/lib/focus/focusable_action_bar.dart @@ -0,0 +1,184 @@ +import 'package:flutter/material.dart'; + +import '../widgets/app_icon.dart'; +import 'focus_theme.dart'; +import 'input_mode_tracker.dart'; +import 'key_event_utils.dart'; + +/// Describes a single action button for use in [FocusableActionBar]. +class FocusableAction { + /// Icon to display. Ignored when [child] is provided. + final IconData icon; + + /// Icon color. Ignored when [child] is provided. + final Color? iconColor; + + final String? tooltip; + final VoidCallback? onPressed; + + /// Optional custom child widget placed inside the focus container. + /// Overrides the default [IconButton] built from [icon]/[tooltip]/[onPressed]. + final Widget? child; + + const FocusableAction({ + this.icon = Icons.circle, + this.iconColor, + this.tooltip, + this.onPressed, + this.child, + }); +} + +/// A row of focusable action buttons for app bar [actions:]. +/// +/// Manages focus nodes, left/right D-pad navigation between buttons, +/// and the standard white-alpha background focus indicator internally. +/// +/// Returns a single [Row] widget — place it inside the `actions:` list: +/// ```dart +/// CustomAppBar( +/// title: Text('Title'), +/// actions: [ +/// FocusableActionBar( +/// actions: [ +/// FocusableAction(icon: Symbols.refresh_rounded, onPressed: _refresh), +/// FocusableAction(icon: Symbols.upload_rounded, onPressed: _upload), +/// ], +/// ), +/// ], +/// ) +/// ``` +class FocusableActionBar extends StatefulWidget { + final List actions; + + /// Called when the user presses down from any action button. + final VoidCallback? onNavigateDown; + + /// Called when the user presses up from any action button. + final VoidCallback? onNavigateUp; + + /// Called when the user presses left from the leftmost button. + final VoidCallback? onNavigateLeft; + + /// Called when the user presses right from the rightmost button. + final VoidCallback? onNavigateRight; + + /// Called when the user presses the back key while an action is focused. + final VoidCallback? onBack; + + const FocusableActionBar({ + super.key, + required this.actions, + this.onNavigateDown, + this.onNavigateUp, + this.onNavigateLeft, + this.onNavigateRight, + this.onBack, + }); + + @override + State createState() => FocusableActionBarState(); +} + +class FocusableActionBarState extends State { + late List _focusNodes; + late List _focusStates; + + /// Access a focus node by index (e.g. for external `requestFocus()` calls). + FocusNode getFocusNode(int index) => _focusNodes[index]; + + @override + void initState() { + super.initState(); + _initNodes(); + } + + @override + void didUpdateWidget(FocusableActionBar oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.actions.length != widget.actions.length) { + _disposeNodes(); + _initNodes(); + } + } + + void _initNodes() { + _focusNodes = List.generate(widget.actions.length, (i) => FocusNode(debugLabel: 'ActionBar[$i]')); + _focusStates = List.filled(widget.actions.length, false); + for (var i = 0; i < _focusNodes.length; i++) { + final idx = i; + _focusNodes[i].addListener(() { + final hasFocus = _focusNodes[idx].hasFocus; + if (_focusStates[idx] != hasFocus) { + setState(() => _focusStates[idx] = hasFocus); + } + }); + } + } + + void _disposeNodes() { + for (final node in _focusNodes) { + node.dispose(); + } + } + + @override + void dispose() { + _disposeNodes(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final isKeyboard = InputModeTracker.isKeyboardMode(context); + final duration = FocusTheme.getAnimationDuration(context); + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (var i = 0; i < widget.actions.length; i++) _buildButton(i, isKeyboard, duration), + ], + ); + } + + Widget _buildButton(int index, bool isKeyboard, Duration duration) { + final action = widget.actions[index]; + final isFocused = _focusStates[index]; + final showFocus = isFocused && isKeyboard; + final opacity = isKeyboard && !isFocused ? 0.6 : 1.0; + + return Focus( + focusNode: _focusNodes[index], + onKeyEvent: (node, event) { + if (widget.onBack != null) { + final backResult = handleBackKeyAction(event, widget.onBack!); + if (backResult != KeyEventResult.ignored) return backResult; + } + return dpadKeyHandler( + onSelect: action.onPressed, + onLeft: index > 0 + ? () => _focusNodes[index - 1].requestFocus() + : widget.onNavigateLeft, + onRight: index < _focusNodes.length - 1 + ? () => _focusNodes[index + 1].requestFocus() + : widget.onNavigateRight, + onDown: widget.onNavigateDown, + onUp: widget.onNavigateUp, + )(node, event); + }, + child: AnimatedOpacity( + opacity: showFocus ? 1.0 : opacity, + duration: duration, + child: Container( + decoration: FocusTheme.focusBackgroundDecoration(isFocused: showFocus, borderRadius: 20), + child: action.child ?? + IconButton( + icon: AppIcon(action.icon, fill: 1, color: action.iconColor), + tooltip: action.tooltip, + onPressed: action.onPressed, + ), + ), + ), + ); + } +} diff --git a/lib/screens/collection_detail_screen.dart b/lib/screens/collection_detail_screen.dart index b7968e5e..4f9e4efe 100644 --- a/lib/screens/collection_detail_screen.dart +++ b/lib/screens/collection_detail_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; +import '../focus/focusable_action_bar.dart'; import '../models/plex_metadata.dart'; import '../widgets/desktop_app_bar.dart'; import '../i18n/strings.g.dart'; @@ -38,9 +39,6 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen items.isNotEmpty; - @override - int get appBarButtonCount => items.isNotEmpty ? 3 : 1; // play, shuffle, delete (or just delete if empty) - @override void dispose() { disposeFocusResources(); @@ -69,23 +67,14 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen getAppBarButtons() { - final buttons = []; - if (items.isNotEmpty) { - buttons.add(AppBarButtonConfig(icon: Symbols.play_arrow_rounded, tooltip: t.common.play, onPressed: playItems)); - buttons.add( - AppBarButtonConfig(icon: Symbols.shuffle_rounded, tooltip: t.common.shuffle, onPressed: shufflePlayItems), - ); - } - buttons.add( - AppBarButtonConfig( - icon: Symbols.delete_rounded, - tooltip: t.common.delete, - onPressed: _deleteCollection, - color: Colors.red, - ), - ); - return buttons; + List getAppBarActions() { + return [ + if (items.isNotEmpty) ...[ + FocusableAction(icon: Symbols.play_arrow_rounded, tooltip: t.common.play, onPressed: playItems), + FocusableAction(icon: Symbols.shuffle_rounded, tooltip: t.common.shuffle, onPressed: shufflePlayItems), + ], + FocusableAction(icon: Symbols.delete_rounded, tooltip: t.common.delete, onPressed: _deleteCollection, iconColor: Colors.red), + ]; } Future _deleteCollection() async { diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index 629b1148..196be93b 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; +import '../focus/focusable_action_bar.dart'; import '../focus/key_event_utils.dart'; import '../utils/global_key_utils.dart'; import 'package:cached_network_image/cached_network_image.dart'; @@ -131,14 +132,7 @@ class _DiscoverScreenState extends State // Hero and app bar focus late FocusNode _heroFocusNode; - late FocusNode _refreshButtonFocusNode; - late FocusNode _watchTogetherButtonFocusNode; - late FocusNode _companionRemoteButtonFocusNode; - late FocusNode _userButtonFocusNode; - bool _isRefreshFocused = false; - bool _isWatchTogetherFocused = false; - bool _isCompanionRemoteFocused = false; - bool _isUserFocused = false; + final _actionBarKey = GlobalKey(); /// Get the correct PlexClient for an item's server PlexClient _getClientForItem(PlexMetadata? item) { @@ -188,7 +182,7 @@ class _DiscoverScreenState extends State if (_isHeroSectionVisible) { _heroFocusNode.requestFocus(); } else { - _refreshButtonFocusNode.requestFocus(); + _actionBarKey.currentState?.getFocusNode(0).requestFocus(); } _scrollToTop(); } @@ -245,14 +239,6 @@ class _DiscoverScreenState extends State super.initState(); WidgetsBinding.instance.addObserver(this); _heroFocusNode = FocusNode(debugLabel: 'hero_section'); - _refreshButtonFocusNode = FocusNode(debugLabel: 'refresh_button'); - _watchTogetherButtonFocusNode = FocusNode(debugLabel: 'watch_together_button'); - _companionRemoteButtonFocusNode = FocusNode(debugLabel: 'companion_remote_button'); - _userButtonFocusNode = FocusNode(debugLabel: 'user_button'); - _refreshButtonFocusNode.addListener(_onRefreshFocusChange); - _watchTogetherButtonFocusNode.addListener(_onWatchTogetherFocusChange); - _companionRemoteButtonFocusNode.addListener(_onCompanionRemoteFocusChange); - _userButtonFocusNode.addListener(_onUserFocusChange); _loadContent(); _startAutoScroll(); } @@ -272,37 +258,6 @@ class _DiscoverScreenState extends State _loadContent(); } - void _onRefreshFocusChange() { - if (mounted) { - setState(() { - _isRefreshFocused = _refreshButtonFocusNode.hasFocus; - }); - } - } - - void _onWatchTogetherFocusChange() { - if (mounted) { - setState(() { - _isWatchTogetherFocused = _watchTogetherButtonFocusNode.hasFocus; - }); - } - } - - void _onCompanionRemoteFocusChange() { - if (mounted) { - setState(() { - _isCompanionRemoteFocused = _companionRemoteButtonFocusNode.hasFocus; - }); - } - } - - void _onUserFocusChange() { - if (mounted) { - setState(() { - _isUserFocused = _userButtonFocusNode.hasFocus; - }); - } - } /// Handle key events for the hero section late final _handleHeroKeyEvent = dpadKeyHandler( @@ -310,7 +265,7 @@ class _DiscoverScreenState extends State final keys = _allHubKeys; if (keys.isNotEmpty) keys.first.currentState?.requestFocusFromMemory(); }, - onUp: () => _refreshButtonFocusNode.requestFocus(), + onUp: () => _actionBarKey.currentState?.getFocusNode(0).requestFocus(), onLeft: () { if (_currentHeroIndex > 0) { _heroController.previousPage(duration: tokens(context).slow, curve: Curves.easeInOut); @@ -330,44 +285,6 @@ class _DiscoverScreenState extends State }, ); - /// Handle key events for the refresh button in app bar - late final _handleRefreshKeyEvent = dpadKeyHandler( - onDown: _focusContentFromAppBar, - onRight: () => _watchTogetherButtonFocusNode.requestFocus(), - onLeft: _navigateToSidebar, - onUp: () {}, // Block at boundary - onSelect: _loadContent, - ); - - /// Handle key events for the watch together button in app bar - late final _handleWatchTogetherKeyEvent = dpadKeyHandler( - onDown: _focusContentFromAppBar, - onLeft: () => _refreshButtonFocusNode.requestFocus(), - onRight: () => _companionRemoteButtonFocusNode.requestFocus(), - onUp: () {}, // Block at boundary - onSelect: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const WatchTogetherScreen())), - ); - - /// Handle key events for the companion remote button in app bar - late final _handleCompanionRemoteKeyEvent = dpadKeyHandler( - onDown: () => _heroFocusNode.requestFocus(), - onLeft: () => _watchTogetherButtonFocusNode.requestFocus(), - onRight: () => _userButtonFocusNode.requestFocus(), - onUp: () {}, // Block at boundary - onSelect: () => RemoteSessionDialog.show(context), - ); - - /// Handle key events for the user button in app bar - late final _handleUserKeyEvent = dpadKeyHandler( - onDown: _focusContentFromAppBar, - onLeft: () => _companionRemoteButtonFocusNode.requestFocus(), - onRight: () {}, // Block at boundary - onUp: () {}, // Block at boundary - onSelect: () { - final userProvider = context.read(); - _showUserMenu(context, userProvider); - }, - ); @override void dispose() { @@ -379,14 +296,6 @@ class _DiscoverScreenState extends State _heroController.dispose(); _scrollController.dispose(); _heroFocusNode.dispose(); - _refreshButtonFocusNode.removeListener(_onRefreshFocusChange); - _refreshButtonFocusNode.dispose(); - _watchTogetherButtonFocusNode.removeListener(_onWatchTogetherFocusChange); - _watchTogetherButtonFocusNode.dispose(); - _companionRemoteButtonFocusNode.removeListener(_onCompanionRemoteFocusChange); - _companionRemoteButtonFocusNode.dispose(); - _userButtonFocusNode.removeListener(_onUserFocusChange); - _userButtonFocusNode.dispose(); super.dispose(); } @@ -849,7 +758,10 @@ class _DiscoverScreenState extends State /// Show user menu programmatically (for D-pad select) void _showUserMenu(BuildContext context, UserProfileProvider userProvider) { - final RenderBox? button = _userButtonFocusNode.context?.findRenderObject() as RenderBox?; + final actionBar = _actionBarKey.currentState; + if (actionBar == null) return; + final lastNode = actionBar.getFocusNode(actionBar.widget.actions.length - 1); + final RenderBox? button = lastNode.context?.findRenderObject() as RenderBox?; if (button == null) return; final RenderBox overlay = Navigator.of(context).overlay!.context.findRenderObject() as RenderBox; @@ -916,171 +828,141 @@ class _DiscoverScreenState extends State ).textTheme.titleLarge?.copyWith(color: Colors.white, fontWeight: FontWeight.bold), ), const Spacer(), - Focus( - focusNode: _refreshButtonFocusNode, - onKeyEvent: _handleRefreshKeyEvent, - child: Container( - decoration: BoxDecoration( - color: _isRefreshFocused ? Colors.white.withValues(alpha: 0.2) : Colors.transparent, - borderRadius: const BorderRadius.all(Radius.circular(20)), - ), - child: IconButton( - icon: const AppIcon(Symbols.refresh_rounded, fill: 1, color: Colors.white), - onPressed: _loadContent, - ), - ), - ), - // Watch Together button - Consumer( - builder: (context, watchTogether, child) { - return Focus( - focusNode: _watchTogetherButtonFocusNode, - onKeyEvent: _handleWatchTogetherKeyEvent, - child: Container( - decoration: BoxDecoration( - color: _isWatchTogetherFocused ? Colors.white.withValues(alpha: 0.2) : Colors.transparent, - borderRadius: const BorderRadius.all(Radius.circular(20)), + Consumer2( + builder: (context, watchTogether, companionRemote, _) { + final isDesktop = PlatformDetector.isDesktop(context); + final userProvider = context.watch(); + + return FocusableActionBar( + key: _actionBarKey, + onNavigateLeft: _navigateToSidebar, + onNavigateDown: _focusContentFromAppBar, + actions: [ + FocusableAction( + icon: Symbols.refresh_rounded, + iconColor: Colors.white, + onPressed: _loadContent, ), - child: Stack( - children: [ - IconButton( - icon: AppIcon( - Symbols.group_rounded, - fill: watchTogether.isInSession ? 1 : 0, - color: watchTogether.isInSession ? Theme.of(context).colorScheme.primary : Colors.white, + // Watch Together + FocusableAction( + onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const WatchTogetherScreen())), + child: Stack( + children: [ + IconButton( + icon: AppIcon( + Symbols.group_rounded, + fill: watchTogether.isInSession ? 1 : 0, + color: watchTogether.isInSession ? Theme.of(context).colorScheme.primary : Colors.white, + ), + onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const WatchTogetherScreen())), + tooltip: 'Watch Together', ), - onPressed: () => - Navigator.push(context, MaterialPageRoute(builder: (_) => const WatchTogetherScreen())), - tooltip: 'Watch Together', - ), - // Badge showing participant count when in session - if (watchTogether.isInSession && watchTogether.participantCount > 1) - Positioned( - top: 6, - right: 6, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primary, - borderRadius: const BorderRadius.all(Radius.circular(8)), - ), - child: Text( - '${watchTogether.participantCount}', - style: TextStyle( - color: Theme.of(context).colorScheme.onPrimary, - fontSize: 10, - fontWeight: FontWeight.bold, + if (watchTogether.isInSession && watchTogether.participantCount > 1) + Positioned( + top: 6, + right: 6, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + borderRadius: const BorderRadius.all(Radius.circular(8)), + ), + child: Text( + '${watchTogether.participantCount}', + style: TextStyle( + color: Theme.of(context).colorScheme.onPrimary, + fontSize: 10, + fontWeight: FontWeight.bold, + ), ), ), ), - ), - ], + ], + ), ), - ), - ); - }, - ), - // Companion Remote button - Consumer( - builder: (context, companionRemote, child) { - final isDesktop = PlatformDetector.isDesktop(context); - final hasDpadNav = isDesktop || PlatformDetector.isTV(); - - return Focus( - focusNode: hasDpadNav ? _companionRemoteButtonFocusNode : null, - onKeyEvent: hasDpadNav ? _handleCompanionRemoteKeyEvent : null, - child: Container( - decoration: BoxDecoration( - color: hasDpadNav && _isCompanionRemoteFocused - ? Colors.white.withValues(alpha: 0.2) - : Colors.transparent, - borderRadius: const BorderRadius.all(Radius.circular(20)), - ), - child: Stack( - children: [ - IconButton( - icon: AppIcon( - Symbols.phone_android_rounded, - fill: companionRemote.isConnected ? 1 : 0, - color: companionRemote.isConnected ? Theme.of(context).colorScheme.primary : Colors.white, - ), - onPressed: () { - if (isDesktop) { - RemoteSessionDialog.show(context); - } else { - Navigator.push(context, MaterialPageRoute(builder: (context) => MobileRemoteScreen())); - } - }, - tooltip: t.companionRemote.title, - ), - // Badge showing connection status - if (companionRemote.isConnected) - Positioned( - top: 6, - right: 6, - child: Container( - width: 8, - height: 8, - decoration: BoxDecoration( - color: Colors.green, - shape: BoxShape.circle, - border: const Border.fromBorderSide(BorderSide(color: Colors.white, width: 1)), - ), - ), - ), - ], - ), - ), - ); - }, - ), - Consumer( - builder: (context, userProvider, child) { - return Focus( - focusNode: _userButtonFocusNode, - onKeyEvent: _handleUserKeyEvent, - child: DecoratedBox( - decoration: BoxDecoration( - color: _isUserFocused ? Colors.white.withValues(alpha: 0.2) : Colors.transparent, - borderRadius: const BorderRadius.all(Radius.circular(20)), - ), - child: PopupMenuButton( - icon: userProvider.currentUser?.thumb != null - ? UserAvatarWidget(user: userProvider.currentUser!, size: 32, showIndicators: false) - : const AppIcon(Symbols.account_circle_rounded, fill: 1, size: 32, color: Colors.white), - onSelected: (value) { - if (value == 'switch_profile') { - _handleSwitchProfile(context); - } else if (value == 'logout') { - _handleLogout(); + // Companion Remote + FocusableAction( + onPressed: () { + if (isDesktop) { + RemoteSessionDialog.show(context); + } else { + Navigator.push(context, MaterialPageRoute(builder: (context) => MobileRemoteScreen())); } }, - itemBuilder: (context) => [ - // Only show Switch Profile if multiple users available - if (userProvider.hasMultipleUsers) + child: Stack( + children: [ + IconButton( + icon: AppIcon( + Symbols.phone_android_rounded, + fill: companionRemote.isConnected ? 1 : 0, + color: companionRemote.isConnected ? Theme.of(context).colorScheme.primary : Colors.white, + ), + onPressed: () { + if (isDesktop) { + RemoteSessionDialog.show(context); + } else { + Navigator.push(context, MaterialPageRoute(builder: (context) => MobileRemoteScreen())); + } + }, + tooltip: t.companionRemote.title, + ), + if (companionRemote.isConnected) + Positioned( + top: 6, + right: 6, + child: Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: Colors.green, + shape: BoxShape.circle, + border: const Border.fromBorderSide(BorderSide(color: Colors.white, width: 1)), + ), + ), + ), + ], + ), + ), + // User menu + FocusableAction( + onPressed: () => _showUserMenu(context, userProvider), + child: PopupMenuButton( + icon: userProvider.currentUser?.thumb != null + ? UserAvatarWidget(user: userProvider.currentUser!, size: 32, showIndicators: false) + : const AppIcon(Symbols.account_circle_rounded, fill: 1, size: 32, color: Colors.white), + onSelected: (value) { + if (value == 'switch_profile') { + _handleSwitchProfile(context); + } else if (value == 'logout') { + _handleLogout(); + } + }, + itemBuilder: (context) => [ + if (userProvider.hasMultipleUsers) + PopupMenuItem( + value: 'switch_profile', + child: Row( + children: [ + AppIcon(Symbols.people_rounded, fill: 1), + SizedBox(width: 8), + Text(t.discover.switchProfile), + ], + ), + ), PopupMenuItem( - value: 'switch_profile', + value: 'logout', child: Row( children: [ - AppIcon(Symbols.people_rounded, fill: 1), + AppIcon(Symbols.logout_rounded, fill: 1), SizedBox(width: 8), - Text(t.discover.switchProfile), + Text(t.common.logout), ], ), ), - PopupMenuItem( - value: 'logout', - child: Row( - children: [ - AppIcon(Symbols.logout_rounded, fill: 1), - SizedBox(width: 8), - Text(t.common.logout), - ], - ), - ), - ], + ], + ), ), - ), + ], ); }, ), diff --git a/lib/screens/focusable_detail_screen_mixin.dart b/lib/screens/focusable_detail_screen_mixin.dart index d28d63b2..a2ffa77f 100644 --- a/lib/screens/focusable_detail_screen_mixin.dart +++ b/lib/screens/focusable_detail_screen_mixin.dart @@ -1,26 +1,14 @@ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; -import '../focus/dpad_navigator.dart'; +import '../focus/focusable_action_bar.dart'; import '../focus/input_mode_tracker.dart'; import '../focus/key_event_utils.dart'; import '../mixins/grid_focus_node_mixin.dart'; import '../providers/settings_provider.dart'; import '../utils/grid_size_calculator.dart'; -import '../widgets/app_icon.dart'; import '../widgets/focusable_media_card.dart'; import '../widgets/media_grid_delegate.dart'; -/// Configuration for app bar buttons -class AppBarButtonConfig { - final IconData icon; - final String tooltip; - final VoidCallback onPressed; - final Color? color; - - const AppBarButtonConfig({required this.icon, required this.tooltip, required this.onPressed, this.color}); -} - /// Mixin that provides common focus navigation functionality for detail screens. /// Handles app bar focus, back navigation, scroll-to-top, and grid item focus management. /// @@ -29,36 +17,27 @@ mixin FocusableDetailScreenMixin on State, GridFocu // Scroll controller for scrolling to top when app bar is focused final ScrollController scrollController = ScrollController(); - // App bar focus nodes - final FocusNode playButtonFocusNode = FocusNode(debugLabel: 'detail_play'); - final FocusNode shuffleButtonFocusNode = FocusNode(debugLabel: 'detail_shuffle'); - final FocusNode deleteButtonFocusNode = FocusNode(debugLabel: 'detail_delete'); + // Action bar key for accessing focus nodes + final GlobalKey actionBarKey = GlobalKey(); // Grid item focus final FocusNode firstItemFocusNode = FocusNode(debugLabel: 'detail_first_item'); // App bar focus state bool isAppBarFocused = false; - int appBarFocusedButton = 0; // 0=play, 1=shuffle, 2=delete (or less if fewer buttons) // Flag to prevent PopScope from exiting when BACK was handled by a key handler bool backHandledByKeyEvent = false; - /// Number of app bar buttons (override if different from 3) - int get appBarButtonCount => 3; - /// Called when items are available and we want to check if focus should be set bool get hasItems; - /// Called to get the list of app bar button configurations - List getAppBarButtons(); + /// Called to get the list of app bar action configurations + List getAppBarActions(); /// Dispose focus-related resources. Call this from your dispose() method. void disposeFocusResources() { scrollController.dispose(); - playButtonFocusNode.dispose(); - shuffleButtonFocusNode.dispose(); - deleteButtonFocusNode.dispose(); firstItemFocusNode.dispose(); disposeGridFocusNodes(); } @@ -67,9 +46,8 @@ mixin FocusableDetailScreenMixin on State, GridFocu void navigateToAppBar() { setState(() { isAppBarFocused = true; - appBarFocusedButton = 0; }); - _focusAppBarButton(0); + actionBarKey.currentState?.getFocusNode(0).requestFocus(); // Scroll to top to show the app bar scrollController.animateTo(0, duration: const Duration(milliseconds: 200), curve: Curves.easeOut); } @@ -115,103 +93,16 @@ mixin FocusableDetailScreenMixin on State, GridFocu } } - /// Focus a specific app bar button by index - void _focusAppBarButton(int index) { - switch (index) { - case 0: - playButtonFocusNode.requestFocus(); - break; - case 1: - shuffleButtonFocusNode.requestFocus(); - break; - case 2: - deleteButtonFocusNode.requestFocus(); - break; - } - } - - /// Handle key events when app bar is focused - KeyEventResult handleAppBarKeyEvent(FocusNode _, KeyEvent event) { - final key = event.logicalKey; - final maxButton = appBarButtonCount - 1; - - final backResult = handleBackKeyAction(event, () => Navigator.pop(context)); - if (backResult != KeyEventResult.ignored) { - return backResult; - } - - if (event is! KeyDownEvent) return KeyEventResult.ignored; - - if (key.isLeftKey && appBarFocusedButton > 0) { - setState(() => appBarFocusedButton--); - _focusAppBarButton(appBarFocusedButton); - return KeyEventResult.handled; - } - if (key.isRightKey && appBarFocusedButton < maxButton) { - setState(() => appBarFocusedButton++); - _focusAppBarButton(appBarFocusedButton); - return KeyEventResult.handled; - } - if (key.isDownKey) { - // Return focus to grid - navigateToGrid(); - return KeyEventResult.handled; - } - if (key.isSelectKey) { - final buttons = getAppBarButtons(); - if (appBarFocusedButton < buttons.length) { - buttons[appBarFocusedButton].onPressed(); - } - return KeyEventResult.handled; - } - return KeyEventResult.ignored; - } - /// Build focusable app bar action widgets List buildFocusableAppBarActions() { - final colorScheme = Theme.of(context).colorScheme; - final isKeyboardMode = InputModeTracker.isKeyboardMode(context); - final buttons = getAppBarButtons(); - - return buttons.asMap().entries.map((entry) { - final index = entry.key; - final config = entry.value; - final isFocused = isKeyboardMode && isAppBarFocused && appBarFocusedButton == index; - - FocusNode focusNode; - switch (index) { - case 0: - focusNode = playButtonFocusNode; - break; - case 1: - focusNode = shuffleButtonFocusNode; - break; - case 2: - focusNode = deleteButtonFocusNode; - break; - default: - focusNode = FocusNode(); - } - - return Focus( - focusNode: focusNode, - onKeyEvent: handleAppBarKeyEvent, - child: Container( - decoration: isFocused - ? BoxDecoration( - color: colorScheme.surfaceContainerHighest, - borderRadius: const BorderRadius.all(Radius.circular(20)), - ) - : null, - child: IconButton( - icon: AppIcon(config.icon, fill: 1), - tooltip: config.tooltip, - onPressed: config.onPressed, - color: config.color, - ), - ), - ); - }).toList(); + return [ + FocusableActionBar( + key: actionBarKey, + onNavigateDown: navigateToGrid, + onBack: () => Navigator.pop(context), + actions: getAppBarActions(), + ), + ]; } /// Auto-focus first item after load if in keyboard mode. diff --git a/lib/screens/hub_detail_screen.dart b/lib/screens/hub_detail_screen.dart index ade17ca3..c9fff089 100644 --- a/lib/screens/hub_detail_screen.dart +++ b/lib/screens/hub_detail_screen.dart @@ -17,7 +17,7 @@ import '../widgets/desktop_app_bar.dart'; import '../widgets/overlay_sheet.dart'; import 'package:flutter/services.dart'; import '../focus/dpad_navigator.dart'; -import '../focus/focus_theme.dart'; +import '../focus/focusable_action_bar.dart'; import '../focus/input_mode_tracker.dart'; import '../focus/key_event_utils.dart'; import '../mixins/grid_focus_node_mixin.dart'; @@ -48,7 +48,7 @@ class _HubDetailScreenState extends State with Refreshable, Gri String? _errorMessage; late final FocusNode _firstItemFocusNode = FocusNode(debugLabel: 'hub_detail_first_item'); - late final FocusNode _sortButtonFocusNode = FocusNode(debugLabel: 'hub_detail_sort'); + final _actionBarKey = GlobalKey(); bool _isAppBarFocused = false; bool _backHandledByKeyEvent = false; @@ -63,7 +63,6 @@ class _HubDetailScreenState extends State with Refreshable, Gri @override void initState() { super.initState(); - _sortButtonFocusNode.addListener(_onSortButtonFocusChange); // Start with items already loaded in the hub _items = widget.hub.items; _filteredItems = widget.hub.items; @@ -84,23 +83,11 @@ class _HubDetailScreenState extends State with Refreshable, Gri @override void dispose() { - _sortButtonFocusNode.removeListener(_onSortButtonFocusChange); _firstItemFocusNode.dispose(); - _sortButtonFocusNode.dispose(); disposeGridFocusNodes(); super.dispose(); } - void _onSortButtonFocusChange() { - if (!mounted) return; - final hasFocus = _sortButtonFocusNode.hasFocus; - if (hasFocus && !_isAppBarFocused) { - setState(() => _isAppBarFocused = true); - } else if (!hasFocus && _isAppBarFocused) { - setState(() => _isAppBarFocused = false); - } - } - void _focusGrid() { if (_filteredItems.isEmpty) return; final targetIndex = @@ -114,7 +101,7 @@ class _HubDetailScreenState extends State with Refreshable, Gri void _navigateToAppBar() { setState(() => _isAppBarFocused = true); - _sortButtonFocusNode.requestFocus(); + _actionBarKey.currentState?.getFocusNode(0).requestFocus(); } void _handleBackFromContent() { @@ -122,24 +109,6 @@ class _HubDetailScreenState extends State with Refreshable, Gri _navigateToAppBar(); } - KeyEventResult _handleSortButtonKeyEvent(FocusNode _, KeyEvent event) { - final key = event.logicalKey; - - final backResult = handleBackKeyAction(event, () => Navigator.pop(context)); - if (backResult != KeyEventResult.ignored) return backResult; - - if (event is! KeyDownEvent) return KeyEventResult.ignored; - - if (key.isDownKey) { - _focusGrid(); - return KeyEventResult.handled; - } - if (key.isSelectKey) { - _showSortBottomSheet(); - return KeyEventResult.handled; - } - return KeyEventResult.ignored; - } Future _loadSorts() async { try { @@ -315,7 +284,6 @@ class _HubDetailScreenState extends State with Refreshable, Gri @override Widget build(BuildContext context) { final isKeyboardMode = InputModeTracker.isKeyboardMode(context); - final sortButtonFocused = isKeyboardMode && _isAppBarFocused; return PopScope( canPop: !isKeyboardMode || _isAppBarFocused, @@ -336,16 +304,17 @@ class _HubDetailScreenState extends State with Refreshable, Gri title: Text(widget.hub.title), pinned: true, actions: [ - Focus( - focusNode: _sortButtonFocusNode, - onKeyEvent: _handleSortButtonKeyEvent, - child: Container( - decoration: FocusTheme.focusBackgroundDecoration(isFocused: sortButtonFocused, borderRadius: 20), - child: IconButton( - icon: AppIcon(Symbols.swap_vert_rounded, fill: 1, semanticLabel: t.libraries.sort), + FocusableActionBar( + key: _actionBarKey, + onNavigateDown: _focusGrid, + onBack: () => Navigator.pop(context), + actions: [ + FocusableAction( + icon: Symbols.swap_vert_rounded, + tooltip: t.libraries.sort, onPressed: _showSortBottomSheet, ), - ), + ], ), ], ), diff --git a/lib/screens/libraries/libraries_screen.dart b/lib/screens/libraries/libraries_screen.dart index 1f0c7784..60aebdcf 100644 --- a/lib/screens/libraries/libraries_screen.dart +++ b/lib/screens/libraries/libraries_screen.dart @@ -4,9 +4,9 @@ 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_action_bar.dart'; import '../../focus/focusable_button.dart'; import '../../focus/dpad_navigator.dart'; -import '../../focus/focus_theme.dart'; import '../../focus/input_mode_tracker.dart'; import '../../focus/key_event_utils.dart'; import '../../mixins/tab_navigation_mixin.dart'; @@ -127,11 +127,8 @@ class _LibrariesScreenState extends State _playlistsTabChipFocusNode, ]; - // App bar action button focus - late FocusNode _editButtonFocusNode; - late FocusNode _refreshButtonFocusNode; - bool _isEditFocused = false; - bool _isRefreshFocused = false; + // App bar action bar + final _actionBarKey = GlobalKey(); // Scroll controller for the outer CustomScrollView final ScrollController _outerScrollController = ScrollController(); @@ -141,12 +138,6 @@ class _LibrariesScreenState extends State super.initState(); initTabNavigation(); - // Initialize action button focus nodes - _editButtonFocusNode = FocusNode(debugLabel: 'EditButton'); - _refreshButtonFocusNode = FocusNode(debugLabel: 'RefreshButton'); - _editButtonFocusNode.addListener(_onEditFocusChange); - _refreshButtonFocusNode.addListener(_onRefreshFocusChange); - // Initialize with libraries from the provider WidgetsBinding.instance.addPostFrameCallback((_) { _initializeWithLibraries(); @@ -338,42 +329,6 @@ class _LibrariesScreenState extends State _focusCurrentTab(); } - void _onEditFocusChange() { - if (mounted) { - setState(() => _isEditFocused = _editButtonFocusNode.hasFocus); - } - } - - void _onRefreshFocusChange() { - if (mounted) { - setState(() => _isRefreshFocused = _refreshButtonFocusNode.hasFocus); - } - } - - /// Handle key events for the edit button in app bar - late final _handleEditKeyEvent = dpadKeyHandler( - onLeft: () => getTabChipFocusNode(3).requestFocus(), - onRight: () => _refreshButtonFocusNode.requestFocus(), - onDown: _focusCurrentTab, - onUp: () {}, // Block at boundary - onSelect: _showLibraryManagementSheet, - ); - - /// Handle key events for the refresh button in app bar - late final _handleRefreshKeyEvent = dpadKeyHandler( - onLeft: () { - final librariesProvider = context.read(); - if (librariesProvider.libraries.isNotEmpty) { - _editButtonFocusNode.requestFocus(); - } else { - getTabChipFocusNode(3).requestFocus(); - } - }, - onRight: () {}, // Block at boundary - onUp: () {}, // Block at boundary - onDown: _focusCurrentTab, - onSelect: _refreshCurrentTab, - ); @override void dispose() { @@ -383,10 +338,6 @@ class _LibrariesScreenState extends State _browseTabChipFocusNode.dispose(); _collectionsTabChipFocusNode.dispose(); _playlistsTabChipFocusNode.dispose(); - _editButtonFocusNode.removeListener(_onEditFocusChange); - _editButtonFocusNode.dispose(); - _refreshButtonFocusNode.removeListener(_onRefreshFocusChange); - _refreshButtonFocusNode.dispose(); disposeTabNavigation(); super.dispose(); } @@ -935,13 +886,7 @@ class _LibrariesScreenState extends State getTabChipFocusNode(newIndex).requestFocus(); } : () { - // Navigate to first action button (edit if libraries exist, else refresh) - final librariesProvider = context.read(); - if (librariesProvider.libraries.isNotEmpty) { - _editButtonFocusNode.requestFocus(); - } else { - _refreshButtonFocusNode.requestFocus(); - } + _actionBarKey.currentState?.getFocusNode(0).requestFocus(); }, onNavigateDown: _focusCurrentTabFromTabBar, onBack: onTabBarBack, @@ -1048,36 +993,23 @@ class _LibrariesScreenState extends State shadowColor: Colors.transparent, scrolledUnderElevation: 0, actions: [ - if (allLibraries.isNotEmpty) - Focus( - focusNode: _editButtonFocusNode, - onKeyEvent: _handleEditKeyEvent, - child: Container( - decoration: BoxDecoration( - color: _isEditFocused ? Colors.white.withValues(alpha: 0.2) : Colors.transparent, - borderRadius: const BorderRadius.all(Radius.circular(20)), - ), - child: IconButton( - icon: const AppIcon(Symbols.edit_rounded, fill: 1), + FocusableActionBar( + key: _actionBarKey, + onNavigateLeft: () => getTabChipFocusNode(3).requestFocus(), + onNavigateDown: _focusCurrentTab, + actions: [ + if (allLibraries.isNotEmpty) + FocusableAction( + icon: Symbols.edit_rounded, tooltip: t.libraries.manageLibraries, onPressed: _showLibraryManagementSheet, ), - ), - ), - Focus( - focusNode: _refreshButtonFocusNode, - onKeyEvent: _handleRefreshKeyEvent, - child: Container( - decoration: BoxDecoration( - color: _isRefreshFocused ? Colors.white.withValues(alpha: 0.2) : Colors.transparent, - borderRadius: const BorderRadius.all(Radius.circular(20)), - ), - child: IconButton( - icon: const AppIcon(Symbols.refresh_rounded, fill: 1), + FocusableAction( + icon: Symbols.refresh_rounded, tooltip: t.common.refresh, onPressed: _refreshCurrentTab, ), - ), + ], ), ], ), diff --git a/lib/screens/livetv/live_tv_screen.dart b/lib/screens/livetv/live_tv_screen.dart index e9dedf88..7e1e24ae 100644 --- a/lib/screens/livetv/live_tv_screen.dart +++ b/lib/screens/livetv/live_tv_screen.dart @@ -3,6 +3,7 @@ import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; import '../../focus/dpad_navigator.dart'; +import '../../focus/focusable_action_bar.dart'; import '../../i18n/strings.g.dart'; import '../../models/livetv_channel.dart'; import '../../models/livetv_dvr.dart'; @@ -31,9 +32,8 @@ class _LiveTvScreenState extends State final _guideTabKey = GlobalKey(); final _whatsOnTabKey = GlobalKey(); - // App bar action button focus - final _refreshButtonFocusNode = FocusNode(debugLabel: 'RefreshButton'); - bool _isRefreshFocused = false; + // App bar action bar + final _actionBarKey = GlobalKey(); List _channels = []; bool _isLoading = true; @@ -47,7 +47,6 @@ class _LiveTvScreenState extends State super.initState(); suppressAutoFocus = true; initTabNavigation(); - _refreshButtonFocusNode.addListener(_onRefreshFocusChange); _loadChannels(); } @@ -55,15 +54,10 @@ class _LiveTvScreenState extends State void dispose() { _guideTabFocusNode.dispose(); _whatsOnTabFocusNode.dispose(); - _refreshButtonFocusNode.removeListener(_onRefreshFocusChange); - _refreshButtonFocusNode.dispose(); disposeTabNavigation(); super.dispose(); } - void _onRefreshFocusChange() { - if (mounted) setState(() => _isRefreshFocused = _refreshButtonFocusNode.hasFocus); - } @override void onTabChanged() { @@ -208,34 +202,6 @@ class _LiveTvScreenState extends State @override void focusActiveTabIfReady() => _focusCurrentTab(); - // --------------------------------------------------------------------------- - // Action button key handlers - // --------------------------------------------------------------------------- - - KeyEventResult _handleRefreshKeyEvent(FocusNode _, KeyEvent event) { - if (!event.isActionable) return KeyEventResult.ignored; - final key = event.logicalKey; - - if (key.isLeftKey) { - getTabChipFocusNode(tabCount - 1).requestFocus(); - return KeyEventResult.handled; - } - if (key.isRightKey) { - return KeyEventResult.handled; - } - if (key.isDownKey) { - _focusCurrentTab(); - return KeyEventResult.handled; - } - if (key.isUpKey) { - return KeyEventResult.handled; - } - if (key.isSelectKey) { - _loadChannels(); - return KeyEventResult.handled; - } - return KeyEventResult.ignored; - } // --------------------------------------------------------------------------- // Tab chips @@ -276,7 +242,7 @@ class _LiveTvScreenState extends State }); getTabChipFocusNode(newIndex).requestFocus(); } - : () => _refreshButtonFocusNode.requestFocus(), + : () => _actionBarKey.currentState?.getFocusNode(0).requestFocus(), onNavigateDown: _focusCurrentTab, onBack: onTabBarBack, ); @@ -303,20 +269,17 @@ class _LiveTvScreenState extends State ) : Text(t.liveTv.title), actions: [ - Focus( - focusNode: _refreshButtonFocusNode, - onKeyEvent: _handleRefreshKeyEvent, - child: Container( - decoration: BoxDecoration( - color: _isRefreshFocused ? Colors.white.withValues(alpha: 0.2) : Colors.transparent, - borderRadius: const BorderRadius.all(Radius.circular(20)), - ), - child: IconButton( - icon: const AppIcon(Symbols.refresh_rounded), + FocusableActionBar( + key: _actionBarKey, + onNavigateLeft: () => getTabChipFocusNode(tabCount - 1).requestFocus(), + onNavigateDown: _focusCurrentTab, + actions: [ + FocusableAction( + icon: Symbols.refresh_rounded, tooltip: t.liveTv.reloadGuide, onPressed: _loadChannels, ), - ), + ], ), ], ), diff --git a/lib/screens/playlist/playlist_detail_screen.dart b/lib/screens/playlist/playlist_detail_screen.dart index 69cca68e..a23d594a 100644 --- a/lib/screens/playlist/playlist_detail_screen.dart +++ b/lib/screens/playlist/playlist_detail_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:material_symbols_icons/symbols.dart'; +import '../../focus/focusable_action_bar.dart'; import '../../services/plex_client.dart'; import '../../services/play_queue_launcher.dart'; import '../../models/plex_playlist.dart'; @@ -51,33 +52,15 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen items.isNotEmpty; @override - int get appBarButtonCount { - int count = 0; - if (items.isNotEmpty) count += 2; // play + shuffle - if (!widget.playlist.smart) count += 1; // delete - return count; - } - - @override - List getAppBarButtons() { - final buttons = []; - if (items.isNotEmpty) { - buttons.add(AppBarButtonConfig(icon: Symbols.play_arrow_rounded, tooltip: t.common.play, onPressed: playItems)); - buttons.add( - AppBarButtonConfig(icon: Symbols.shuffle_rounded, tooltip: t.common.shuffle, onPressed: shufflePlayItems), - ); - } - if (!widget.playlist.smart) { - buttons.add( - AppBarButtonConfig( - icon: Symbols.delete_rounded, - tooltip: t.playlists.delete, - onPressed: _deletePlaylist, - color: Colors.red, - ), - ); - } - return buttons; + List getAppBarActions() { + return [ + if (items.isNotEmpty) ...[ + FocusableAction(icon: Symbols.play_arrow_rounded, tooltip: t.common.play, onPressed: playItems), + FocusableAction(icon: Symbols.shuffle_rounded, tooltip: t.common.shuffle, onPressed: shufflePlayItems), + ], + if (!widget.playlist.smart) + FocusableAction(icon: Symbols.delete_rounded, tooltip: t.playlists.delete, onPressed: _deletePlaylist, iconColor: Colors.red), + ]; } // Focus management for regular (non-smart) reorderable lists diff --git a/lib/screens/settings/logs_screen.dart b/lib/screens/settings/logs_screen.dart index 6dff4316..ed08666b 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_action_bar.dart'; import '../../focus/focusable_button.dart'; import '../../focus/key_event_utils.dart'; import '../../i18n/strings.g.dart'; @@ -24,36 +25,15 @@ class _LogsScreenState extends State { List _logs = []; final ScrollController _scrollController = ScrollController(); - late final FocusNode _refreshFocusNode; - late final FocusNode _uploadFocusNode; - late final FocusNode _copyFocusNode; - late final FocusNode _clearFocusNode; - bool _isRefreshFocused = false; - bool _isUploadFocused = false; - bool _isCopyFocused = false; - bool _isClearFocused = false; - @override void initState() { super.initState(); _logs = MemoryLogOutput.getLogs(); - _refreshFocusNode = FocusNode(debugLabel: 'RefreshLogs'); - _uploadFocusNode = FocusNode(debugLabel: 'UploadLogs'); - _copyFocusNode = FocusNode(debugLabel: 'CopyLogs'); - _clearFocusNode = FocusNode(debugLabel: 'ClearLogs'); - _refreshFocusNode.addListener(() => setState(() => _isRefreshFocused = _refreshFocusNode.hasFocus)); - _uploadFocusNode.addListener(() => setState(() => _isUploadFocused = _uploadFocusNode.hasFocus)); - _copyFocusNode.addListener(() => setState(() => _isCopyFocused = _copyFocusNode.hasFocus)); - _clearFocusNode.addListener(() => setState(() => _isClearFocused = _clearFocusNode.hasFocus)); } @override void dispose() { _scrollController.dispose(); - _refreshFocusNode.dispose(); - _uploadFocusNode.dispose(); - _copyFocusNode.dispose(); - _clearFocusNode.dispose(); super.dispose(); } @@ -225,31 +205,6 @@ class _LogsScreenState extends State { return spans; } - Widget _buildActionButton({ - required FocusNode focusNode, - required bool isFocused, - required FocusOnKeyEventCallback onKeyEvent, - required IconData icon, - required String? tooltip, - required VoidCallback? onPressed, - }) { - return Focus( - focusNode: focusNode, - onKeyEvent: onKeyEvent, - child: Container( - decoration: BoxDecoration( - color: isFocused ? Colors.white.withValues(alpha: 0.2) : Colors.transparent, - borderRadius: const BorderRadius.all(Radius.circular(20)), - ), - child: IconButton( - icon: AppIcon(icon, fill: 1), - tooltip: tooltip, - onPressed: onPressed, - ), - ), - ); - } - @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -279,51 +234,29 @@ class _LogsScreenState extends State { title: Text(t.screens.logs), pinned: true, actions: [ - _buildActionButton( - focusNode: _refreshFocusNode, - isFocused: _isRefreshFocused, - onKeyEvent: dpadKeyHandler( - onSelect: _loadLogs, - onRight: () => _uploadFocusNode.requestFocus(), - ), - icon: Symbols.refresh_rounded, - tooltip: t.common.refresh, - onPressed: _loadLogs, - ), - _buildActionButton( - focusNode: _uploadFocusNode, - isFocused: _isUploadFocused, - onKeyEvent: dpadKeyHandler( - onSelect: _logs.isNotEmpty ? _uploadLogs : null, - onLeft: () => _refreshFocusNode.requestFocus(), - onRight: () => _copyFocusNode.requestFocus(), - ), - icon: Symbols.upload_rounded, - tooltip: t.logs.uploadLogs, - onPressed: _logs.isNotEmpty ? _uploadLogs : null, - ), - _buildActionButton( - focusNode: _copyFocusNode, - isFocused: _isCopyFocused, - onKeyEvent: dpadKeyHandler( - onSelect: _logs.isNotEmpty ? _copyAllLogs : null, - onLeft: () => _uploadFocusNode.requestFocus(), - onRight: () => _clearFocusNode.requestFocus(), - ), - icon: Symbols.content_copy_rounded, - tooltip: t.logs.copyLogs, - onPressed: _logs.isNotEmpty ? _copyAllLogs : null, - ), - _buildActionButton( - focusNode: _clearFocusNode, - isFocused: _isClearFocused, - onKeyEvent: dpadKeyHandler( - onSelect: _logs.isNotEmpty ? _clearLogs : null, - onLeft: () => _copyFocusNode.requestFocus(), - ), - icon: Symbols.delete_outline_rounded, - tooltip: t.logs.clearLogs, - onPressed: _logs.isNotEmpty ? _clearLogs : null, + FocusableActionBar( + actions: [ + FocusableAction( + icon: Symbols.refresh_rounded, + tooltip: t.common.refresh, + onPressed: _loadLogs, + ), + FocusableAction( + icon: Symbols.upload_rounded, + tooltip: t.logs.uploadLogs, + onPressed: _logs.isNotEmpty ? _uploadLogs : null, + ), + FocusableAction( + icon: Symbols.content_copy_rounded, + tooltip: t.logs.copyLogs, + onPressed: _logs.isNotEmpty ? _copyAllLogs : null, + ), + FocusableAction( + icon: Symbols.delete_outline_rounded, + tooltip: t.logs.clearLogs, + onPressed: _logs.isNotEmpty ? _clearLogs : null, + ), + ], ), ], ),