From 19dea4b31e17df485bb75b6f5f95fb095b803cb9 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 8 May 2026 06:50:34 +0200 Subject: [PATCH] refactor(ui): share bottom sheet scaffold --- .../libraries/filters_bottom_sheet.dart | 307 +++++++++--------- lib/widgets/bottom_sheet_page_scaffold.dart | 81 +++++ .../sheets/base_video_control_sheet.dart | 37 +-- .../sheets/video_sheet_header.dart | 31 -- 4 files changed, 244 insertions(+), 212 deletions(-) create mode 100644 lib/widgets/bottom_sheet_page_scaffold.dart delete mode 100644 lib/widgets/video_controls/sheets/video_sheet_header.dart diff --git a/lib/screens/libraries/filters_bottom_sheet.dart b/lib/screens/libraries/filters_bottom_sheet.dart index cf9ab6f8..fc004c9d 100644 --- a/lib/screens/libraries/filters_bottom_sheet.dart +++ b/lib/screens/libraries/filters_bottom_sheet.dart @@ -2,11 +2,11 @@ import 'package:flutter/material.dart'; import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../focus/focusable_button.dart'; +import '../../focus/input_mode_tracker.dart'; import '../../media/media_filter.dart'; import '../../services/plex_client.dart'; import '../../utils/scroll_utils.dart'; -import '../../widgets/app_bar_back_button.dart'; -import '../../widgets/bottom_sheet_header.dart'; +import '../../widgets/bottom_sheet_page_scaffold.dart'; import '../../widgets/focusable_list_tile.dart'; import '../../widgets/overlay_sheet.dart'; import '../../utils/provider_extensions.dart'; @@ -111,6 +111,7 @@ class _FiltersBottomSheetState extends State { _filterValues = values; _isLoadingValues = false; }); + _requestInitialFocus(); // Scroll to selected value if any final selectedValue = _tempSelectedFilters[filter.filter]; if (selectedValue != null) { @@ -126,6 +127,7 @@ class _FiltersBottomSheetState extends State { _filterValues = []; _isLoadingValues = false; }); + _requestInitialFocus(); } } @@ -134,6 +136,26 @@ class _FiltersBottomSheetState extends State { _currentFilter = null; _filterValues = []; }); + _requestInitialFocus(); + } + + void _requestInitialFocus() { + if (!InputModeTracker.isKeyboardMode(context)) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + if (_initialFocusNode.context != null) { + _initialFocusNode.requestFocus(); + } else { + OverlaySheetController.maybeOf(context)?.refocus(); + } + }); + } + + void _clearFilters() { + setState(() { + _tempSelectedFilters.clear(); + }); + _applyFilters(); } void _applyFilters() { @@ -155,164 +177,141 @@ class _FiltersBottomSheetState extends State { @override Widget build(BuildContext context) { - if (_currentFilter != null) { - // Show filter options view - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Header with back button - BottomSheetHeader( - title: _currentFilter!.title, - leading: AppBarBackButton(style: BackButtonStyle.plain, onPressed: _goBack), - ), - - // Filter options list - if (_isLoadingValues) - const SizedBox(height: 120, child: Center(child: CircularProgressIndicator())) - else - Flexible( - child: ListView.builder( - controller: _valuesScrollController, - primary: false, - shrinkWrap: true, - padding: const EdgeInsets.symmetric(vertical: 8), - itemCount: _filterValues.length + 1, - itemBuilder: (context, index) { - if (index == 0) { - final isSelected = !_tempSelectedFilters.containsKey(_currentFilter!.filter); - return FocusableListTile( - key: _valuesFirstItemKey, - focusNode: _initialFocusNode, - title: Text(t.libraries.all), - selected: isSelected, - onTap: () { - setState(() { - _tempSelectedFilters.remove(_currentFilter!.filter); - }); - _applyFilters(); - }, - ); - } - - final value = _filterValues[index - 1]; - final filterValue = _extractFilterValue(value.key, _currentFilter!.filter); - final isSelected = _tempSelectedFilters[_currentFilter!.filter] == filterValue; - - return FocusableListTile( - title: Text(value.title), - selected: isSelected, - onTap: () { - setState(() { - _tempSelectedFilters[_currentFilter!.filter] = filterValue; - // Cache the display name for this filter value - if (_filterDisplayNames.length > _maxCachedDisplayNames) { - _filterDisplayNames.clear(); - } - _filterDisplayNames[_cacheKey(_currentFilter!.filter, filterValue)] = value.title; - }); - _applyFilters(); - }, - ); - }, + final currentFilter = _currentFilter; + return BottomSheetPageScaffold( + title: currentFilter?.title ?? t.libraries.filters, + icon: Symbols.filter_alt_rounded, + onBack: currentFilter != null ? _goBack : null, + action: currentFilter == null && _tempSelectedFilters.isNotEmpty + ? FocusableButton( + onPressed: _clearFilters, + child: TextButton.icon( + onPressed: _clearFilters, + icon: const AppIcon(Symbols.clear_all_rounded, fill: 1), + label: Text(t.libraries.clearAll), ), - ), - ], + ) + : null, + child: currentFilter != null ? _buildFilterValuesView(currentFilter) : _buildFiltersView(), + ); + } + + Widget _buildFilterValuesView(MediaFilter filter) { + if (_isLoadingValues) { + return Focus( + autofocus: InputModeTracker.isKeyboardMode(context), + child: const Center(child: CircularProgressIndicator()), ); } - // Show main filters view - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Header - BottomSheetHeader( - title: t.libraries.filters, - leading: const AppIcon(Symbols.filter_alt_rounded, fill: 1), - action: _tempSelectedFilters.isNotEmpty - ? FocusableButton( - onPressed: () { - setState(() { - _tempSelectedFilters.clear(); - }); - _applyFilters(); - }, - child: TextButton.icon( - onPressed: () { - setState(() { - _tempSelectedFilters.clear(); - }); - _applyFilters(); - }, - icon: const AppIcon(Symbols.clear_all_rounded, fill: 1), - label: Text(t.libraries.clearAll), - ), - ) - : null, - ), - - // All Filters (boolean toggles first, then regular filters) - Flexible( - child: ListView.builder( - primary: false, - shrinkWrap: true, - padding: const EdgeInsets.symmetric(vertical: 8), - itemCount: _sortedFilters.length, - itemBuilder: (context, index) { - final filter = _sortedFilters[index]; - - // Handle boolean filters as switches (unwatched, inProgress, unmatched, hdr, etc.) - if (_isBooleanFilter(filter)) { - final isActive = - _tempSelectedFilters.containsKey(filter.filter) && _tempSelectedFilters[filter.filter] == '1'; - return FocusableSwitchListTile( - focusNode: index == 0 ? _initialFocusNode : null, - value: isActive, - onChanged: (value) { - setState(() { - if (value) { - _tempSelectedFilters[filter.filter] = '1'; - } else { - _tempSelectedFilters.remove(filter.filter); - } - }); - _applyFilters(); - }, - title: Text(filter.title), - ); - } - - // Regular navigable filters - show selected value instead of checkmark - final selectedValue = _tempSelectedFilters[filter.filter]; - String? displayValue; - if (selectedValue != null) { - // Try to get the cached display name, fall back to the value itself - displayValue = _filterDisplayNames[_cacheKey(filter.filter, selectedValue)] ?? selectedValue; - } - - return FocusableListTile( - focusNode: index == 0 ? _initialFocusNode : null, - title: Text(filter.title), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (displayValue != null) - Flexible( - child: Text( - displayValue, - style: TextStyle(color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.w500), - overflow: TextOverflow.ellipsis, - ), - ), - if (displayValue != null) const SizedBox(width: 8), - const AppIcon(Symbols.chevron_right_rounded, fill: 1), - ], - ), - onTap: () => _loadFilterValues(filter), - ); + final autofocusFirst = InputModeTracker.isKeyboardMode(context); + return ListView.builder( + controller: _valuesScrollController, + primary: false, + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: _filterValues.length + 1, + itemBuilder: (context, index) { + if (index == 0) { + final isSelected = !_tempSelectedFilters.containsKey(filter.filter); + return FocusableListTile( + key: _valuesFirstItemKey, + focusNode: _initialFocusNode, + autofocus: autofocusFirst, + title: Text(t.libraries.all), + selected: isSelected, + onTap: () { + setState(() { + _tempSelectedFilters.remove(filter.filter); + }); + _applyFilters(); }, + ); + } + + final value = _filterValues[index - 1]; + final filterValue = _extractFilterValue(value.key, filter.filter); + final isSelected = _tempSelectedFilters[filter.filter] == filterValue; + + return FocusableListTile( + title: Text(value.title), + selected: isSelected, + onTap: () { + setState(() { + _tempSelectedFilters[filter.filter] = filterValue; + // Cache the display name for this filter value. + if (_filterDisplayNames.length > _maxCachedDisplayNames) { + _filterDisplayNames.clear(); + } + _filterDisplayNames[_cacheKey(filter.filter, filterValue)] = value.title; + }); + _applyFilters(); + }, + ); + }, + ); + } + + Widget _buildFiltersView() { + final autofocusFirst = InputModeTracker.isKeyboardMode(context); + return ListView.builder( + primary: false, + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: _sortedFilters.length, + itemBuilder: (context, index) { + final filter = _sortedFilters[index]; + + // Handle boolean filters as switches (unwatched, inProgress, unmatched, hdr, etc.) + if (_isBooleanFilter(filter)) { + final isActive = + _tempSelectedFilters.containsKey(filter.filter) && _tempSelectedFilters[filter.filter] == '1'; + return FocusableSwitchListTile( + focusNode: index == 0 ? _initialFocusNode : null, + autofocus: index == 0 && autofocusFirst, + value: isActive, + onChanged: (value) { + setState(() { + if (value) { + _tempSelectedFilters[filter.filter] = '1'; + } else { + _tempSelectedFilters.remove(filter.filter); + } + }); + _applyFilters(); + }, + title: Text(filter.title), + ); + } + + // Regular navigable filters - show selected value instead of checkmark + final selectedValue = _tempSelectedFilters[filter.filter]; + String? displayValue; + if (selectedValue != null) { + // Try to get the cached display name, fall back to the value itself + displayValue = _filterDisplayNames[_cacheKey(filter.filter, selectedValue)] ?? selectedValue; + } + + return FocusableListTile( + focusNode: index == 0 ? _initialFocusNode : null, + autofocus: index == 0 && autofocusFirst, + title: Text(filter.title), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (displayValue != null) + Flexible( + child: Text( + displayValue, + style: TextStyle(color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.w500), + overflow: TextOverflow.ellipsis, + ), + ), + if (displayValue != null) const SizedBox(width: 8), + const AppIcon(Symbols.chevron_right_rounded, fill: 1), + ], ), - ), - ], + onTap: () => _loadFilterValues(filter), + ); + }, ); } } diff --git a/lib/widgets/bottom_sheet_page_scaffold.dart b/lib/widgets/bottom_sheet_page_scaffold.dart new file mode 100644 index 00000000..8dfc44d5 --- /dev/null +++ b/lib/widgets/bottom_sheet_page_scaffold.dart @@ -0,0 +1,81 @@ +import 'package:flutter/material.dart'; + +import '../focus/dpad_navigator.dart'; +import '../focus/key_event_utils.dart'; +import 'bottom_sheet_header.dart'; + +/// Shared page layout for bottom sheets with a stable header and content area. +class BottomSheetPageScaffold extends StatelessWidget { + final String title; + final Widget child; + final Widget? leading; + final Widget? action; + final VoidCallback? onClose; + final IconData? icon; + final Color? iconColor; + final VoidCallback? onBack; + final TextStyle? titleStyle; + final Color? titleColor; + final bool showHeaderBorder; + final bool showHeaderDivider; + final FocusNode? closeFocusNode; + + const BottomSheetPageScaffold({ + super.key, + required this.title, + required this.child, + this.leading, + this.action, + this.onClose, + this.icon, + this.iconColor, + this.onBack, + this.titleStyle, + this.titleColor, + this.showHeaderBorder = true, + this.showHeaderDivider = false, + this.closeFocusNode, + }); + + @override + Widget build(BuildContext context) { + Widget content = Column( + children: [ + BottomSheetHeader( + title: title, + leading: leading, + action: action, + onClose: onClose, + icon: icon, + iconColor: iconColor, + onBack: onBack, + titleStyle: titleStyle, + titleColor: titleColor, + showBorder: showHeaderBorder, + closeFocusNode: closeFocusNode, + ), + if (showHeaderDivider) Divider(color: Theme.of(context).dividerColor, height: 1), + Expanded(child: child), + ], + ); + + // Let sub-pages consume Back and return to their parent instead of closing + // the whole sheet via the overlay host. + final back = onBack; + if (back != null) { + content = Focus( + canRequestFocus: false, + skipTraversal: true, + onKeyEvent: (node, event) { + if (event.logicalKey.isBackKey) { + return handleBackKeyAction(event, back); + } + return KeyEventResult.ignored; + }, + child: content, + ); + } + + return content; + } +} diff --git a/lib/widgets/video_controls/sheets/base_video_control_sheet.dart b/lib/widgets/video_controls/sheets/base_video_control_sheet.dart index c579847d..73a1a6d2 100644 --- a/lib/widgets/video_controls/sheets/base_video_control_sheet.dart +++ b/lib/widgets/video_controls/sheets/base_video_control_sheet.dart @@ -1,8 +1,6 @@ import 'package:flutter/material.dart'; -import '../../../focus/key_event_utils.dart'; -import '../../../focus/dpad_navigator.dart'; -import 'video_sheet_header.dart'; +import '../../../widgets/bottom_sheet_page_scaffold.dart'; /// Base class for video control bottom sheets providing common UI structure class BaseVideoControlSheet extends StatelessWidget { @@ -23,30 +21,15 @@ class BaseVideoControlSheet extends StatelessWidget { @override Widget build(BuildContext context) { - Widget content = Column( - children: [ - VideoSheetHeader(title: title, icon: icon, iconColor: iconColor, onBack: onBack), - Divider(color: Theme.of(context).dividerColor, height: 1), - Expanded(child: child), - ], + return BottomSheetPageScaffold( + title: title, + icon: icon, + iconColor: iconColor, + onBack: onBack, + titleStyle: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + showHeaderBorder: false, + showHeaderDivider: true, + child: child, ); - - // Intercept back key at the sub-page level so it triggers onBack - // instead of bubbling up to OverlaySheetHost which would close the sheet. - if (onBack != null) { - content = Focus( - canRequestFocus: false, - skipTraversal: true, - onKeyEvent: (node, event) { - if (event.logicalKey.isBackKey) { - return handleBackKeyAction(event, onBack!); - } - return KeyEventResult.ignored; - }, - child: content, - ); - } - - return content; } } diff --git a/lib/widgets/video_controls/sheets/video_sheet_header.dart b/lib/widgets/video_controls/sheets/video_sheet_header.dart deleted file mode 100644 index 7d6b8d6e..00000000 --- a/lib/widgets/video_controls/sheets/video_sheet_header.dart +++ /dev/null @@ -1,31 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:plezy/widgets/bottom_sheet_header.dart'; - -/// Shared header widget for video control sheets -/// -/// This is now a thin wrapper around [BottomSheetHeader] for backward compatibility. -/// Consider using [BottomSheetHeader] directly for new implementations. -/// -/// Provides a consistent header with an icon/back button, title, and close button -class VideoSheetHeader extends StatelessWidget { - final String title; - final IconData? icon; - final Color? iconColor; - final VoidCallback? onBack; - final VoidCallback? onClose; - - const VideoSheetHeader({super.key, required this.title, this.icon, this.iconColor, this.onBack, this.onClose}); - - @override - Widget build(BuildContext context) { - return BottomSheetHeader( - title: title, - icon: icon, - iconColor: iconColor, - onBack: onBack, - onClose: onClose, - titleStyle: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), - showBorder: false, - ); - } -}