feat(collections): unify selection-aware screens

This commit is contained in:
zarzet
2026-07-29 17:13:21 +07:00
parent 78670a6ea3
commit 4ee7558bca
7 changed files with 412 additions and 452 deletions
-65
View File
@@ -1,65 +0,0 @@
import 'package:flutter/material.dart';
/// Shared body for album-style screens: a [PopScope] that exits selection
/// mode instead of popping, a scrollable track list, and a bottom-select bar
/// that slides in over it.
class AlbumScaffoldBody extends StatelessWidget {
const AlbumScaffoldBody({
super.key,
required this.scrollController,
required this.isSelectionMode,
required this.onExitSelectionMode,
required this.appBar,
required this.trackList,
required this.bottomBar,
required this.bottomInset,
required this.bottomPadding,
});
final ScrollController scrollController;
final bool isSelectionMode;
final VoidCallback onExitSelectionMode;
final Widget appBar;
final Widget trackList;
final Widget bottomBar;
final double bottomInset;
final double bottomPadding;
@override
Widget build(BuildContext context) {
return PopScope(
canPop: !isSelectionMode,
onPopInvokedWithResult: (didPop, result) {
if (!didPop && isSelectionMode) {
onExitSelectionMode();
}
},
child: Scaffold(
body: Stack(
children: [
CustomScrollView(
controller: scrollController,
slivers: [
appBar,
trackList,
SliverToBoxAdapter(
child: SizedBox(height: isSelectionMode ? 120 : 32),
),
SliverToBoxAdapter(child: SizedBox(height: bottomInset)),
],
),
AnimatedPositioned(
duration: const Duration(milliseconds: 250),
curve: Curves.easeOutCubic,
left: 0,
right: 0,
bottom: isSelectionMode ? 0 : -(200 + bottomPadding),
child: bottomBar,
),
],
),
),
);
}
}
+124
View File
@@ -0,0 +1,124 @@
import 'package:flutter/material.dart';
import 'package:spotiflac_android/widgets/selection_bottom_bar.dart';
/// Shared shell for every track-collection screen: album, playlist, local
/// album, downloaded album and library folders.
///
/// Before this existed each of those screens built its own `Scaffold` +
/// `PopScope` + selection plumbing, which is why the same four screens had three
/// different selection-bar mechanisms and why the playlist screen ended up with
/// none at all. Screens now supply their header, their content slivers and the
/// bar contents; everything else is shared:
///
/// * back gesture exits selection mode instead of popping the route,
/// * the selection bar is mounted in the root overlay so it floats above the
/// shell navigation bar,
/// * trailing space is reserved so the last row stays reachable while the bar
/// is up.
class CollectionScaffold extends StatefulWidget {
const CollectionScaffold({
super.key,
required this.scrollController,
required this.isSelectionMode,
required this.onExitSelectionMode,
required this.appBar,
required this.slivers,
required this.bottomInset,
this.selectionBar,
this.backgroundColor,
});
final ScrollController scrollController;
final bool isSelectionMode;
final VoidCallback onExitSelectionMode;
/// The collapsing header sliver.
final Widget appBar;
/// Content slivers below the header (track list, error card, footer, ...).
final List<Widget> slivers;
/// Bottom inset needed to clear the shell navigation bar.
final double bottomInset;
/// Rebuilt by the caller each frame; shown only while [isSelectionMode] is
/// true. Null means the screen has no multi-select.
final Widget? selectionBar;
final Color? backgroundColor;
@override
State<CollectionScaffold> createState() => _CollectionScaffoldState();
}
class _CollectionScaffoldState extends State<CollectionScaffold> {
final SelectionOverlayController _selectionOverlay =
SelectionOverlayController();
final GlobalKey _selectionBarKey = GlobalKey();
// Used for the first frame only. Once the overlay has laid out, the spacer
// tracks its real height so multi-row actions and large text cannot cover the
// final collection rows.
double _selectionBarHeight = 160;
@override
void dispose() {
_selectionOverlay.dispose();
super.dispose();
}
void _syncSelectionOverlay() {
if (!mounted) return;
final bar = widget.selectionBar;
if (!widget.isSelectionMode || bar == null) {
_selectionOverlay.hide();
return;
}
_selectionOverlay.show(
context,
(_) => KeyedSubtree(key: _selectionBarKey, child: bar),
);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !widget.isSelectionMode) return;
final height = _selectionBarKey.currentContext?.size?.height;
if (height == null || height <= 0) return;
if ((height - _selectionBarHeight).abs() < 0.5) return;
setState(() => _selectionBarHeight = height);
});
}
@override
Widget build(BuildContext context) {
if (widget.isSelectionMode || _selectionOverlay.isVisible) {
WidgetsBinding.instance.addPostFrameCallback(
(_) => _syncSelectionOverlay(),
);
}
return PopScope(
canPop: !widget.isSelectionMode,
onPopInvokedWithResult: (didPop, result) {
if (!didPop && widget.isSelectionMode) {
widget.onExitSelectionMode();
}
},
child: Scaffold(
backgroundColor: widget.backgroundColor,
body: CustomScrollView(
controller: widget.scrollController,
slivers: [
widget.appBar,
...widget.slivers,
SliverToBoxAdapter(
child: SizedBox(
key: const ValueKey('collection-selection-spacer'),
height: widget.isSelectionMode ? _selectionBarHeight : 32,
),
),
SliverToBoxAdapter(child: SizedBox(height: widget.bottomInset)),
],
),
),
);
}
}