diff --git a/lib/screens/libraries/sort_bottom_sheet.dart b/lib/screens/libraries/sort_bottom_sheet.dart index 157ad494..130e273a 100644 --- a/lib/screens/libraries/sort_bottom_sheet.dart +++ b/lib/screens/libraries/sort_bottom_sheet.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; +import '../../focus/dpad_navigator.dart'; import '../../models/plex_sort.dart'; import '../../widgets/bottom_sheet_header.dart'; import '../../widgets/focusable_bottom_sheet.dart'; @@ -46,13 +47,20 @@ class _SortBottomSheetState extends State { super.dispose(); } - void _handleSortChange(PlexSort sort, bool descending) { + void _handleSortSelect(PlexSort sort) { + final descending = (_currentSort?.key == sort.key) ? _currentDescending : sort.isDefaultDescending; setState(() { _currentSort = sort; _currentDescending = descending; }); widget.onSortChanged(sort, descending); - Navigator.pop(context); + } + + void _handleDirectionChange(PlexSort sort, bool descending) { + setState(() { + _currentDescending = descending; + }); + widget.onSortChanged(sort, descending); } void _handleClear() { @@ -61,7 +69,6 @@ class _SortBottomSheetState extends State { _currentDescending = false; }); widget.onClear?.call(); - Navigator.pop(context); } @override @@ -83,25 +90,38 @@ class _SortBottomSheetState extends State { : null, ), Expanded( - child: RadioGroup( - groupValue: _currentSort, - onChanged: (value) { - if (value != null) { - _handleSortChange(value, value.isDefaultDescending); - } - }, - child: ListView.builder( - controller: scrollController, - padding: const EdgeInsets.symmetric(vertical: 8), - itemCount: widget.sortOptions.length, - itemBuilder: (context, index) { - final sort = widget.sortOptions[index]; - final isSelected = _currentSort?.key == sort.key; + child: ListView.builder( + controller: scrollController, + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: widget.sortOptions.length, + itemBuilder: (context, index) { + final sort = widget.sortOptions[index]; + final isSelected = _currentSort?.key == sort.key; - return FocusableRadioListTile( + return Focus( + canRequestFocus: false, + skipTraversal: true, + onKeyEvent: (node, event) { + if (!event.isActionable) return KeyEventResult.ignored; + if (!isSelected) return KeyEventResult.ignored; + if (event.logicalKey.isLeftKey) { + _handleDirectionChange(sort, false); + return KeyEventResult.handled; + } + if (event.logicalKey.isRightKey) { + _handleDirectionChange(sort, true); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }, + child: FocusableRadioListTile( focusNode: index == 0 ? _initialFocusNode : null, title: Text(sort.title), value: sort, + groupValue: _currentSort, + onChanged: (value) { + if (value != null) _handleSortSelect(value); + }, secondary: isSelected ? SegmentedButton( showSelectedIcon: false, @@ -117,13 +137,13 @@ class _SortBottomSheetState extends State { ], selected: {_currentDescending}, onSelectionChanged: (Set newSelection) { - _handleSortChange(sort, newSelection.first); + _handleDirectionChange(sort, newSelection.first); }, ) : null, - ); - }, - ), + ), + ); + }, ), ), ], diff --git a/lib/screens/libraries/tabs/library_browse_tab.dart b/lib/screens/libraries/tabs/library_browse_tab.dart index 71e3763a..5b64e77a 100644 --- a/lib/screens/libraries/tabs/library_browse_tab.dart +++ b/lib/screens/libraries/tabs/library_browse_tab.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; import 'package:dio/dio.dart'; +import '../../../focus/dpad_navigator.dart'; import '../../../../services/plex_client.dart'; import '../../../models/plex_metadata.dart'; import '../../../models/plex_filter.dart'; @@ -402,40 +403,50 @@ class _LibraryBrowseTabState extends BaseLibraryTabState( - groupValue: _selectedGrouping, - onChanged: (value) async { - if (value == null) return; - setState(() { - _selectedGrouping = value; - }); - - final storage = await StorageService.getInstance(); - await storage.saveLibraryGrouping(widget.library.globalKey, value); - - if (!sheetContext.mounted || !mounted) return; - - Navigator.pop(sheetContext); - _loadItems(); + return StatefulBuilder( + builder: (context, setSheetState) { + return ListView.builder( + shrinkWrap: true, + itemCount: options.length, + itemBuilder: (context, index) { + final grouping = options[index]; + return RadioListTile( + title: Text(_getGroupingLabel(grouping)), + value: grouping, + groupValue: pendingGrouping, + onChanged: (value) { + if (value == null) return; + setSheetState(() { + pendingGrouping = value; + }); + }, + ); + }, + ); }, - child: ListView.builder( - shrinkWrap: true, - itemCount: options.length, - itemBuilder: (context, index) { - final grouping = options[index]; - return RadioListTile(title: Text(_getGroupingLabel(grouping)), value: grouping); - }, - ), ); }, - ); + ).then((_) { + if (!mounted) return; + if (pendingGrouping == _selectedGrouping) return; + setState(() { + _selectedGrouping = pendingGrouping; + }); + StorageService.getInstance().then((storage) { + storage.saveLibraryGrouping(widget.library.globalKey, pendingGrouping); + }); + _loadItems(); + }); } void _showFiltersBottomSheet() { + SelectKeyUpSuppressor.suppressSelectUntilKeyUp(); showModalBottomSheet( context: context, isScrollControlled: true, @@ -460,6 +471,12 @@ class _LibraryBrowseTabState extends BaseLibraryTabState { /// A RadioListTile that accepts a FocusNode for keyboard/controller navigation. /// /// Uses Flutter's native RadioListTile focus support - no custom styling wrapper. +/// Can be used standalone with [groupValue]/[onChanged] or inside a [RadioGroup]. class FocusableRadioListTile extends StatelessWidget { /// The primary content of the list tile. final Widget? title; @@ -124,6 +125,14 @@ class FocusableRadioListTile extends StatelessWidget { /// The value represented by this radio button. final T value; + /// The currently selected value for the group. + /// When provided, the widget works without a [RadioGroup] ancestor. + final T? groupValue; + + /// Called when this radio button is selected. + /// When provided, the widget works without a [RadioGroup] ancestor. + final ValueChanged? onChanged; + /// Whether this radio button is part of a vertically dense list. final bool dense; @@ -142,6 +151,8 @@ class FocusableRadioListTile extends StatelessWidget { this.subtitle, this.secondary, required this.value, + this.groupValue, + this.onChanged, this.dense = false, this.focusNode, this.autofocus = false, @@ -155,6 +166,8 @@ class FocusableRadioListTile extends StatelessWidget { subtitle: subtitle, secondary: secondary, value: value, + groupValue: groupValue, + onChanged: onChanged, dense: dense, focusNode: focusNode, autofocus: autofocus,