refactor(ui): share bottom sheet scaffold

This commit is contained in:
edde746
2026-05-08 06:50:34 +02:00
parent 653fada5dc
commit 19dea4b31e
4 changed files with 244 additions and 212 deletions
+153 -154
View File
@@ -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<FiltersBottomSheet> {
_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<FiltersBottomSheet> {
_filterValues = [];
_isLoadingValues = false;
});
_requestInitialFocus();
}
}
@@ -134,6 +136,26 @@ class _FiltersBottomSheetState extends State<FiltersBottomSheet> {
_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<FiltersBottomSheet> {
@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),
);
},
);
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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,
);
}
}