feat(libraries): add mobile browse options sheet

This commit is contained in:
edde746
2026-06-04 20:41:34 +02:00
parent aed420525c
commit 8201d8dcda
5 changed files with 371 additions and 198 deletions
@@ -19,6 +19,7 @@ class FiltersBottomSheet extends StatefulWidget {
final Function(Map<String, String>) onFiltersChanged;
final String serverId;
final String libraryKey;
final VoidCallback? onBack;
/// Optional pre-fetched values per filter name. When non-null the sheet
/// reads from this instead of calling `client.getFilterValues` — used
@@ -33,6 +34,7 @@ class FiltersBottomSheet extends StatefulWidget {
required this.onFiltersChanged,
required this.serverId,
required this.libraryKey,
this.onBack,
this.cachedValues,
});
@@ -182,7 +184,7 @@ class _FiltersBottomSheetState extends State<FiltersBottomSheet> {
return BottomSheetPageScaffold(
title: currentFilter?.title ?? t.libraries.filters,
icon: Symbols.filter_alt_rounded,
onBack: currentFilter != null ? _goBack : null,
onBack: currentFilter != null ? _goBack : widget.onBack,
action: currentFilter == null && _tempSelectedFilters.isNotEmpty
? FocusableButton(
onPressed: _clearFilters,
+29 -26
View File
@@ -313,6 +313,15 @@ class _LibrariesScreenState extends State<LibrariesScreen>
};
}
void _showBrowseOptionsForCurrentTab() {
if (_visibleTabs.isEmpty) return;
final index = tabController.index.clamp(0, _visibleTabs.length - 1).toInt();
if (_visibleTabs[index] != LibraryTabType.browse) return;
final tabState = _browseTabKey.currentState;
if (tabState == null) return;
(tabState as dynamic).showBrowseOptionsSheet();
}
/// Handle when a tab's data has finished loading
void _handleTabDataLoaded(int tabIndex) {
// Track that this tab has loaded
@@ -1003,6 +1012,24 @@ class _LibrariesScreenState extends State<LibrariesScreen>
final currentTabIndex = _visibleTabs.isEmpty ? 0 : tabController.index.clamp(0, _visibleTabs.length - 1).toInt();
final currentTabType = _visibleTabs.isEmpty ? null : _visibleTabs[currentTabIndex];
final useTvRecommendedBackdrop = PlatformDetector.isTV() && currentTabType == LibraryTabType.recommended;
final showBrowseOptionsAction =
selectedLibrary != null && PlatformDetector.isMobile(context) && currentTabType == LibraryTabType.browse;
List<FocusableAction> appBarActions() => [
if (allLibraries.isNotEmpty)
FocusableAction(
icon: Symbols.edit_rounded,
tooltip: t.libraries.manageLibraries,
onPressed: _showLibraryManagementSheet,
),
if (showBrowseOptionsAction)
FocusableAction(
icon: Symbols.tune_rounded,
tooltip: t.libraries.libraryOptions,
onPressed: _showBrowseOptionsForCurrentTab,
),
FocusableAction(icon: Symbols.refresh_rounded, tooltip: t.common.refresh, onPressed: _refreshSelectedLibraryTabs),
];
Widget appBar({required bool floating}) => DesktopSliverAppBar(
title: _buildAppBarTitle(visibleLibraries, selectedLibrary, groupByServer: groupByServerSetting),
@@ -1021,19 +1048,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
key: _actionBarKey,
onNavigateLeft: () => getTabChipFocusNode(_visibleTabs.length - 1).requestFocus(),
onNavigateDown: _focusCurrentTab,
actions: [
if (allLibraries.isNotEmpty)
FocusableAction(
icon: Symbols.edit_rounded,
tooltip: t.libraries.manageLibraries,
onPressed: _showLibraryManagementSheet,
),
FocusableAction(
icon: Symbols.refresh_rounded,
tooltip: t.common.refresh,
onPressed: _refreshSelectedLibraryTabs,
),
],
actions: appBarActions(),
),
],
);
@@ -1064,19 +1079,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
key: _actionBarKey,
onNavigateLeft: () => getTabChipFocusNode(_visibleTabs.length - 1).requestFocus(),
onNavigateDown: _focusCurrentTab,
actions: [
if (allLibraries.isNotEmpty)
FocusableAction(
icon: Symbols.edit_rounded,
tooltip: t.libraries.manageLibraries,
onPressed: _showLibraryManagementSheet,
),
FocusableAction(
icon: Symbols.refresh_rounded,
tooltip: t.common.refresh,
onPressed: _refreshSelectedLibraryTabs,
),
],
actions: appBarActions(),
),
],
),
+117 -58
View File
@@ -19,6 +19,7 @@ class SortBottomSheet extends StatefulWidget {
final bool isSortDescending;
final Function(MediaSort, bool) onSortChanged;
final VoidCallback? onClear;
final VoidCallback? onBack;
const SortBottomSheet({
super.key,
@@ -27,6 +28,7 @@ class SortBottomSheet extends StatefulWidget {
required this.isSortDescending,
required this.onSortChanged,
this.onClear,
this.onBack,
});
@override
@@ -105,6 +107,7 @@ class _SortBottomSheetState extends State<SortBottomSheet> {
children: [
BottomSheetHeader(
title: t.libraries.sortBy,
onBack: widget.onBack,
action: widget.onClear != null
? FocusableButton(
onPressed: _handleClear,
@@ -113,70 +116,126 @@ class _SortBottomSheetState extends State<SortBottomSheet> {
: null,
),
Flexible(
child: RadioGroup<MediaSort>(
groupValue: _currentSort,
onChanged: (value) {
if (value != null) _handleSortSelect(value);
},
child: ListView.builder(
controller: _scrollController,
primary: false,
shrinkWrap: true,
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,
primary: false,
shrinkWrap: true,
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 Focus(
key: index == 0 ? _firstItemKey : null,
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<MediaSort>(
focusNode: (widget.selectedSort?.key == sort.key || (widget.selectedSort == null && index == 0))
? _initialFocusNode
: null,
title: Text(sort.title),
value: sort,
secondary: Visibility(
visible: isSelected,
maintainAnimation: true,
maintainSize: true,
maintainState: true,
child: SegmentedButton<bool>(
showSelectedIcon: false,
segments: const [
ButtonSegment(value: false, icon: AppIcon(Symbols.arrow_upward_rounded, fill: 1, size: 16)),
ButtonSegment(value: true, icon: AppIcon(Symbols.arrow_downward_rounded, fill: 1, size: 16)),
],
selected: {_currentDescending},
onSelectionChanged: isSelected
? (Set<bool> newSelection) {
_handleDirectionChange(sort, newSelection.first);
}
: null,
),
return Focus(
key: index == 0 ? _firstItemKey : null,
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: FocusableListTile(
focusNode: (widget.selectedSort?.key == sort.key || (widget.selectedSort == null && index == 0))
? _initialFocusNode
: null,
leading: AppIcon(
isSelected ? Symbols.radio_button_checked_rounded : Symbols.radio_button_unchecked_rounded,
fill: 1,
),
title: Text(sort.title),
trailing: Visibility(
visible: isSelected,
maintainAnimation: true,
maintainSize: true,
maintainState: true,
child: SegmentedButton<bool>(
// Match FocusableListTile's dense visualDensity so the segment's min
// tap-target height equals ListTile's trailing-height cap. Without this
// the ~48dp button overflows the ~36dp cap from the top and the arrows
// render bottom-aligned instead of centered.
style: SegmentedButton.styleFrom(visualDensity: const VisualDensity(vertical: -3)),
showSelectedIcon: false,
segments: const [
ButtonSegment(value: false, label: _SortDirectionIcon(upward: true)),
ButtonSegment(value: true, label: _SortDirectionIcon(upward: false)),
],
selected: {_currentDescending},
onSelectionChanged: isSelected
? (Set<bool> newSelection) {
_handleDirectionChange(sort, newSelection.first);
}
: null,
),
),
);
},
),
onTap: () => _handleSortSelect(sort),
),
);
},
),
),
],
);
}
}
class _SortDirectionIcon extends StatelessWidget {
final bool upward;
const _SortDirectionIcon({required this.upward});
@override
Widget build(BuildContext context) {
return SizedBox.square(
dimension: 16,
child: CustomPaint(
painter: _SortDirectionArrowPainter(color: IconTheme.of(context).color, upward: upward),
),
);
}
}
class _SortDirectionArrowPainter extends CustomPainter {
final Color? color;
final bool upward;
const _SortDirectionArrowPainter({required this.color, required this.upward});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = color ?? Colors.black
..strokeWidth = 2.25
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round
..style = PaintingStyle.stroke;
final centerX = size.width / 2;
final top = size.height * 0.14;
final bottom = size.height * 0.86;
final headY = upward ? top : bottom;
final tailY = upward ? bottom : top;
final wingY = upward ? top + size.height * 0.28 : bottom - size.height * 0.28;
final wingOffset = size.width * 0.28;
final path = Path()
..moveTo(centerX, tailY)
..lineTo(centerX, headY)
..moveTo(centerX - wingOffset, wingY)
..lineTo(centerX, headY)
..lineTo(centerX + wingOffset, wingY);
canvas.drawPath(path, paint);
}
@override
bool shouldRepaint(covariant _SortDirectionArrowPainter oldDelegate) =>
color != oldDelegate.color || upward != oldDelegate.upward;
}
+218 -112
View File
@@ -37,6 +37,7 @@ import '../../../widgets/focusable_filter_chip.dart';
import '../../../widgets/loading_indicator_box.dart';
import '../../../widgets/media_grid_delegate.dart';
import '../../../widgets/media_card_list_layout.dart';
import '../../../widgets/bottom_sheet_page_scaffold.dart';
import '../../../widgets/overlay_sheet.dart';
import '../../../mixins/library_tab_focus_mixin.dart';
import '../folder_tree_view.dart';
@@ -426,10 +427,22 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
/// Focus the chips bar (for navigating from tab bar to content).
/// Called by libraries screen when pressing DOWN on tab bar.
void focusChipsBar() {
if (_usesMobileBrowseOptions) {
focusFirstItem();
return;
}
lastFocusedGridIndex = null;
_groupingChipFocusNode.requestFocus();
}
/// Show the mobile browse options sheet from the parent app bar.
void showBrowseOptionsSheet() {
if (!mounted) return;
SelectKeyUpSuppressor.suppressSelectUntilKeyUp();
final controller = OverlaySheetController.of(context);
controller.show(builder: (sheetContext) => _buildBrowseOptionsSheet(sheetContext));
}
/// Reset transient browse state before loading a different library.
void _resetForFullReload() {
_scrollActivityTimer?.cancel();
@@ -738,9 +751,50 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
return mapUnexpectedErrorToMessage(error, context: t.libraries.content);
}
Widget _buildBrowseOptionsSheet(BuildContext sheetContext) {
final controller = OverlaySheetController.of(sheetContext);
return BottomSheetPageScaffold(
title: t.libraries.libraryOptions,
icon: Symbols.tune_rounded,
shrinkWrap: true,
child: ListView(
primary: false,
shrinkWrap: true,
padding: const EdgeInsets.symmetric(vertical: 8),
children: [
FocusableListTile(
leading: const AppIcon(Symbols.category_rounded, fill: 1),
title: Text(t.libraries.groupings.title),
subtitle: Text(_getGroupingLabel(_selectedGrouping)),
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
onTap: () => _showGroupingOptionsPage(controller),
),
if (_isFiltersChipVisible)
FocusableListTile(
leading: const AppIcon(Symbols.filter_alt_rounded, fill: 1),
title: Text(
_selectedFilters.isEmpty
? t.libraries.filters
: t.libraries.filtersWithCount(count: _selectedFilters.length),
),
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
onTap: () => _showFiltersOptionsPage(controller),
),
if (_isSortChipVisible)
FocusableListTile(
leading: const AppIcon(Symbols.sort_rounded, fill: 1),
title: Text(t.libraries.sort),
subtitle: _selectedSort == null ? null : Text(_selectedSort!.title),
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
onTap: () => _showSortOptionsPage(controller),
),
],
),
);
}
void _showGroupingBottomSheet() {
SelectKeyUpSuppressor.suppressSelectUntilKeyUp();
final options = _getGroupingOptions();
final controller = OverlaySheetController.of(context);
controller
.show<String>(
@@ -759,119 +813,164 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
),
Flexible(
child: SingleChildScrollView(
child: Column(
mainAxisSize: .min,
children: options.map((grouping) {
final isSelected = _selectedGrouping == grouping;
return FocusableListTile(
key: ValueKey(grouping),
dense: true,
leading: AppIcon(
isSelected ? Symbols.radio_button_checked_rounded : Symbols.radio_button_unchecked_rounded,
fill: 1,
),
title: Text(_getGroupingLabel(grouping)),
onTap: () => controller.close(grouping),
);
}).toList(),
),
child: Column(mainAxisSize: .min, children: _buildGroupingTiles((value) => controller.close(value))),
),
),
],
),
)
.then((value) {
if (!mounted || value == null || value == _selectedGrouping) return;
setState(() {
_selectedGrouping = value;
});
StorageService.getInstance().then((storage) {
storage.saveLibraryGrouping(widget.library.globalKey, value);
});
_loadItems();
_loadFirstCharacters();
});
.then(_handleGroupingSelection);
}
void _showFiltersBottomSheet() {
void _showGroupingOptionsPage(OverlaySheetController controller) {
SelectKeyUpSuppressor.suppressSelectUntilKeyUp();
OverlaySheetController.of(context).show(
builder: (context) => FiltersBottomSheet(
filters: _filters,
selectedFilters: _selectedFilters,
serverId: widget.library.serverId!,
libraryKey: widget.library.globalKey,
// Pre-populated values arrive only from backends that bundle them
// with the category listing (Jellyfin's `/Items/Filters`). The empty
// map for Plex libraries falls through to lazy `getFilterValues`.
cachedValues: _jellyfinFilterValues.isEmpty ? null : _jellyfinFilterValues,
onFiltersChanged: (filters) async {
setState(() {
_selectedFilters.clear();
_selectedFilters.addAll(filters);
});
controller
.push<String>(
builder: (_) =>
_buildGroupingBottomSheet(onBack: () => controller.pop(), onSelected: (value) => controller.close(value)),
)
.then(_handleGroupingSelection);
}
// Save filters to storage
final storage = await StorageService.getInstance();
await storage.saveLibraryFilters(filters, sectionId: widget.library.globalKey);
unawaited(_loadItems());
unawaited(_loadFirstCharacters());
},
Widget _buildGroupingBottomSheet({required ValueChanged<String> onSelected, VoidCallback? onBack}) {
return BottomSheetPageScaffold(
title: t.libraries.groupings.title,
icon: Symbols.category_rounded,
onBack: onBack,
shrinkWrap: true,
child: ListView(
primary: false,
shrinkWrap: true,
padding: const EdgeInsets.symmetric(vertical: 8),
children: _buildGroupingTiles(onSelected),
),
);
}
List<Widget> _buildGroupingTiles(ValueChanged<String> onSelected) {
final options = _getGroupingOptions();
return options.map((grouping) {
final isSelected = _selectedGrouping == grouping;
return FocusableListTile(
key: ValueKey(grouping),
dense: true,
leading: AppIcon(
isSelected ? Symbols.radio_button_checked_rounded : Symbols.radio_button_unchecked_rounded,
fill: 1,
),
title: Text(_getGroupingLabel(grouping)),
onTap: () => onSelected(grouping),
);
}).toList();
}
void _handleGroupingSelection(String? value) {
if (!mounted || value == null || value == _selectedGrouping) return;
setState(() {
_selectedGrouping = value;
});
StorageService.getInstance().then((storage) {
storage.saveLibraryGrouping(widget.library.globalKey, value);
});
_loadItems();
_loadFirstCharacters();
}
void _showFiltersBottomSheet() {
SelectKeyUpSuppressor.suppressSelectUntilKeyUp();
OverlaySheetController.of(context).show(builder: (_) => _buildFiltersBottomSheet());
}
void _showFiltersOptionsPage(OverlaySheetController controller) {
SelectKeyUpSuppressor.suppressSelectUntilKeyUp();
controller.push(builder: (_) => _buildFiltersBottomSheet(onBack: () => controller.pop()));
}
Widget _buildFiltersBottomSheet({VoidCallback? onBack}) {
return FiltersBottomSheet(
filters: _filters,
selectedFilters: _selectedFilters,
serverId: widget.library.serverId!,
libraryKey: widget.library.globalKey,
onBack: onBack,
// Pre-populated values arrive only from backends that bundle them
// with the category listing (Jellyfin's `/Items/Filters`). The empty
// map for Plex libraries falls through to lazy `getFilterValues`.
cachedValues: _jellyfinFilterValues.isEmpty ? null : _jellyfinFilterValues,
onFiltersChanged: (filters) async {
setState(() {
_selectedFilters.clear();
_selectedFilters.addAll(filters);
});
// Save filters to storage
final storage = await StorageService.getInstance();
await storage.saveLibraryFilters(filters, sectionId: widget.library.globalKey);
unawaited(_loadItems());
unawaited(_loadFirstCharacters());
},
);
}
void _showSortBottomSheet() {
final controller = OverlaySheetController.of(context);
_openSortBottomSheet((builder) => controller.show(builder: builder));
}
void _showSortOptionsPage(OverlaySheetController controller) {
_openSortBottomSheet((builder) => controller.push(builder: builder), onBack: () => controller.pop());
}
void _openSortBottomSheet(Future<dynamic> Function(WidgetBuilder builder) open, {VoidCallback? onBack}) {
SelectKeyUpSuppressor.suppressSelectUntilKeyUp();
// Track pending state in local variables so the callbacks don't trigger
// setState/_loadItems while the sheet is open (which would steal focus).
MediaSort? pendingSort = _selectedSort;
bool pendingDescending = _isSortDescending;
bool pendingCleared = false;
OverlaySheetController.of(context)
.show(
builder: (context) => SortBottomSheet(
sortOptions: _sortOptions,
selectedSort: _selectedSort,
isSortDescending: _isSortDescending,
onSortChanged: (sort, descending) {
pendingSort = sort;
pendingDescending = descending;
pendingCleared = false;
},
onClear: () {
pendingSort = null;
pendingDescending = false;
pendingCleared = true;
},
),
)
.then((_) {
if (!mounted) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
if (pendingCleared) {
setState(() {
_selectedSort = null;
_isSortDescending = false;
});
_loadItems();
_loadFirstCharacters();
} else if (pendingSort != null &&
(pendingSort!.key != _selectedSort?.key || pendingDescending != _isSortDescending)) {
setState(() {
_selectedSort = pendingSort;
_isSortDescending = pendingDescending;
});
StorageService.getInstance().then((storage) {
storage.saveLibrarySort(widget.library.globalKey, pendingSort!.key, descending: pendingDescending);
});
_loadItems();
_loadFirstCharacters();
}
open(
(context) => SortBottomSheet(
sortOptions: _sortOptions,
selectedSort: _selectedSort,
isSortDescending: _isSortDescending,
onBack: onBack,
onSortChanged: (sort, descending) {
pendingSort = sort;
pendingDescending = descending;
pendingCleared = false;
},
onClear: () {
pendingSort = null;
pendingDescending = false;
pendingCleared = true;
},
),
).then((_) {
if (!mounted) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
if (pendingCleared) {
setState(() {
_selectedSort = null;
_isSortDescending = false;
});
});
_loadItems();
_loadFirstCharacters();
} else if (pendingSort != null &&
(pendingSort!.key != _selectedSort?.key || pendingDescending != _isSortDescending)) {
setState(() {
_selectedSort = pendingSort;
_isSortDescending = pendingDescending;
});
StorageService.getInstance().then((storage) {
storage.saveLibrarySort(widget.library.globalKey, pendingSort!.key, descending: pendingDescending);
});
_loadItems();
_loadFirstCharacters();
}
});
});
}
/// Navigate focus from chips down to the grid item.
@@ -956,8 +1055,12 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
}
}
/// Navigate focus from grid up to the chips bar
/// Navigate focus from grid up to the chips bar, or the tab bar on mobile.
void _navigateToChips() {
if (_usesMobileBrowseOptions) {
widget.onBack?.call();
return;
}
_groupingChipFocusNode.requestFocus();
}
@@ -974,6 +1077,9 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
/// Whether the device is a phone (not tablet/desktop/TV).
bool _isPhone(BuildContext context) => PlatformDetector.isPhone(context);
/// Mobile uses a top-bar options action instead of inline browse chips.
bool get _usesMobileBrowseOptions => PlatformDetector.isMobile(context);
/// The letter currently visible at the top of the grid, determined by
/// how many items we've scrolled past relative to the API's cumulative
/// firstCharacter counts.
@@ -1094,8 +1200,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
}
/// Compute the first visible item index from a scroll offset.
/// Chips bar is the first sliver (height = _chipsBarHeight) followed by the
/// grid's own top padding before the first row.
/// The inline chips bar, when present, is followed by the grid's own top
/// padding before the first row.
int _itemIndexFromScrollOffset(double offset) {
return _scrollMetrics.itemIndexFromScrollOffset(
offset,
@@ -1104,7 +1210,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
);
}
double get _contentStartScrollOffset => _chipsBarHeight + _effectiveTopPadding;
double get _contentStartScrollOffset => (_usesMobileBrowseOptions ? 0.0 : _chipsBarHeight) + _effectiveTopPadding;
/// Handle a tap on the letter at [targetIndex] in the alpha bar. The
/// active [LibraryAlphaBarStrategy] owns the per-backend behaviour and
@@ -1150,7 +1256,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
_loadItems();
}
/// Scroll the current layout so that [index] is visible just below the chips bar
/// Scroll the current layout so that [index] is visible just below the chrome.
void _scrollToItemIndex(int index) {
final pos = _innerPosition;
if (!_scrollMetrics.isUsable || pos == null) {
@@ -1158,8 +1264,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
return;
}
// Position the target row at the top of the viewport. Chips and the grid's
// top padding both precede the items in scroll coordinates.
// Position the target row at the top of the viewport. Inline chips, when
// present, and grid top padding both precede items in scroll coordinates.
final offset = _scrollMetrics.scrollOffsetForItemIndex(index, contentStartOffset: _contentStartScrollOffset);
final gen = _jumpScrollGeneration;
@@ -1189,12 +1295,11 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
Widget build(BuildContext context) {
super.build(context); // Required for AutomaticKeepAliveClientMixin
// Chips are inline as a floating sliver in the inner scroll. The alpha
// jump bar is the only overlay; we offset it by the typical app bar height
// so it isn't obscured by the floating outer header. Using MediaQuery
// (not the absorber handle) avoids rebuilding during layout — listening to
// the handle from a builder fires notifyListeners during the build phase
// and triggers a setState-in-build assertion.
// The alpha jump bar is the only overlay; we offset it by the typical app
// bar height so it isn't obscured by the floating outer header. Using
// MediaQuery (not the absorber handle) avoids rebuilding during layout —
// listening to the handle from a builder fires notifyListeners during the
// build phase and triggers a setState-in-build assertion.
final media = MediaQuery.of(context);
final overlayTopPadding = media.padding.top + kToolbarHeight;
@@ -1237,7 +1342,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
);
}
/// Builds the scrollable content with chips, then either folder tree or grid/list.
/// Builds the scrollable content with optional chips, then folder tree or grid/list.
Widget _buildScrollableContent() {
final isFolders = _selectedGrouping == 'folders';
@@ -1282,11 +1387,12 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
// Floating chips: scroll off with content but snap back into view
// on upward direction reversal, matching the outer floating
// SliverAppBar's behavior.
SliverPersistentHeader(
floating: true,
pinned: false,
delegate: _ChipsBarDelegate(builder: (_) => _buildChipsBar(), height: _chipsBarHeight),
),
if (!_usesMobileBrowseOptions)
SliverPersistentHeader(
floating: true,
pinned: false,
delegate: _ChipsBarDelegate(builder: (_) => _buildChipsBar(), height: _chipsBarHeight),
),
..._buildContentSlivers(),
],
),
+4 -1
View File
@@ -19,6 +19,7 @@ class BottomSheetPageScaffold extends StatelessWidget {
final bool showHeaderBorder;
final bool showHeaderDivider;
final FocusNode? closeFocusNode;
final bool shrinkWrap;
const BottomSheetPageScaffold({
super.key,
@@ -35,11 +36,13 @@ class BottomSheetPageScaffold extends StatelessWidget {
this.showHeaderBorder = true,
this.showHeaderDivider = false,
this.closeFocusNode,
this.shrinkWrap = false,
});
@override
Widget build(BuildContext context) {
Widget content = Column(
mainAxisSize: shrinkWrap ? MainAxisSize.min : MainAxisSize.max,
children: [
BottomSheetHeader(
title: title,
@@ -55,7 +58,7 @@ class BottomSheetPageScaffold extends StatelessWidget {
closeFocusNode: closeFocusNode,
),
if (showHeaderDivider) Divider(color: Theme.of(context).dividerColor, height: 1),
Expanded(child: child),
if (shrinkWrap) child else Expanded(child: child),
],
);