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:
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user