fix(album): overlay selection bar above navbar

This commit is contained in:
zarzet
2026-07-28 17:25:14 +07:00
parent 650c360eb9
commit 1390376f09
5 changed files with 202 additions and 99 deletions
+97 -46
View File
@@ -82,6 +82,9 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen>
String? _headerVideoUrl;
String? _headerImageUrl;
List<String> _audioTraits = const [];
OverlayEntry? _selectionOverlayEntry;
List<Track> _selectionOverlayTracks = const [];
double _selectionOverlayBottomPadding = 0;
String _effectiveMetadataProviderIdFromAlbumId() {
if (widget.extensionId != null && widget.extensionId!.isNotEmpty) {
@@ -130,6 +133,24 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen>
}
}
@override
void dispose() {
_hideSelectionOverlay();
super.dispose();
}
@override
void exitSelectionMode() {
super.exitSelectionMode();
_hideSelectionOverlay();
}
@override
void toggleSelection(String itemId) {
super.toggleSelection(itemId);
if (!isSelectionMode) _hideSelectionOverlay();
}
Future<void> _fetchTracks() async {
setState(() => _isLoading = true);
try {
@@ -333,6 +354,13 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen>
_trackSelectionId(tracks[index], index),
});
if (isSelectionMode || _selectionOverlayEntry != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_syncSelectionOverlay(tracks: tracks, bottomPadding: bottomPadding);
});
}
return PopScope(
canPop: !isSelectionMode,
onPopInvokedWithResult: (didPop, result) {
@@ -340,51 +368,31 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen>
},
child: Scaffold(
backgroundColor: pageBackgroundColor,
body: Stack(
children: [
CustomScrollView(
controller: scrollController,
slivers: [
_buildAppBar(context, colorScheme, pageBackgroundColor),
if (_isLoading)
const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(16),
child: AlbumTrackListSkeleton(itemCount: 10),
),
),
if (_error != null)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: ErrorCard(
error: _error!,
colorScheme: colorScheme,
),
),
),
if (!_isLoading && _error == null && tracks.isNotEmpty) ...[
_buildTrackList(context, colorScheme, tracks),
_buildAlbumFooter(context, colorScheme, tracks),
],
SliverToBoxAdapter(
child: SizedBox(
height: (isSelectionMode ? 140 : 32) + bottomInset,
),
body: CustomScrollView(
controller: scrollController,
slivers: [
_buildAppBar(context, colorScheme, pageBackgroundColor),
if (_isLoading)
const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(16),
child: AlbumTrackListSkeleton(itemCount: 10),
),
],
),
AnimatedPositioned(
duration: const Duration(milliseconds: 250),
curve: Curves.easeOutCubic,
left: 0,
right: 0,
bottom: isSelectionMode ? 0 : -(200 + bottomPadding),
child: _buildSelectionBottomBar(
context,
colorScheme,
tracks,
bottomPadding,
),
if (_error != null)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: ErrorCard(error: _error!, colorScheme: colorScheme),
),
),
if (!_isLoading && _error == null && tracks.isNotEmpty) ...[
_buildTrackList(context, colorScheme, tracks),
_buildAlbumFooter(context, colorScheme, tracks),
],
SliverToBoxAdapter(
child: SizedBox(
height: (isSelectionMode ? 140 : 32) + bottomInset,
),
),
],
@@ -651,8 +659,51 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen>
tracks[index],
];
void _hideSelectionOverlay() {
_selectionOverlayEntry?.remove();
_selectionOverlayEntry = null;
}
void _syncSelectionOverlay({
required List<Track> tracks,
required double bottomPadding,
}) {
if (!isSelectionMode) {
_hideSelectionOverlay();
return;
}
_selectionOverlayTracks = tracks;
_selectionOverlayBottomPadding = bottomPadding;
if (_selectionOverlayEntry != null) {
_selectionOverlayEntry!.markNeedsBuild();
return;
}
final overlay = Overlay.of(context, rootOverlay: true);
_selectionOverlayEntry = OverlayEntry(
builder: (overlayContext) => Positioned(
left: 0,
right: 0,
bottom: 0,
child: AnimatedSelectionBottomBar(
child: Material(
color: Colors.transparent,
child: _buildSelectionBottomBar(
overlayContext,
Theme.of(overlayContext).colorScheme,
_selectionOverlayTracks,
_selectionOverlayBottomPadding,
),
),
),
),
);
overlay.insert(_selectionOverlayEntry!);
}
Widget _buildSelectionBottomBar(
BuildContext context,
BuildContext barContext,
ColorScheme colorScheme,
List<Track> tracks,
double bottomPadding,
@@ -680,7 +731,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen>
width: double.infinity,
child: SelectionActionButton(
icon: Icons.download_rounded,
label: '${context.l10n.dialogDownload} ($selectedCount)',
label: '${barContext.l10n.dialogDownload} ($selectedCount)',
onPressed: selectedCount == 0
? null
: () => _downloadSelected(context, tracks),
+2 -2
View File
@@ -77,7 +77,7 @@ extension _QueueTabSelectionActions on _QueueTabState {
left: 0,
right: 0,
bottom: 0,
child: _AnimatedOverlayBottomBar(
child: AnimatedSelectionBottomBar(
child: Material(
color: Colors.transparent,
child: _buildSelectionBottomBar(
@@ -127,7 +127,7 @@ extension _QueueTabSelectionActions on _QueueTabState {
left: 0,
right: 0,
bottom: 0,
child: _AnimatedOverlayBottomBar(
child: AnimatedSelectionBottomBar(
child: Material(
color: Colors.transparent,
child: _buildPlaylistSelectionBottomBar(
+3 -51
View File
@@ -90,57 +90,9 @@ class _FilterChip extends StatelessWidget {
onSelected: (_) => onTap(),
showCheckmark: false,
backgroundColor: settingsGroupColor(context),
side: BorderSide(color: colorScheme.outlineVariant.withValues(alpha: 0.6)),
);
}
}
class _AnimatedOverlayBottomBar extends StatefulWidget {
final Widget child;
const _AnimatedOverlayBottomBar({required this.child});
@override
State<_AnimatedOverlayBottomBar> createState() =>
_AnimatedOverlayBottomBarState();
}
class _AnimatedOverlayBottomBarState extends State<_AnimatedOverlayBottomBar>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final Animation<Offset> _slideAnimation;
late final Animation<double> _fadeAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 240),
);
final curve = CurvedAnimation(
parent: _controller,
curve: Curves.easeOutCubic,
);
_slideAnimation = Tween<Offset>(
begin: const Offset(0, 0.08),
end: Offset.zero,
).animate(curve);
_fadeAnimation = Tween<double>(begin: 0, end: 1).animate(curve);
_controller.forward();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return FadeTransition(
opacity: _fadeAnimation,
child: SlideTransition(position: _slideAnimation, child: widget.child),
side: BorderSide(
color: colorScheme.outlineVariant.withValues(alpha: 0.6),
),
);
}
}
+51
View File
@@ -1,6 +1,57 @@
import 'package:flutter/material.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
/// Entrance animation shared by selection bars mounted in the root overlay.
class AnimatedSelectionBottomBar extends StatefulWidget {
const AnimatedSelectionBottomBar({super.key, required this.child});
final Widget child;
@override
State<AnimatedSelectionBottomBar> createState() =>
_AnimatedSelectionBottomBarState();
}
class _AnimatedSelectionBottomBarState extends State<AnimatedSelectionBottomBar>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final Animation<Offset> _slideAnimation;
late final Animation<double> _fadeAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 240),
);
final curve = CurvedAnimation(
parent: _controller,
curve: Curves.easeOutCubic,
);
_slideAnimation = Tween<Offset>(
begin: const Offset(0, 0.08),
end: Offset.zero,
).animate(curve);
_fadeAnimation = Tween<double>(begin: 0, end: 1).animate(curve);
_controller.forward();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return FadeTransition(
opacity: _fadeAnimation,
child: SlideTransition(position: _slideAnimation, child: widget.child),
);
}
}
/// Shared chrome for the multi-select bottom bar: rounded surface, drag
/// handle, close button, "N selected" header and select-all toggle.
/// The screen-specific action buttons go in [children].
+49
View File
@@ -4,7 +4,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/l10n/app_localizations.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/screens/album_screen.dart';
import 'package:spotiflac_android/widgets/animation_utils.dart';
import 'package:spotiflac_android/widgets/selection_bottom_bar.dart';
import 'package:spotiflac_android/widgets/track_detail_actions.dart';
import 'package:spotiflac_android/widgets/track_list_tile.dart';
@@ -95,4 +97,51 @@ void main() {
expect(enteredSelection, isTrue);
});
testWidgets('album selection bar is mounted in front of the shell navbar', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(430, 900));
addTearDown(() => tester.binding.setSurfaceSize(null));
var navbarTaps = 0;
await tester.pumpWidget(
ProviderScope(
child: MaterialApp(
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
extendBody: true,
body: const AlbumScreen(
albumId: 'album-1',
albumName: 'Album',
artistName: 'Artist',
tracks: [track],
),
bottomNavigationBar: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => navbarTaps++,
child: const SizedBox(height: 80),
),
),
),
),
);
await tester.pumpAndSettle();
await tester.ensureVisible(find.text('Selected song'));
await tester.longPress(find.text('Selected song'));
await tester.pumpAndSettle();
expect(find.byType(SelectionBottomBar), findsOneWidget);
await tester.tapAt(const Offset(2, 898));
await tester.pump();
expect(navbarTaps, 0);
});
}