From 1390376f093f5a473f9695b08a0ff6ee4b0ed740 Mon Sep 17 00:00:00 2001 From: zarzet Date: Tue, 28 Jul 2026 17:25:14 +0700 Subject: [PATCH] fix(album): overlay selection bar above navbar --- lib/screens/album_screen.dart | 143 +++++++++++++++-------- lib/screens/queue_tab_selection.dart | 4 +- lib/screens/queue_tab_widgets.dart | 54 +-------- lib/widgets/selection_bottom_bar.dart | 51 ++++++++ test/track_list_tile_selection_test.dart | 49 ++++++++ 5 files changed, 202 insertions(+), 99 deletions(-) diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index f889812d..e878aa17 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -82,6 +82,9 @@ class _AlbumScreenState extends ConsumerState String? _headerVideoUrl; String? _headerImageUrl; List _audioTraits = const []; + OverlayEntry? _selectionOverlayEntry; + List _selectionOverlayTracks = const []; + double _selectionOverlayBottomPadding = 0; String _effectiveMetadataProviderIdFromAlbumId() { if (widget.extensionId != null && widget.extensionId!.isNotEmpty) { @@ -130,6 +133,24 @@ class _AlbumScreenState extends ConsumerState } } + @override + void dispose() { + _hideSelectionOverlay(); + super.dispose(); + } + + @override + void exitSelectionMode() { + super.exitSelectionMode(); + _hideSelectionOverlay(); + } + + @override + void toggleSelection(String itemId) { + super.toggleSelection(itemId); + if (!isSelectionMode) _hideSelectionOverlay(); + } + Future _fetchTracks() async { setState(() => _isLoading = true); try { @@ -333,6 +354,13 @@ class _AlbumScreenState extends ConsumerState _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 }, 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 tracks[index], ]; + void _hideSelectionOverlay() { + _selectionOverlayEntry?.remove(); + _selectionOverlayEntry = null; + } + + void _syncSelectionOverlay({ + required List 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 tracks, double bottomPadding, @@ -680,7 +731,7 @@ class _AlbumScreenState extends ConsumerState 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), diff --git a/lib/screens/queue_tab_selection.dart b/lib/screens/queue_tab_selection.dart index 231574f2..62eae62d 100644 --- a/lib/screens/queue_tab_selection.dart +++ b/lib/screens/queue_tab_selection.dart @@ -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( diff --git a/lib/screens/queue_tab_widgets.dart b/lib/screens/queue_tab_widgets.dart index 99749471..7ef342a0 100644 --- a/lib/screens/queue_tab_widgets.dart +++ b/lib/screens/queue_tab_widgets.dart @@ -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 _slideAnimation; - late final Animation _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( - begin: const Offset(0, 0.08), - end: Offset.zero, - ).animate(curve); - _fadeAnimation = Tween(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), + ), ); } } diff --git a/lib/widgets/selection_bottom_bar.dart b/lib/widgets/selection_bottom_bar.dart index 382df44f..6c935581 100644 --- a/lib/widgets/selection_bottom_bar.dart +++ b/lib/widgets/selection_bottom_bar.dart @@ -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 createState() => + _AnimatedSelectionBottomBarState(); +} + +class _AnimatedSelectionBottomBarState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + late final Animation _slideAnimation; + late final Animation _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( + begin: const Offset(0, 0.08), + end: Offset.zero, + ).animate(curve); + _fadeAnimation = Tween(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]. diff --git a/test/track_list_tile_selection_test.dart b/test/track_list_tile_selection_test.dart index 3299a5b3..2eba732a 100644 --- a/test/track_list_tile_selection_test.dart +++ b/test/track_list_tile_selection_test.dart @@ -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); + }); }