mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-02 09:08:35 +02:00
fix(album): overlay selection bar above navbar
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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].
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user