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.
This commit is contained in:
edde746
2026-02-27 11:55:19 +01:00
parent 8559037a50
commit cc30bee83a
9 changed files with 407 additions and 681 deletions
+184
View File
@@ -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<FocusableAction> 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<FocusableActionBar> createState() => FocusableActionBarState();
}
class FocusableActionBarState extends State<FocusableActionBar> {
late List<FocusNode> _focusNodes;
late List<bool> _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,
),
),
),
);
}
}
+9 -20
View File
@@ -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<CollectionD
@override
bool get hasItems => 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<CollectionD
}
@override
List<AppBarButtonConfig> getAppBarButtons() {
final buttons = <AppBarButtonConfig>[];
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<FocusableAction> 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<void> _deleteCollection() async {
+127 -245
View File
@@ -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<DiscoverScreen>
// 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<FocusableActionBarState>();
/// Get the correct PlexClient for an item's server
PlexClient _getClientForItem(PlexMetadata? item) {
@@ -188,7 +182,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
if (_isHeroSectionVisible) {
_heroFocusNode.requestFocus();
} else {
_refreshButtonFocusNode.requestFocus();
_actionBarKey.currentState?.getFocusNode(0).requestFocus();
}
_scrollToTop();
}
@@ -245,14 +239,6 @@ class _DiscoverScreenState extends State<DiscoverScreen>
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<DiscoverScreen>
_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<DiscoverScreen>
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<DiscoverScreen>
},
);
/// 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<UserProfileProvider>();
_showUserMenu(context, userProvider);
},
);
@override
void dispose() {
@@ -379,14 +296,6 @@ class _DiscoverScreenState extends State<DiscoverScreen>
_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<DiscoverScreen>
/// 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<DiscoverScreen>
).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<WatchTogetherProvider>(
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<WatchTogetherProvider, CompanionRemoteProvider>(
builder: (context, watchTogether, companionRemote, _) {
final isDesktop = PlatformDetector.isDesktop(context);
final userProvider = context.watch<UserProfileProvider>();
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<CompanionRemoteProvider>(
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<UserProfileProvider>(
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<String>(
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<String>(
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),
],
),
),
],
],
),
),
),
],
);
},
),
+14 -123
View File
@@ -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<T extends StatefulWidget> on State<T>, 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<FocusableActionBarState> actionBarKey = GlobalKey<FocusableActionBarState>();
// 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<AppBarButtonConfig> getAppBarButtons();
/// Called to get the list of app bar action configurations
List<FocusableAction> 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<T extends StatefulWidget> on State<T>, 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<T extends StatefulWidget> on State<T>, 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<Widget> 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.
+12 -43
View File
@@ -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<HubDetailScreen> 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<FocusableActionBarState>();
bool _isAppBarFocused = false;
bool _backHandledByKeyEvent = false;
@@ -63,7 +63,6 @@ class _HubDetailScreenState extends State<HubDetailScreen> 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<HubDetailScreen> 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<HubDetailScreen> 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<HubDetailScreen> 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<void> _loadSorts() async {
try {
@@ -315,7 +284,6 @@ class _HubDetailScreenState extends State<HubDetailScreen> 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<HubDetailScreen> 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,
),
),
],
),
],
),
+15 -83
View File
@@ -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<LibrariesScreen>
_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<FocusableActionBarState>();
// Scroll controller for the outer CustomScrollView
final ScrollController _outerScrollController = ScrollController();
@@ -141,12 +138,6 @@ class _LibrariesScreenState extends State<LibrariesScreen>
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<LibrariesScreen>
_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<LibrariesProvider>();
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<LibrariesScreen>
_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<LibrariesScreen>
getTabChipFocusNode(newIndex).requestFocus();
}
: () {
// Navigate to first action button (edit if libraries exist, else refresh)
final librariesProvider = context.read<LibrariesProvider>();
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<LibrariesScreen>
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,
),
),
],
),
],
),
+12 -49
View File
@@ -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<LiveTvScreen>
final _guideTabKey = GlobalKey<GuideTabState>();
final _whatsOnTabKey = GlobalKey<WhatsOnTabState>();
// App bar action button focus
final _refreshButtonFocusNode = FocusNode(debugLabel: 'RefreshButton');
bool _isRefreshFocused = false;
// App bar action bar
final _actionBarKey = GlobalKey<FocusableActionBarState>();
List<LiveTvChannel> _channels = [];
bool _isLoading = true;
@@ -47,7 +47,6 @@ class _LiveTvScreenState extends State<LiveTvScreen>
super.initState();
suppressAutoFocus = true;
initTabNavigation();
_refreshButtonFocusNode.addListener(_onRefreshFocusChange);
_loadChannels();
}
@@ -55,15 +54,10 @@ class _LiveTvScreenState extends State<LiveTvScreen>
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<LiveTvScreen>
@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<LiveTvScreen>
});
getTabChipFocusNode(newIndex).requestFocus();
}
: () => _refreshButtonFocusNode.requestFocus(),
: () => _actionBarKey.currentState?.getFocusNode(0).requestFocus(),
onNavigateDown: _focusCurrentTab,
onBack: onTabBarBack,
);
@@ -303,20 +269,17 @@ class _LiveTvScreenState extends State<LiveTvScreen>
)
: 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,
),
),
],
),
],
),
@@ -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<PlaylistDetai
bool get hasItems => 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<AppBarButtonConfig> getAppBarButtons() {
final buttons = <AppBarButtonConfig>[];
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<FocusableAction> 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
+24 -91
View File
@@ -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<LogsScreen> {
List<LogEntry> _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<LogsScreen> {
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<LogsScreen> {
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,
),
],
),
],
),