diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index ce77d2b4..298b3e67 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -765,22 +765,25 @@ class _HomeTabState extends ConsumerState ), ); - final hasExploreContent = ref.watch( - exploreProvider.select((s) => s.sections.isNotEmpty), - ); - final exploreLoading = ref.watch( - exploreProvider.select((s) => s.isLoading), - ); - final hasHomeFeedExtension = ref.watch( - extensionProvider.select( - (s) => s.extensions.any((e) => e.enabled && e.hasHomeFeed), - ), - ); - final homeFeedDisabled = ref.watch( - settingsProvider.select( - (s) => s.homeFeedProvider == AppSettings.homeFeedProviderOff, - ), - ); + final hasExploreContent = + _showsHome && + ref.watch(exploreProvider.select((s) => s.sections.isNotEmpty)); + final exploreLoading = + _showsHome && ref.watch(exploreProvider.select((s) => s.isLoading)); + final hasHomeFeedExtension = + _showsHome && + ref.watch( + extensionProvider.select( + (s) => s.extensions.any((e) => e.enabled && e.hasHomeFeed), + ), + ); + final homeFeedDisabled = + _showsHome && + ref.watch( + settingsProvider.select( + (s) => s.homeFeedProvider == AppSettings.homeFeedProviderOff, + ), + ); final colorScheme = Theme.of(context).colorScheme; final searchText = _showsSearch ? _urlController.text.trim() : ''; @@ -804,9 +807,11 @@ class _HomeTabState extends ConsumerState _showsSearch && ref.watch(trackProvider.select((s) => s.isShowingRecentAccess)); final screenHeight = MediaQuery.sizeOf(context).height; - final hasHistoryItems = ref.watch( - _homeHistoryPreviewProvider.select((items) => items.isNotEmpty), - ); + final hasHistoryItems = + _showsHome && + ref.watch( + _homeHistoryPreviewProvider.select((items) => items.isNotEmpty), + ); final recentModeRequested = widget.mode == HomeTabMode.search || @@ -982,9 +987,7 @@ class _HomeTabState extends ConsumerState final recentAccessView = ref.watch( recentAccessViewProvider, ); - return SliverToBoxAdapter( - child: _buildRecentAccess(recentAccessView, colorScheme), - ); + return _buildRecentAccess(recentAccessView, colorScheme); }, ), @@ -1113,6 +1116,7 @@ class _HomeTabState extends ConsumerState return SliverMainAxisGroup( slivers: _buildSearchResults( + resultsRef: ref, tracks: tracks, isLoading: isLoading, error: error, diff --git a/lib/screens/home_tab_recent.dart b/lib/screens/home_tab_recent.dart index fb985976..c57a6620 100644 --- a/lib/screens/home_tab_recent.dart +++ b/lib/screens/home_tab_recent.dart @@ -2,12 +2,43 @@ part of 'home_tab.dart'; extension _HomeTabRecentUI on _HomeTabState { Widget _buildRecentAccess(_RecentAccessView view, ColorScheme colorScheme) { + return SliverMainAxisGroup( + slivers: [ + SliverToBoxAdapter(child: _buildRecentAccessHeader(view, colorScheme)), + if (view.uniqueItems.isNotEmpty) + SliverPadding( + padding: + const EdgeInsets.fromLTRB(16, 0, 16, 8) + + EdgeInsets.symmetric(horizontal: wideListInset(context)), + sliver: SliverList.builder( + itemCount: view.uniqueItems.length, + itemBuilder: (context, index) { + final item = view.uniqueItems[index]; + return KeyedSubtree( + key: ValueKey('${item.type.name}:${item.id}'), + child: _buildRecentAccessItem( + item, + colorScheme, + view.downloadFilePathByRecentKey, + ), + ); + }, + ), + ), + ], + ); + } + + Widget _buildRecentAccessHeader( + _RecentAccessView view, + ColorScheme colorScheme, + ) { final uniqueItems = view.uniqueItems; final hasHiddenDownloads = view.hasHiddenDownloads; return Padding( padding: - const EdgeInsets.fromLTRB(16, 8, 16, 8) + + EdgeInsets.fromLTRB(16, 8, 16, uniqueItems.isEmpty ? 8 : 0) + EdgeInsets.symmetric(horizontal: wideListInset(context)), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -99,14 +130,6 @@ extension _HomeTabRecentUI on _HomeTabState { ], ), ), - ) - else - ...uniqueItems.map( - (item) => _buildRecentAccessItem( - item, - colorScheme, - view.downloadFilePathByRecentKey, - ), ), ], ), diff --git a/lib/screens/home_tab_search_results.dart b/lib/screens/home_tab_search_results.dart index 2da84cd1..8d600dc4 100644 --- a/lib/screens/home_tab_search_results.dart +++ b/lib/screens/home_tab_search_results.dart @@ -299,6 +299,7 @@ extension _HomeTabSearchResultsUI on _HomeTabState { } List _buildSearchResults({ + required WidgetRef resultsRef, required List tracks, required bool isLoading, required String? error, @@ -418,7 +419,7 @@ extension _HomeTabSearchResultsUI on _HomeTabState { if (sortedTracks.isNotEmpty) { _historySnapshot.update(sortedTracks); final historyLookups = _historySnapshot.lookups; - final existingHistoryKeys = ref.watch( + final existingHistoryKeys = resultsRef.watch( downloadHistoryVisibleBatchExistsProvider(_historySnapshot.request), ); slivers.addAll( @@ -540,6 +541,7 @@ extension _HomeTabSearchResultsUI on _HomeTabState { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: MornyeGlassPanel.overlay( + blurEnabled: false, radius: 24, firstInGroup: isFirst, lastInGroup: isLast, diff --git a/lib/screens/home_tab_widgets.dart b/lib/screens/home_tab_widgets.dart index 9ba0093f..edba0c8c 100644 --- a/lib/screens/home_tab_widgets.dart +++ b/lib/screens/home_tab_widgets.dart @@ -219,9 +219,9 @@ class _TrackItemWithStatus extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final colorScheme = Theme.of(context).colorScheme; - final queueItem = ref.watch( + final isQueued = ref.watch( downloadQueueLookupProvider.select( - (lookup) => lookup.byTrackId[track.id], + (lookup) => lookup.byTrackId.containsKey(track.id), ), ); @@ -249,7 +249,6 @@ class _TrackItemWithStatus extends ConsumerWidget { thumbHeight = thumbSize.$2; } - final isQueued = queueItem != null; final hasCover = track.coverUrl?.isNotEmpty == true; return Column( diff --git a/lib/widgets/mornye_chrome.dart b/lib/widgets/mornye_chrome.dart index e40e6e41..24da120a 100644 --- a/lib/widgets/mornye_chrome.dart +++ b/lib/widgets/mornye_chrome.dart @@ -131,6 +131,7 @@ class MornyeGlassPanel extends ConsumerWidget { this.strongTint = false, this.tintOpacity, this.liquidGlass = false, + this.blurEnabled = true, }); /// Shared glass material for confirmation dialogs and floating sheets. @@ -142,6 +143,7 @@ class MornyeGlassPanel extends ConsumerWidget { this.lastInGroup = true, this.tintOpacity = 0.78, this.liquidGlass = false, + this.blurEnabled = true, }) : strongTint = false; final Widget child; @@ -152,9 +154,13 @@ class MornyeGlassPanel extends ConsumerWidget { final double? tintOpacity; final bool liquidGlass; + /// Disable backdrop sampling for surfaces that scroll over a plain page. + final bool blurEnabled; + @override Widget build(BuildContext context, WidgetRef ref) { final blur = + blurEnabled && !MediaQuery.highContrastOf(context) && (!ref.watch(lowEndDeviceProvider) || ref.watch(backdropBlurEnabledProvider)); diff --git a/test/home_search_tabs_test.dart b/test/home_search_tabs_test.dart index daf2434f..0047d182 100644 --- a/test/home_search_tabs_test.dart +++ b/test/home_search_tabs_test.dart @@ -4,14 +4,17 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:spotiflac_android/l10n/app_localizations.dart'; import 'package:spotiflac_android/models/settings.dart'; import 'package:spotiflac_android/models/track.dart'; -import 'package:spotiflac_android/providers/download_history_provider.dart'; +import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/explore_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; +import 'package:spotiflac_android/providers/music_player_provider.dart'; import 'package:spotiflac_android/providers/recent_access_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/track_provider.dart'; import 'package:spotiflac_android/screens/home_tab.dart'; import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/app_search_field.dart'; +import 'package:spotiflac_android/widgets/mornye_chrome.dart'; void main() { testWidgets( @@ -124,6 +127,128 @@ void main() { expect(find.text('Found artist'), findsOneWidget); expect(tester.takeException(), isNull); }); + + for (final (brightness, itemType) in [ + for (final brightness in Brightness.values) + for (final itemType in ['artist', 'track']) (brightness, itemType), + ]) { + testWidgets( + 'Search keeps field glass and scrolls $itemType results without blur in $brightness', + (tester) async { + tester.view.physicalSize = const Size(430, 650); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final search = _Search(resultCount: 120, itemType: itemType); + await tester.pumpWidget( + ProviderScope( + overrides: [ + settingsProvider.overrideWith(_Settings.new), + extensionProvider.overrideWith(_Extensions.new), + exploreProvider.overrideWith(_Explore.new), + downloadHistoryProvider.overrideWith(_History.new), + downloadHistoryBatchExistsProvider.overrideWith( + (ref, request) async => {}, + ), + downloadQueueLookupProvider.overrideWith( + (ref) => DownloadQueueState().lookup, + ), + currentMediaItemProvider.overrideWith( + (ref) => Stream.value(null), + ), + recentAccessProvider.overrideWith(_Recent.new), + trackProvider.overrideWith(() => search), + ], + child: MaterialApp( + theme: MornyeTheme.build(brightness), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const HomeTab(mode: HomeTabMode.search), + ), + ), + ); + await tester.pumpAndSettle(); + expect( + find.descendant( + of: find.byType(AppSearchField), + matching: find.byType(BackdropFilter), + ), + findsOneWidget, + ); + await tester.enterText(find.byType(TextField), 'Example'); + await tester.testTextInput.receiveAction(TextInputAction.search); + await tester.pumpAndSettle(); + expect(find.text('Found $itemType 0'), findsOneWidget); + expect(find.text('Found $itemType 119'), findsNothing); + expect( + find.descendant( + of: find.ancestor( + of: find.text('Found $itemType 0'), + matching: find.byType(MornyeGlassPanel), + ), + matching: find.byType(BackdropFilter), + ), + findsNothing, + ); + + await tester.scrollUntilVisible( + find.text('Found $itemType 119'), + 500, + scrollable: find.byType(Scrollable).first, + ); + await tester.pumpAndSettle(); + expect(find.text('Found $itemType 119').hitTestable(), findsOneWidget); + expect(search._requests, 1); + expect( + find.descendant( + of: find.ancestor( + of: find.text('Found $itemType 119'), + matching: find.byType(MornyeGlassPanel), + ), + matching: find.byType(BackdropFilter), + ), + findsNothing, + ); + expect(tester.takeException(), isNull); + }, + ); + } + + testWidgets('Search builds recent items as they enter the viewport', ( + tester, + ) async { + tester.view.physicalSize = const Size(430, 600); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpWidget( + ProviderScope( + overrides: [ + settingsProvider.overrideWith(_Settings.new), + extensionProvider.overrideWith(_Extensions.new), + exploreProvider.overrideWith(_Explore.new), + downloadHistoryProvider.overrideWith(_History.new), + recentAccessProvider.overrideWith(() => _Recent(itemCount: 10)), + trackProvider.overrideWith(_Search.new), + ], + child: MaterialApp( + theme: MornyeTheme.build(Brightness.dark), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const HomeTab(mode: HomeTabMode.search), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Recent artist 0'), findsOneWidget); + expect(find.text('Recent artist 9'), findsNothing); + await tester.scrollUntilVisible( + find.text('Recent artist 9'), + 300, + scrollable: find.byType(Scrollable).first, + ); + await tester.pumpAndSettle(); + expect(find.text('Recent artist 9').hitTestable(), findsOneWidget); + expect(tester.takeException(), isNull); + }); } class _Tabs extends StatefulWidget { @@ -211,25 +336,38 @@ class _History extends DownloadHistoryNotifier { } class _Recent extends RecentAccessNotifier { + _Recent({this.itemCount = 1}); + + final int itemCount; + @override RecentAccessState build() => RecentAccessState( isLoaded: true, - items: [ - RecentAccessItem( - id: 'recent', - name: 'Recently visited artist', + items: List.generate( + itemCount, + (index) => RecentAccessItem( + id: 'recent-$index', + name: itemCount == 1 + ? 'Recently visited artist' + : 'Recent artist $index', type: RecentAccessType.artist, - accessedAt: DateTime(2026), + accessedAt: DateTime(2026, 1, itemCount - index), providerId: 'example', ), - ], + ), ); } class _Search extends TrackNotifier { - _Search({bool failFirst = false}) : _failFirst = failFirst; + _Search({ + bool failFirst = false, + this.resultCount = 1, + this.itemType = 'artist', + }) : _failFirst = failFirst; final bool _failFirst; + final int resultCount; + final String itemType; int _requests = 0; @override @@ -248,20 +386,21 @@ class _Search extends TrackNotifier { ); return; } - state = const TrackState( + state = TrackState( hasSearchText: true, searchExtensionId: 'example', - tracks: [ - Track( - id: 'artist', - name: 'Found artist', + tracks: List.generate( + resultCount, + (index) => Track( + id: '$itemType-$index', + name: resultCount == 1 ? 'Found $itemType' : 'Found $itemType $index', artistName: '', albumName: '', duration: 0, - itemType: 'artist', + itemType: itemType, source: 'example', ), - ], + ), ); } }