diff --git a/lib/screens/queue_tab_selection.dart b/lib/screens/queue_tab_selection.dart index 62eae62d..8e10cb37 100644 --- a/lib/screens/queue_tab_selection.dart +++ b/lib/screens/queue_tab_selection.dart @@ -45,8 +45,7 @@ extension _QueueTabSelectionActions on _QueueTabState { } void _hideSelectionOverlay() { - _selectionOverlayEntry?.remove(); - _selectionOverlayEntry = null; + _selectionOverlay.hide(); } void _syncSelectionOverlay({ @@ -63,40 +62,19 @@ extension _QueueTabSelectionActions on _QueueTabState { _selectionOverlayItems = items; _selectionOverlayBottomPadding = bottomPadding; - - if (_selectionOverlayEntry != null) { - _selectionOverlayEntry!.markNeedsBuild(); - return; - } - - final overlay = Overlay.of(context, rootOverlay: true); - _selectionOverlayEntry = OverlayEntry( - builder: (overlayContext) { - final colorScheme = Theme.of(context).colorScheme; - return Positioned( - left: 0, - right: 0, - bottom: 0, - child: AnimatedSelectionBottomBar( - child: Material( - color: Colors.transparent, - child: _buildSelectionBottomBar( - context, - colorScheme, - _selectionOverlayItems, - _selectionOverlayBottomPadding, - ), - ), - ), - ); - }, + _selectionOverlay.show( + context, + (_) => _buildSelectionBottomBar( + context, + Theme.of(context).colorScheme, + _selectionOverlayItems, + _selectionOverlayBottomPadding, + ), ); - overlay.insert(_selectionOverlayEntry!); } void _hidePlaylistSelectionOverlay() { - _playlistSelectionOverlayEntry?.remove(); - _playlistSelectionOverlayEntry = null; + _playlistSelectionOverlay.hide(); } void _syncPlaylistSelectionOverlay({ @@ -113,35 +91,15 @@ extension _QueueTabSelectionActions on _QueueTabState { _playlistSelectionOverlayItems = playlists; _playlistSelectionOverlayBottomPadding = bottomPadding; - - if (_playlistSelectionOverlayEntry != null) { - _playlistSelectionOverlayEntry!.markNeedsBuild(); - return; - } - - final overlay = Overlay.of(context, rootOverlay: true); - _playlistSelectionOverlayEntry = OverlayEntry( - builder: (overlayContext) { - final colorScheme = Theme.of(context).colorScheme; - return Positioned( - left: 0, - right: 0, - bottom: 0, - child: AnimatedSelectionBottomBar( - child: Material( - color: Colors.transparent, - child: _buildPlaylistSelectionBottomBar( - context, - colorScheme, - _playlistSelectionOverlayItems, - _playlistSelectionOverlayBottomPadding, - ), - ), - ), - ); - }, + _playlistSelectionOverlay.show( + context, + (_) => _buildPlaylistSelectionBottomBar( + context, + Theme.of(context).colorScheme, + _playlistSelectionOverlayItems, + _playlistSelectionOverlayBottomPadding, + ), ); - overlay.insert(_playlistSelectionOverlayEntry!); } void _enterPlaylistSelectionMode(String playlistId) { diff --git a/lib/widgets/selection_bottom_bar.dart b/lib/widgets/selection_bottom_bar.dart index 6c935581..43f3120d 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/theme/app_tokens.dart'; +import 'package:spotiflac_android/widgets/app_bottom_sheet.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; +/// Mounts a selection bar in the **root** overlay. +/// +/// The bar has to float above the shell's navigation bar, which lives outside +/// the per-tab navigators, so an in-tree `Positioned` inside the screen is not +/// enough. Five screens each solved this differently (two hand-rolled +/// `OverlayEntry` blocks, two `AnimatedPositioned` stacks and one bespoke +/// `Positioned` container); they now all go through this controller, which also +/// guarantees the same entrance animation everywhere. +class SelectionOverlayController { + OverlayEntry? _entry; + WidgetBuilder? _builder; + + bool get isVisible => _entry != null; + + /// Shows the bar, or rebuilds it in place when already visible so the + /// entrance animation does not replay on every selection change. + void show(BuildContext context, WidgetBuilder builder) { + _builder = builder; + if (_entry != null) { + _entry!.markNeedsBuild(); + return; + } + _entry = OverlayEntry( + builder: (overlayContext) => Positioned( + left: 0, + right: 0, + bottom: 0, + child: AnimatedSelectionBottomBar( + child: Material( + color: Colors.transparent, + child: _builder!(overlayContext), + ), + ), + ), + ); + Overlay.of(context, rootOverlay: true).insert(_entry!); + } + + void hide() { + _entry?.remove(); + _entry = null; + _builder = null; + } + + /// Call from the host `State.dispose`; an entry left in the root overlay + /// outlives the screen that created it. + void dispose() => hide(); +} + /// Entrance animation shared by selection bars mounted in the root overlay. class AnimatedSelectionBottomBar extends StatefulWidget { const AnimatedSelectionBottomBar({super.key, required this.child}); @@ -83,15 +134,18 @@ class SelectionBottomBar extends StatelessWidget { @override Widget build(BuildContext context) { + final tokens = context.tokens; final colorScheme = Theme.of(context).colorScheme; return Container( decoration: BoxDecoration( color: colorScheme.surfaceContainerHigh, - borderRadius: const BorderRadius.vertical(top: Radius.circular(28)), + borderRadius: BorderRadius.vertical( + top: Radius.circular(tokens.radiusSheet), + ), boxShadow: [ BoxShadow( - color: Colors.black.withValues(alpha: 0.15), + color: colorScheme.shadow.withValues(alpha: 0.15), blurRadius: 12, offset: const Offset(0, -4), ), @@ -104,15 +158,7 @@ class SelectionBottomBar extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Container( - width: 32, - height: 4, - margin: const EdgeInsets.only(bottom: 16), - decoration: BoxDecoration( - color: colorScheme.outlineVariant, - borderRadius: BorderRadius.circular(2), - ), - ), + const AppSheetHandle(), Row( children: [