From f8acd8f3b6f4c6ef678f33452c2d3239d95a89f3 Mon Sep 17 00:00:00 2001 From: zarzet Date: Wed, 4 Feb 2026 11:48:38 +0700 Subject: [PATCH] fix: show search filter bar only after results load --- CHANGELOG.md | 1 + lib/screens/home_tab.dart | 4 +- lib/screens/main_shell.dart | 278 +++++++++++++++++++++++++++++++----- 3 files changed, 243 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b5e89193..2c22105f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ ### Fixed +- Search filter bar now only appears after results load, not during loading - MP3/Ogg metadata parsing (ID3v2 extended headers, Ogg packet reassembly) - Library scan metadata (ISRC, disc number, release date) - Cover cache robustness (size + mtime cache key) diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index f3196adf..88842b41 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -631,8 +631,8 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ), ), - // Search filter bar (only shown when has search results or loading search) - if (searchFilters.isNotEmpty && (hasActualResults || isLoading)) + // Search filter bar (only shown when has search results) + if (searchFilters.isNotEmpty && hasActualResults) SliverToBoxAdapter( child: _buildSearchFilterBar( searchFilters, diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index 2b1e6d08..b2b0eb8d 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -63,16 +63,16 @@ class _MainShellState extends ConsumerState { void _handleSharedUrl(String url) { Navigator.of(context).popUntil((route) => route.isFirst); - + if (_currentIndex != 0) { _onNavTap(0); } ref.read(trackProvider.notifier).fetchFromUrl(url); ref.read(settingsProvider.notifier).setHasSearchedBefore(); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.loadingSharedLink)), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(context.l10n.loadingSharedLink))); } } @@ -83,7 +83,9 @@ class _MainShellState extends ConsumerState { final settings = ref.read(settingsProvider); if (!settings.checkForUpdates) return; - final updateInfo = await UpdateChecker.checkForUpdate(channel: settings.updateChannel); + final updateInfo = await UpdateChecker.checkForUpdate( + channel: settings.updateChannel, + ); if (updateInfo != null && mounted) { showUpdateDialog( context, @@ -104,6 +106,7 @@ class _MainShellState extends ConsumerState { void _onNavTap(int index) { if (_currentIndex != index) { + HapticFeedback.selectionClick(); setState(() => _currentIndex = index); _pageController.animateToPage( index, @@ -122,35 +125,38 @@ class _MainShellState extends ConsumerState { void _handleBackPress() { final trackState = ref.read(trackProvider); - + final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0; if (isKeyboardVisible) { FocusManager.instance.primaryFocus?.unfocus(); return; } - + if (_currentIndex == 0 && trackState.isShowingRecentAccess) { ref.read(trackProvider.notifier).setShowingRecentAccess(false); FocusManager.instance.primaryFocus?.unfocus(); return; } - - if (_currentIndex == 0 && !trackState.isLoading && (trackState.hasSearchText || trackState.hasContent)) { + + if (_currentIndex == 0 && + !trackState.isLoading && + (trackState.hasSearchText || trackState.hasContent)) { ref.read(trackProvider.notifier).clear(); return; } - + if (_currentIndex != 0) { _onNavTap(0); return; } - + if (trackState.isLoading) { return; } - + final now = DateTime.now(); - if (_lastBackPress != null && now.difference(_lastBackPress!) < const Duration(seconds: 2)) { + if (_lastBackPress != null && + now.difference(_lastBackPress!) < const Duration(seconds: 2)) { SystemNavigator.pop(); } else { _lastBackPress = now; @@ -166,19 +172,26 @@ class _MainShellState extends ConsumerState { @override Widget build(BuildContext context) { - final queueState = ref.watch(downloadQueueProvider.select((s) => s.queuedCount)); + final queueState = ref.watch( + downloadQueueProvider.select((s) => s.queuedCount), + ); final trackState = ref.watch(trackProvider); - final showStore = ref.watch(settingsProvider.select((s) => s.showExtensionStore)); - final storeUpdatesCount = ref.watch(storeProvider.select((s) => s.updatesAvailableCount)); - + final showStore = ref.watch( + settingsProvider.select((s) => s.showExtensionStore), + ); + final storeUpdatesCount = ref.watch( + storeProvider.select((s) => s.updatesAvailableCount), + ); + final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0; - - final canPop = _currentIndex == 0 && - !trackState.hasSearchText && - !trackState.hasContent && - !trackState.isLoading && - !trackState.isShowingRecentAccess && - !isKeyboardVisible; + + final canPop = + _currentIndex == 0 && + !trackState.hasSearchText && + !trackState.hasContent && + !trackState.isLoading && + !trackState.isShowingRecentAccess && + !isKeyboardVisible; final tabs = [ const HomeTab(), @@ -195,7 +208,7 @@ class _MainShellState extends ConsumerState { final destinations = [ NavigationDestination( icon: const Icon(Icons.home_outlined), - selectedIcon: const Icon(Icons.home), + selectedIcon: BouncingIcon(child: const Icon(Icons.home)), label: l10n.navHome, ), NavigationDestination( @@ -204,10 +217,12 @@ class _MainShellState extends ConsumerState { label: Text('$queueState'), child: const Icon(Icons.library_music_outlined), ), - selectedIcon: Badge( - isLabelVisible: queueState > 0, - label: Text('$queueState'), - child: const Icon(Icons.library_music), + selectedIcon: SlidingIcon( + child: Badge( + isLabelVisible: queueState > 0, + label: Text('$queueState'), + child: const Icon(Icons.library_music), + ), ), label: l10n.navLibrary, ), @@ -218,16 +233,18 @@ class _MainShellState extends ConsumerState { label: Text('$storeUpdatesCount'), child: const Icon(Icons.store_outlined), ), - selectedIcon: Badge( - isLabelVisible: storeUpdatesCount > 0, - label: Text('$storeUpdatesCount'), - child: const Icon(Icons.store), + selectedIcon: SwingIcon( + child: Badge( + isLabelVisible: storeUpdatesCount > 0, + label: Text('$storeUpdatesCount'), + child: const Icon(Icons.store), + ), ), label: l10n.navStore, ), NavigationDestination( icon: const Icon(Icons.settings_outlined), - selectedIcon: const Icon(Icons.settings), + selectedIcon: SpinIcon(child: const Icon(Icons.settings)), label: l10n.navSettings, ), ]; @@ -248,7 +265,7 @@ class _MainShellState extends ConsumerState { if (didPop) { return; } - + _handleBackPress(); }, child: Scaffold( @@ -261,13 +278,198 @@ class _MainShellState extends ConsumerState { bottomNavigationBar: NavigationBar( selectedIndex: _currentIndex.clamp(0, maxIndex), onDestinationSelected: _onNavTap, - animationDuration: const Duration(milliseconds: 200), + animationDuration: const Duration(milliseconds: 500), backgroundColor: Theme.of(context).brightness == Brightness.dark - ? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), Theme.of(context).colorScheme.surface) - : Color.alphaBlend(Colors.black.withValues(alpha: 0.03), Theme.of(context).colorScheme.surface), + ? Color.alphaBlend( + Colors.white.withValues(alpha: 0.05), + Theme.of(context).colorScheme.surface, + ) + : Color.alphaBlend( + Colors.black.withValues(alpha: 0.03), + Theme.of(context).colorScheme.surface, + ), destinations: destinations, ), ), ); } } + +class BouncingIcon extends StatefulWidget { + final Widget child; + const BouncingIcon({super.key, required this.child}); + + @override + State createState() => _BouncingIconState(); +} + +class _BouncingIconState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _scaleAnimation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: const Duration(milliseconds: 400), + vsync: this, + ); + _scaleAnimation = Tween( + begin: 0.1, + end: 1.0, + ).animate(CurvedAnimation(parent: _controller, curve: Curves.elasticOut)); + _controller.forward(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ScaleTransition(scale: _scaleAnimation, child: widget.child); + } +} + +class SlidingIcon extends StatefulWidget { + final Widget child; + const SlidingIcon({super.key, required this.child}); + + @override + State createState() => _SlidingIconState(); +} + +class _SlidingIconState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _offsetAnimation; + late Animation _fadeAnimation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: const Duration(milliseconds: 350), + vsync: this, + ); + _offsetAnimation = Tween( + begin: const Offset(0, 0.5), + end: Offset.zero, + ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOutBack)); + _fadeAnimation = Tween( + begin: 0.0, + end: 1.0, + ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut)); + _controller.forward(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return FadeTransition( + opacity: _fadeAnimation, + child: SlideTransition(position: _offsetAnimation, child: widget.child), + ); + } +} + +class SwingIcon extends StatefulWidget { + final Widget child; + const SwingIcon({super.key, required this.child}); + + @override + State createState() => _SwingIconState(); +} + +class _SwingIconState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _rotationAnimation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: const Duration(milliseconds: 600), + vsync: this, + ); + // Create a swinging motion (like a pendulum/sign) + _rotationAnimation = TweenSequence([ + TweenSequenceItem(tween: Tween(begin: 0.0, end: -0.2), weight: 20), + TweenSequenceItem(tween: Tween(begin: -0.2, end: 0.15), weight: 20), + TweenSequenceItem(tween: Tween(begin: 0.15, end: -0.1), weight: 20), + TweenSequenceItem(tween: Tween(begin: -0.1, end: 0.05), weight: 20), + TweenSequenceItem(tween: Tween(begin: 0.05, end: 0.0), weight: 20), + ]).animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut)); + + _controller.forward(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _rotationAnimation, + builder: (context, child) { + return Transform.rotate( + angle: _rotationAnimation.value, + alignment: Alignment.topCenter, + child: child, + ); + }, + child: widget.child, + ); + } +} + +class SpinIcon extends StatefulWidget { + final Widget child; + const SpinIcon({super.key, required this.child}); + + @override + State createState() => _SpinIconState(); +} + +class _SpinIconState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _rotationAnimation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: const Duration(milliseconds: 500), + vsync: this, + ); + _rotationAnimation = Tween( + begin: 0.0, + end: 0.5, + ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOutBack)); + _controller.forward(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return RotationTransition(turns: _rotationAnimation, child: widget.child); + } +}